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.
Files changed (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/admin/router.py ADDED
@@ -0,0 +1,528 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+ from typing import Annotated, Callable
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
8
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
9
+ from pydantic import BaseModel, Field, SecretStr
10
+
11
+ from app.admin.advanced_settings import AdvancedSettings, FileAdvancedSettingsStore
12
+ from app.admin.database_browser import ConversationDatabaseBrowser
13
+ from app.config import Settings
14
+ from app.models import ModelName, UiLanguage
15
+ from app.monitoring.events import EventLogger
16
+ from app.monitoring.log_buffer import InMemoryLogBuffer
17
+ from app.projects.registry import (
18
+ WEBHOOK_TOKEN_HASH_PREFIX_LENGTH,
19
+ ProjectRecord,
20
+ ProjectRegistry,
21
+ mask_bot_token,
22
+ )
23
+ from app.telegram.bot_instances import BotInstanceManager
24
+ from app.telegram.conversation import SQLiteConversationStore
25
+ from app.telegram.webhook_registration import TelegramWebhookRegistrar
26
+
27
+ _adminlog = EventLogger("app.admin", "admin.ui")
28
+ _monitorlog = EventLogger("app.admin.monitoring", "monitoring.ui")
29
+
30
+
31
+ def _client_host(request: Request) -> str:
32
+ if request.client is None:
33
+ return ""
34
+ return request.client.host or ""
35
+
36
+
37
+ def require_localhost(request: Request) -> None:
38
+ host = _client_host(request)
39
+ if host in ("127.0.0.1", "::1", "localhost", "testclient"):
40
+ return
41
+ raise HTTPException(status_code=403, detail="The admin UI is only available from localhost.")
42
+
43
+
44
+ LocalhostOnly = Annotated[None, Depends(require_localhost)]
45
+
46
+ _DEFAULT_NEW_PROJECT_WEBHOOK_SECRET = "optional-secret"
47
+
48
+
49
+ class ProjectUpsertBody(BaseModel):
50
+ name: str
51
+ root_path: str
52
+ worktree_base_dir: str
53
+ default_model: ModelName = ModelName.CLAUDE
54
+ enabled: bool = True
55
+ bot_token: str | None = None
56
+ webhook_secret: str | None = None
57
+ allowed_chat_ids: list[int] | None = None
58
+ allowed_user_ids: list[int] | None = None
59
+
60
+
61
+ class DefaultProjectBody(BaseModel):
62
+ name: str = Field(min_length=1)
63
+
64
+
65
+ _ADMIN_ICON_NAMES = frozenset(
66
+ {"home.svg", "projects.svg", "advanced.svg", "logs.svg", "database.svg", "download.svg"}
67
+ )
68
+
69
+
70
+ @lru_cache(maxsize=8)
71
+ def _load_template_html(template_name: str) -> str:
72
+ template_path = Path(__file__).parent / "templates" / template_name
73
+ return template_path.read_text(encoding="utf-8")
74
+
75
+
76
+ def _render_admin_page(template_name: str, lang: UiLanguage) -> str:
77
+ html = _load_template_html(template_name)
78
+ inject = f'<script>window.__UI_LANG__="{lang.value}";</script>\n</head>'
79
+ return html.replace("</head>", inject, 1)
80
+
81
+
82
+ def _sync_bot_instance(manager: BotInstanceManager | None, record: ProjectRecord) -> None:
83
+ if manager is None:
84
+ return
85
+ if record.enabled:
86
+ manager.register(record)
87
+ else:
88
+ manager.unregister(record.name)
89
+
90
+
91
+ def _sync_project_webhook(
92
+ registrar: TelegramWebhookRegistrar | None,
93
+ record: ProjectRecord,
94
+ ) -> None:
95
+ if registrar is None or not record.enabled:
96
+ return
97
+ if not registrar.sync_project(record):
98
+ _adminlog.warning("project webhook sync failed name=%s", record.name, project=record.name)
99
+
100
+
101
+ def create_admin_router(
102
+ settings: Settings,
103
+ registry: ProjectRegistry,
104
+ advanced_settings_store: FileAdvancedSettingsStore,
105
+ log_buffer: InMemoryLogBuffer,
106
+ conversation_store: SQLiteConversationStore,
107
+ bot_instance_manager: BotInstanceManager | None = None,
108
+ webhook_registrar: TelegramWebhookRegistrar | None = None,
109
+ bot_commands_builder: Callable[[UiLanguage], list[dict[str, str]]] | None = None,
110
+ ) -> APIRouter:
111
+ router = APIRouter(tags=["admin"])
112
+
113
+ def _ui_language() -> UiLanguage:
114
+ return advanced_settings_store.get().ui_language
115
+
116
+ @router.get("/", response_class=HTMLResponse)
117
+ def admin_hub(_: LocalhostOnly) -> str:
118
+ _adminlog.info("page served path=/")
119
+ return _render_admin_page("admin.html", _ui_language())
120
+
121
+ @router.get("/projects", response_class=HTMLResponse)
122
+ def admin_projects(_: LocalhostOnly) -> str:
123
+ _adminlog.info("page served path=/projects")
124
+ return _render_admin_page("projects.html", _ui_language())
125
+
126
+ @router.get("/advanced", response_class=HTMLResponse)
127
+ def admin_advanced(_: LocalhostOnly) -> str:
128
+ _adminlog.info("page served path=/advanced")
129
+ return _render_admin_page("advanced.html", _ui_language())
130
+
131
+ @router.get("/logs", response_class=HTMLResponse)
132
+ def admin_logs(_: LocalhostOnly) -> str:
133
+ _adminlog.info("page served path=/logs")
134
+ return _render_admin_page("logs.html", _ui_language())
135
+
136
+ @router.get("/database", response_class=HTMLResponse)
137
+ def admin_database(_: LocalhostOnly) -> str:
138
+ _adminlog.info("page served path=/database")
139
+ return _render_admin_page("database.html", _ui_language())
140
+
141
+ @router.get("/api/database/tables")
142
+ def api_database_tables(_: LocalhostOnly) -> dict[str, object]:
143
+ browser = ConversationDatabaseBrowser(conversation_store.db_path)
144
+ payload = browser.tables_payload()
145
+ _monitorlog.info("database tables queried count=%d", len(payload.get("tables", [])))
146
+ return payload
147
+
148
+ @router.get("/api/database/filter-options")
149
+ def api_database_filter_options(
150
+ _: LocalhostOnly,
151
+ table: str = Query(..., min_length=1, max_length=64),
152
+ ) -> dict[str, object]:
153
+ browser = ConversationDatabaseBrowser(conversation_store.db_path)
154
+ try:
155
+ payload = browser.distinct_filter_options(table)
156
+ _monitorlog.info("database filter options queried table=%s", table)
157
+ return payload
158
+ except ValueError as exc:
159
+ _monitorlog.warning("database filter options failed table=%s err=%s", table, exc)
160
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
161
+
162
+ @router.get("/api/database/rows")
163
+ def api_database_rows(
164
+ _: LocalhostOnly,
165
+ table: str = Query(..., min_length=1, max_length=64),
166
+ project: str | None = Query(None, max_length=200),
167
+ chat_id: int | None = Query(None),
168
+ role: str | None = Query(None, max_length=64),
169
+ job_id: str | None = Query(None, max_length=200),
170
+ q: str | None = Query(None, max_length=500),
171
+ sort: str | None = Query(None, max_length=64),
172
+ order: str = Query("desc"),
173
+ limit: int = Query(50, ge=1, le=200),
174
+ offset: int = Query(0, ge=0),
175
+ ) -> dict[str, object]:
176
+ if order not in ("asc", "desc"):
177
+ raise HTTPException(status_code=422, detail="order must be asc or desc.")
178
+ browser = ConversationDatabaseBrowser(conversation_store.db_path)
179
+ try:
180
+ payload = browser.query_rows(
181
+ table,
182
+ project=project,
183
+ chat_id=chat_id,
184
+ role=role,
185
+ job_id=job_id,
186
+ q=q,
187
+ sort=sort,
188
+ order=order,
189
+ limit=limit,
190
+ offset=offset,
191
+ )
192
+ _monitorlog.info(
193
+ "database rows queried table=%s rows=%d limit=%d offset=%d filters=%d",
194
+ table,
195
+ len(payload.get("rows", [])),
196
+ limit,
197
+ offset,
198
+ sum(v is not None for v in (project, chat_id, role, job_id, q)),
199
+ chat_id=chat_id,
200
+ job_id=job_id,
201
+ project=project,
202
+ )
203
+ return payload
204
+ except ValueError as exc:
205
+ _monitorlog.warning("database rows query failed table=%s err=%s", table, exc)
206
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
207
+
208
+ @router.get("/api/database/export.csv")
209
+ def api_database_export_csv(
210
+ _: LocalhostOnly,
211
+ table: str = Query(..., min_length=1, max_length=64),
212
+ project: str | None = Query(None, max_length=200),
213
+ chat_id: int | None = Query(None),
214
+ role: str | None = Query(None, max_length=64),
215
+ job_id: str | None = Query(None, max_length=200),
216
+ q: str | None = Query(None, max_length=500),
217
+ sort: str | None = Query(None, max_length=64),
218
+ order: str = Query("desc"),
219
+ max_rows: int = Query(50_000, ge=1, le=100_000),
220
+ ) -> StreamingResponse:
221
+ if order not in ("asc", "desc"):
222
+ raise HTTPException(status_code=422, detail="order must be asc or desc.")
223
+ browser = ConversationDatabaseBrowser(conversation_store.db_path)
224
+ try:
225
+ stream = browser.iter_csv_rows(
226
+ table,
227
+ project=project,
228
+ chat_id=chat_id,
229
+ role=role,
230
+ job_id=job_id,
231
+ q=q,
232
+ sort=sort,
233
+ order=order,
234
+ max_rows=max_rows,
235
+ )
236
+ except ValueError as exc:
237
+ _monitorlog.warning("database export failed table=%s err=%s", table, exc)
238
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
239
+ safe_name = table.replace("/", "_").replace("\\", "_")[:80] or "export"
240
+ _monitorlog.info(
241
+ "database export started table=%s max_rows=%d filters=%d",
242
+ table,
243
+ max_rows,
244
+ sum(v is not None for v in (project, chat_id, role, job_id, q)),
245
+ chat_id=chat_id,
246
+ job_id=job_id,
247
+ project=project,
248
+ )
249
+ return StreamingResponse(
250
+ stream,
251
+ media_type="text/csv; charset=utf-8",
252
+ headers={
253
+ "Content-Disposition": f'attachment; filename="{safe_name}_export.csv"',
254
+ },
255
+ )
256
+
257
+ @router.get("/api/logs")
258
+ def api_logs(
259
+ _: LocalhostOnly,
260
+ limit: int = Query(200, ge=1, le=1000),
261
+ after_id: int | None = Query(None, ge=1),
262
+ level: str | None = Query(None, description="Minimum log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"),
263
+ q: str | None = Query(None, max_length=500),
264
+ logger: str | None = Query(None, max_length=200),
265
+ chat_id: int | None = Query(None),
266
+ user_id: int | None = Query(None),
267
+ job_id: str | None = Query(None, max_length=200),
268
+ project: str | None = Query(None, max_length=200),
269
+ category: str | None = Query(None, max_length=64),
270
+ ) -> dict[str, object]:
271
+ q_clean = q.strip() if q else None
272
+ if q_clean == "":
273
+ q_clean = None
274
+ logger_clean = logger.strip() if logger else None
275
+ if logger_clean == "":
276
+ logger_clean = None
277
+ level_clean = level.strip() if level else None
278
+ if level_clean == "":
279
+ level_clean = None
280
+ job_id_clean = job_id.strip() if job_id else None
281
+ if job_id_clean == "":
282
+ job_id_clean = None
283
+ project_clean = project.strip() if project else None
284
+ if project_clean == "":
285
+ project_clean = None
286
+ category_clean = category.strip() if category else None
287
+ if category_clean == "":
288
+ category_clean = None
289
+ try:
290
+ entries, max_seen = log_buffer.query(
291
+ limit=limit,
292
+ after_id=after_id,
293
+ min_level=level_clean,
294
+ q=q_clean,
295
+ logger_sub=logger_clean,
296
+ chat_id=chat_id,
297
+ user_id=user_id,
298
+ job_id=job_id_clean,
299
+ project=project_clean,
300
+ category=category_clean,
301
+ )
302
+ except ValueError as exc:
303
+ _monitorlog.warning("logs query failed err=%s", exc)
304
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
305
+ _monitorlog.info(
306
+ "logs queried entries=%d max_id=%d after_id=%s limit=%d level=%s logger=%s category=%s",
307
+ len(entries),
308
+ max_seen,
309
+ after_id or "-",
310
+ limit,
311
+ level_clean or "-",
312
+ logger_clean or "-",
313
+ category_clean or "-",
314
+ chat_id=chat_id,
315
+ user_id=user_id,
316
+ job_id=job_id_clean,
317
+ project=project_clean,
318
+ )
319
+ return {
320
+ "entries": entries,
321
+ "max_id": max_seen,
322
+ "max_entries": log_buffer.max_entries,
323
+ }
324
+
325
+ @router.get("/api/settings")
326
+ def api_settings(_: LocalhostOnly) -> dict:
327
+ token = (
328
+ settings.telegram_bot_token.get_secret_value()
329
+ if settings.telegram_bot_token is not None
330
+ else ""
331
+ )
332
+ _adminlog.info(
333
+ "settings queried env_allowlist_chats=%d env_allowlist_users=%d webhook_secret_set=%s default_model=%s",
334
+ len(settings.telegram_allowed_chat_ids),
335
+ len(settings.telegram_allowed_user_ids),
336
+ bool(settings.telegram_webhook_secret),
337
+ settings.default_model.value,
338
+ )
339
+ return {
340
+ "telegram_bot_token_masked": mask_bot_token(token),
341
+ "telegram_allowed_chat_ids": settings.telegram_allowed_chat_ids,
342
+ "telegram_allowed_user_ids": settings.telegram_allowed_user_ids,
343
+ "telegram_webhook_secret_set": bool(settings.telegram_webhook_secret),
344
+ "default_model_env": settings.default_model.value,
345
+ "job_timeout_seconds_env": settings.job_timeout_seconds,
346
+ "projects_config_path": str(registry.config_path),
347
+ "webhook_token_hash_prefix_length": WEBHOOK_TOKEN_HASH_PREFIX_LENGTH,
348
+ "webhook_route_template": "/telegram/webhook/{token_hash_prefix}",
349
+ "webhook_public_url_rule": "<public HTTPS Base URL> + each project's webhook_path",
350
+ "webhook_hint": "Each project (bot) has its own webhook_path and token_hash_prefix. "
351
+ "The full URL is the public Base joined with webhook_path. While remote-coder up is running, "
352
+ "registration/edits refresh it automatically. "
353
+ "Manual registration: python scripts/set_webhook.py <Base URL>",
354
+ "webhook_deleted_disabled_note": (
355
+ "When a project is disabled or deleted, the server no longer handles updates arriving "
356
+ "with that token_hash_prefix (match failure / 404). Even if Telegram calls the old URL, "
357
+ "this app ignores it. To clear the bot's webhook or point it at a new URL, run Bot API "
358
+ "deleteWebhook or rerun scripts/set_webhook.py with the updated registry."
359
+ ),
360
+ }
361
+
362
+ @router.get("/api/projects")
363
+ def api_projects_get(_: LocalhostOnly) -> JSONResponse:
364
+ _adminlog.info("projects queried count=%d", len(registry.list_projects()))
365
+ return JSONResponse(registry.to_public_dict())
366
+
367
+ @router.post("/api/projects")
368
+ def api_projects_create(body: ProjectUpsertBody, _: LocalhostOnly) -> JSONResponse:
369
+ if not body.bot_token or not body.bot_token.strip():
370
+ raise HTTPException(status_code=400, detail="bot_token is required")
371
+ if not body.allowed_chat_ids:
372
+ raise HTTPException(status_code=400, detail="allowed_chat_ids must have at least one entry")
373
+ wh_stripped = (body.webhook_secret or "").strip()
374
+ webhook_secret = SecretStr(
375
+ wh_stripped if wh_stripped else _DEFAULT_NEW_PROJECT_WEBHOOK_SECRET
376
+ )
377
+ record = ProjectRecord(
378
+ name=body.name,
379
+ root_path=body.root_path,
380
+ worktree_base_dir=body.worktree_base_dir,
381
+ default_model=body.default_model,
382
+ enabled=body.enabled,
383
+ bot_token=SecretStr(body.bot_token.strip()),
384
+ webhook_secret=webhook_secret,
385
+ allowed_chat_ids=list(body.allowed_chat_ids),
386
+ allowed_user_ids=list(body.allowed_user_ids or []),
387
+ )
388
+ try:
389
+ registry.add_project(record)
390
+ except ValueError as exc:
391
+ _adminlog.warning("project create failed name=%s err=%s", body.name, exc, project=body.name)
392
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
393
+ _adminlog.info("project created name=%s enabled=%s", body.name, body.enabled, project=body.name)
394
+ _sync_bot_instance(bot_instance_manager, record)
395
+ _sync_project_webhook(webhook_registrar, record)
396
+ return JSONResponse(registry.to_public_dict())
397
+
398
+ @router.put("/api/projects/{name}")
399
+ def api_projects_update(name: str, body: ProjectUpsertBody, _: LocalhostOnly) -> JSONResponse:
400
+ existing = registry.get(name)
401
+ if existing is None:
402
+ raise HTTPException(status_code=404, detail=f"unknown project: {name}")
403
+ bot_token = (
404
+ SecretStr(body.bot_token.strip())
405
+ if body.bot_token and body.bot_token.strip()
406
+ else existing.bot_token
407
+ )
408
+ if body.webhook_secret is not None:
409
+ webhook_secret = SecretStr(body.webhook_secret) if body.webhook_secret.strip() else None
410
+ else:
411
+ webhook_secret = existing.webhook_secret
412
+ allowed_chat_ids = (
413
+ list(body.allowed_chat_ids)
414
+ if body.allowed_chat_ids is not None
415
+ else existing.allowed_chat_ids
416
+ )
417
+ if not allowed_chat_ids:
418
+ raise HTTPException(status_code=400, detail="allowed_chat_ids must have at least one entry")
419
+ allowed_user_ids = (
420
+ list(body.allowed_user_ids)
421
+ if body.allowed_user_ids is not None
422
+ else existing.allowed_user_ids
423
+ )
424
+ record = ProjectRecord(
425
+ name=body.name,
426
+ root_path=body.root_path,
427
+ worktree_base_dir=body.worktree_base_dir,
428
+ default_model=body.default_model,
429
+ enabled=body.enabled,
430
+ bot_token=bot_token,
431
+ webhook_secret=webhook_secret,
432
+ allowed_chat_ids=allowed_chat_ids,
433
+ allowed_user_ids=allowed_user_ids,
434
+ )
435
+ try:
436
+ registry.update_project(name, record)
437
+ except ValueError as exc:
438
+ _adminlog.warning(
439
+ "project update failed old_name=%s new_name=%s err=%s",
440
+ name,
441
+ body.name,
442
+ exc,
443
+ project=name,
444
+ )
445
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
446
+ _adminlog.info(
447
+ "project updated old_name=%s new_name=%s enabled=%s",
448
+ name,
449
+ body.name,
450
+ body.enabled,
451
+ project=body.name,
452
+ )
453
+ _sync_bot_instance(bot_instance_manager, record)
454
+ _sync_project_webhook(webhook_registrar, record)
455
+ return JSONResponse(registry.to_public_dict())
456
+
457
+ @router.delete("/api/projects/{name}")
458
+ def api_projects_delete(name: str, _: LocalhostOnly) -> JSONResponse:
459
+ try:
460
+ registry.remove_project(name)
461
+ except ValueError as exc:
462
+ _adminlog.warning("project delete failed name=%s err=%s", name, exc, project=name)
463
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
464
+ _adminlog.info("project deleted name=%s", name, project=name)
465
+ if bot_instance_manager is not None:
466
+ bot_instance_manager.unregister(name)
467
+ return JSONResponse(registry.to_public_dict())
468
+
469
+ @router.post("/api/projects/default")
470
+ def api_projects_set_default(body: DefaultProjectBody, _: LocalhostOnly) -> JSONResponse:
471
+ try:
472
+ registry.set_default_project(body.name)
473
+ except ValueError as exc:
474
+ _adminlog.warning("default project update failed name=%s err=%s", body.name, exc, project=body.name)
475
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
476
+ _adminlog.info("default project updated name=%s", body.name, project=body.name)
477
+ return JSONResponse(registry.to_public_dict())
478
+
479
+ @router.get("/api/advanced-settings")
480
+ def api_advanced_settings_get(_: LocalhostOnly) -> dict:
481
+ _adminlog.info("advanced settings queried")
482
+ return advanced_settings_store.get().model_dump(mode="json")
483
+
484
+ @router.put("/api/advanced-settings")
485
+ def api_advanced_settings_put(body: AdvancedSettings, _: LocalhostOnly) -> dict:
486
+ saved = advanced_settings_store.save(body)
487
+ if webhook_registrar is not None and bot_commands_builder is not None:
488
+ webhook_registrar.set_bot_commands(bot_commands_builder(saved.ui_language))
489
+ for project in registry.list_projects():
490
+ webhook_registrar.sync_project_commands(project)
491
+ _adminlog.info(
492
+ "advanced settings updated ui_language=%s lifecycle_notify=%s pull_startup=%s auto_merge=%s delete_rebased_branch=%s natural_confirm_buttons=%s status_limit=%d job_timeout=%s memory_limit=%s",
493
+ saved.ui_language.value,
494
+ saved.server_lifecycle_notify_enabled,
495
+ saved.pull_projects_on_server_startup_enabled,
496
+ saved.auto_merge_to_main_enabled,
497
+ saved.delete_rebased_branch_enabled,
498
+ saved.natural_job_confirmation_buttons_enabled,
499
+ saved.status_recent_job_limit,
500
+ saved.job_timeout_seconds or "-",
501
+ saved.conversation_memory_limit_enabled,
502
+ )
503
+ return saved.model_dump(mode="json")
504
+
505
+ @router.get("/admin-static/i18n.js")
506
+ def admin_i18n_js(_: LocalhostOnly) -> FileResponse:
507
+ path = Path(__file__).parent / "static" / "i18n.js"
508
+ if not path.is_file():
509
+ raise HTTPException(status_code=404, detail="not found")
510
+ return FileResponse(path, media_type="application/javascript; charset=utf-8")
511
+
512
+ @router.get("/admin-static/summary.js")
513
+ def admin_summary_js(_: LocalhostOnly) -> FileResponse:
514
+ path = Path(__file__).parent / "static" / "summary.js"
515
+ if not path.is_file():
516
+ raise HTTPException(status_code=404, detail="not found")
517
+ return FileResponse(path, media_type="application/javascript; charset=utf-8")
518
+
519
+ @router.get("/admin-static/icons/{filename}")
520
+ def admin_icon(filename: str, _: LocalhostOnly) -> FileResponse:
521
+ if filename not in _ADMIN_ICON_NAMES:
522
+ raise HTTPException(status_code=404, detail="not found")
523
+ path = Path(__file__).parent / "static" / "icons" / filename
524
+ if not path.is_file():
525
+ raise HTTPException(status_code=404, detail="not found")
526
+ return FileResponse(path, media_type="image/svg+xml")
527
+
528
+ return router