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/jobs/schemas.py ADDED
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import UTC, datetime
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+ from app.models import ModelName
11
+
12
+ _SAFE_BRANCH_TOKEN = re.compile(r"^[A-Za-z0-9/._-]+$")
13
+ _SAFE_JOB_ID_TOKEN = re.compile(r"^[A-Za-z0-9_.-]+$")
14
+
15
+
16
+ class JobStatus(StrEnum):
17
+ QUEUED = "queued"
18
+ RUNNING = "running"
19
+ SUCCEEDED = "succeeded"
20
+ FAILED = "failed"
21
+ CANCELLED = "cancelled"
22
+
23
+
24
+ class JobMode(StrEnum):
25
+ AGENT = "agent"
26
+ PLAN = "plan"
27
+ ASK = "ask"
28
+ AGENT_FIX = "agent_fix"
29
+
30
+
31
+ class FixKind(StrEnum):
32
+ COMMIT = "commit"
33
+ SOURCE = "source"
34
+
35
+
36
+ class JobRequest(BaseModel):
37
+ project: str
38
+ model: ModelName
39
+ model_id: str | None = None
40
+ instruction: str
41
+ mode: JobMode = JobMode.AGENT
42
+ job_id: str | None = None
43
+ branch: str | None = None
44
+ commit: bool = True
45
+ chat_id: int
46
+ requested_by: int | None = None
47
+ message_id: int | None = None
48
+ reply_to_message_id: int | None = None
49
+ parent_job_id: str | None = None
50
+ fix_kind: FixKind | None = None
51
+
52
+ @field_validator("branch")
53
+ @classmethod
54
+ def _validate_branch(cls, value: str | None) -> str | None:
55
+ if value is None:
56
+ return None
57
+ if not value or len(value) > 255:
58
+ raise ValueError("branch is empty or too long")
59
+ if ".." in value or value.startswith("-") or not _SAFE_BRANCH_TOKEN.match(value):
60
+ raise ValueError("branch must use only ASCII letters, numbers, /, ., _, -")
61
+ return value
62
+
63
+ @field_validator("job_id", "parent_job_id")
64
+ @classmethod
65
+ def _validate_job_id(cls, value: str | None) -> str | None:
66
+ if value is None:
67
+ return None
68
+ if not value or len(value) > 128 or not _SAFE_JOB_ID_TOKEN.match(value):
69
+ raise ValueError("job_id must use only ASCII letters, numbers, ., _, -")
70
+ return value
71
+
72
+
73
+ class Job(BaseModel):
74
+ id: str
75
+ request: JobRequest
76
+ status: JobStatus = JobStatus.QUEUED
77
+ branch: str | None = None
78
+ commit_hash: str | None = None
79
+ changed_files: list[str] = Field(default_factory=list)
80
+ error: str | None = None
81
+ error_stage: str | None = None
82
+ runner_actual_model: str | None = None
83
+ runner_token_usage: dict[str, int] = Field(default_factory=dict)
84
+ runner_stdout_summary: str | None = None
85
+ runner_stderr_summary: str | None = None
86
+ accepted_message_id: int | None = None
87
+ result_message_ids: list[int] = Field(default_factory=list)
88
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
89
+ started_at: datetime | None = None
90
+ finished_at: datetime | None = None
91
+ log_path: Path | None = None
92
+
93
+ def mark_running(self) -> None:
94
+ if self.status != JobStatus.QUEUED:
95
+ raise ValueError(f"Cannot move {self.status} to running")
96
+ self.status = JobStatus.RUNNING
97
+ self.started_at = datetime.now(UTC)
98
+
99
+ def mark_succeeded(self) -> None:
100
+ if self.status != JobStatus.RUNNING:
101
+ raise ValueError(f"Cannot move {self.status} to succeeded")
102
+ self.status = JobStatus.SUCCEEDED
103
+ self.finished_at = datetime.now(UTC)
104
+
105
+ def mark_failed(self, error: str) -> None:
106
+ if self.status not in (JobStatus.QUEUED, JobStatus.RUNNING):
107
+ raise ValueError(f"Cannot move {self.status} to failed")
108
+ self.status = JobStatus.FAILED
109
+ self.error = error
110
+ self.finished_at = datetime.now(UTC)
111
+
112
+ def mark_cancelled(self) -> None:
113
+ if self.status not in (JobStatus.QUEUED, JobStatus.RUNNING):
114
+ raise ValueError(f"Cannot move {self.status} to cancelled")
115
+ self.status = JobStatus.CANCELLED
116
+ self.finished_at = datetime.now(UTC)
app/jobs/store.py ADDED
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+ from threading import Lock
6
+ from typing import Protocol
7
+
8
+ from app.jobs.schemas import Job, JobStatus
9
+
10
+
11
+ class JobStore(Protocol):
12
+ def create(self, job: Job) -> None:
13
+ ...
14
+
15
+ def get(self, job_id: str) -> Job | None:
16
+ ...
17
+
18
+ def update(self, job: Job) -> None:
19
+ ...
20
+
21
+ def list_recent(self, limit: int = 20) -> list[Job]:
22
+ ...
23
+
24
+ def get_latest_succeeded_branch_for_project_chat(
25
+ self, project: str, chat_id: int
26
+ ) -> str | None:
27
+ ...
28
+
29
+ def list_recent_for_chat(self, chat_id: int, limit: int = 20) -> list[Job]:
30
+ ...
31
+
32
+ def list_recent_for_project_chat(self, project: str, chat_id: int, limit: int = 20) -> list[Job]:
33
+ ...
34
+
35
+
36
+ class InMemoryJobStore:
37
+ def __init__(self) -> None:
38
+ self._jobs: dict[str, list[Job]] = {}
39
+ self._lock = Lock()
40
+
41
+ def create(self, job: Job) -> None:
42
+ with self._lock:
43
+ self._jobs.setdefault(job.id, []).append(job)
44
+
45
+ def get(self, job_id: str) -> Job | None:
46
+ with self._lock:
47
+ jobs = self._jobs.get(job_id)
48
+ return jobs[-1] if jobs else None
49
+
50
+ def update(self, job: Job) -> None:
51
+ with self._lock:
52
+ jobs = self._jobs.get(job.id)
53
+ if not jobs:
54
+ self._jobs[job.id] = [job]
55
+ return
56
+ for idx, existing in enumerate(jobs):
57
+ if existing.created_at == job.created_at:
58
+ jobs[idx] = job
59
+ return
60
+ jobs[-1] = job
61
+
62
+ def _all_jobs(self) -> list[Job]:
63
+ return [job for jobs in self._jobs.values() for job in jobs]
64
+
65
+ def list_recent(self, limit: int = 20) -> list[Job]:
66
+ with self._lock:
67
+ values = sorted(self._all_jobs(), key=lambda job: job.created_at, reverse=True)
68
+ return values[:limit]
69
+
70
+ def list_recent_for_chat(self, chat_id: int, limit: int = 20) -> list[Job]:
71
+ with self._lock:
72
+ values = [
73
+ job
74
+ for job in self._all_jobs()
75
+ if job.request.chat_id == chat_id
76
+ ]
77
+ values.sort(key=lambda job: job.created_at, reverse=True)
78
+ return values[:limit]
79
+
80
+ def list_recent_for_project_chat(self, project: str, chat_id: int, limit: int = 20) -> list[Job]:
81
+ with self._lock:
82
+ values = [
83
+ job
84
+ for job in self._all_jobs()
85
+ if job.request.project == project and job.request.chat_id == chat_id
86
+ ]
87
+ values.sort(key=lambda job: job.created_at, reverse=True)
88
+ return values[:limit]
89
+
90
+ def get_latest_succeeded_branch_for_project_chat(
91
+ self, project: str, chat_id: int
92
+ ) -> str | None:
93
+ with self._lock:
94
+ candidates = [
95
+ j
96
+ for j in self._all_jobs()
97
+ if j.request.project == project
98
+ and j.request.chat_id == chat_id
99
+ and j.status == JobStatus.SUCCEEDED
100
+ and j.branch
101
+ ]
102
+ if not candidates:
103
+ return None
104
+ candidates.sort(
105
+ key=lambda j: (j.finished_at or j.created_at, j.created_at),
106
+ reverse=True,
107
+ )
108
+ return candidates[0].branch
109
+
110
+
111
+ def _job_to_payload(job: Job) -> str:
112
+ return job.model_dump_json()
113
+
114
+
115
+ def _payload_to_job(payload: str) -> Job:
116
+ return Job.model_validate_json(payload)
117
+
118
+
119
+ def _job_sort_timestamp(job: Job) -> str:
120
+ return job.created_at.isoformat()
121
+
122
+
123
+ def _job_finish_sort_timestamp(job: Job) -> str | None:
124
+ return (job.finished_at or job.created_at).isoformat()
125
+
126
+
127
+ class SQLiteJobStore:
128
+ def __init__(self, db_path: Path) -> None:
129
+ self._db_path = db_path.resolve()
130
+ self._lock = Lock()
131
+ self.ensure_schema()
132
+
133
+ @property
134
+ def db_path(self) -> Path:
135
+ return self._db_path
136
+
137
+ def ensure_schema(self) -> None:
138
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
139
+ with self._lock:
140
+ conn = sqlite3.connect(self._db_path)
141
+ try:
142
+ conn.execute(
143
+ """
144
+ CREATE TABLE IF NOT EXISTS jobs (
145
+ row_id INTEGER PRIMARY KEY AUTOINCREMENT,
146
+ job_id TEXT NOT NULL,
147
+ created_at TEXT NOT NULL,
148
+ finished_at TEXT,
149
+ request_project TEXT NOT NULL,
150
+ request_chat_id INTEGER NOT NULL,
151
+ status TEXT NOT NULL,
152
+ branch TEXT,
153
+ commit_hash TEXT,
154
+ payload TEXT NOT NULL
155
+ )
156
+ """
157
+ )
158
+ conn.execute(
159
+ """
160
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_job_created
161
+ ON jobs (job_id, created_at)
162
+ """
163
+ )
164
+ conn.execute(
165
+ """
166
+ CREATE INDEX IF NOT EXISTS idx_jobs_recent
167
+ ON jobs (created_at DESC, row_id DESC)
168
+ """
169
+ )
170
+ conn.execute(
171
+ """
172
+ CREATE INDEX IF NOT EXISTS idx_jobs_project_chat_recent
173
+ ON jobs (request_project, request_chat_id, created_at DESC, row_id DESC)
174
+ """
175
+ )
176
+ conn.commit()
177
+ finally:
178
+ conn.close()
179
+
180
+ def create(self, job: Job) -> None:
181
+ with self._lock:
182
+ conn = sqlite3.connect(self._db_path)
183
+ try:
184
+ self._insert_job(conn, job)
185
+ conn.commit()
186
+ finally:
187
+ conn.close()
188
+
189
+ def get(self, job_id: str) -> Job | None:
190
+ with self._lock:
191
+ conn = sqlite3.connect(self._db_path)
192
+ try:
193
+ row = conn.execute(
194
+ """
195
+ SELECT payload
196
+ FROM jobs
197
+ WHERE job_id = ?
198
+ ORDER BY created_at DESC, row_id DESC
199
+ LIMIT 1
200
+ """,
201
+ (job_id,),
202
+ ).fetchone()
203
+ finally:
204
+ conn.close()
205
+ return _payload_to_job(str(row[0])) if row else None
206
+
207
+ def update(self, job: Job) -> None:
208
+ with self._lock:
209
+ conn = sqlite3.connect(self._db_path)
210
+ try:
211
+ cur = conn.execute(
212
+ """
213
+ UPDATE jobs
214
+ SET finished_at = ?,
215
+ request_project = ?,
216
+ request_chat_id = ?,
217
+ status = ?,
218
+ branch = ?,
219
+ commit_hash = ?,
220
+ payload = ?
221
+ WHERE job_id = ? AND created_at = ?
222
+ """,
223
+ self._row_values(job)[1:] + (job.id, _job_sort_timestamp(job)),
224
+ )
225
+ if cur.rowcount == 0:
226
+ self._insert_job(conn, job)
227
+ conn.commit()
228
+ finally:
229
+ conn.close()
230
+
231
+ def list_recent(self, limit: int = 20) -> list[Job]:
232
+ if limit <= 0:
233
+ return []
234
+ return self._fetch_jobs(
235
+ """
236
+ SELECT payload
237
+ FROM jobs
238
+ ORDER BY created_at DESC, row_id DESC
239
+ LIMIT ?
240
+ """,
241
+ (limit,),
242
+ )
243
+
244
+ def list_recent_for_chat(self, chat_id: int, limit: int = 20) -> list[Job]:
245
+ if limit <= 0:
246
+ return []
247
+ return self._fetch_jobs(
248
+ """
249
+ SELECT payload
250
+ FROM jobs
251
+ WHERE request_chat_id = ?
252
+ ORDER BY created_at DESC, row_id DESC
253
+ LIMIT ?
254
+ """,
255
+ (chat_id, limit),
256
+ )
257
+
258
+ def list_recent_for_project_chat(self, project: str, chat_id: int, limit: int = 20) -> list[Job]:
259
+ if limit <= 0:
260
+ return []
261
+ return self._fetch_jobs(
262
+ """
263
+ SELECT payload
264
+ FROM jobs
265
+ WHERE request_project = ? AND request_chat_id = ?
266
+ ORDER BY created_at DESC, row_id DESC
267
+ LIMIT ?
268
+ """,
269
+ (project, chat_id, limit),
270
+ )
271
+
272
+ def get_latest_succeeded_branch_for_project_chat(
273
+ self, project: str, chat_id: int
274
+ ) -> str | None:
275
+ with self._lock:
276
+ conn = sqlite3.connect(self._db_path)
277
+ try:
278
+ row = conn.execute(
279
+ """
280
+ SELECT branch
281
+ FROM jobs
282
+ WHERE request_project = ?
283
+ AND request_chat_id = ?
284
+ AND status = ?
285
+ AND branch IS NOT NULL
286
+ ORDER BY COALESCE(finished_at, created_at) DESC, created_at DESC, row_id DESC
287
+ LIMIT 1
288
+ """,
289
+ (project, chat_id, JobStatus.SUCCEEDED.value),
290
+ ).fetchone()
291
+ finally:
292
+ conn.close()
293
+ return str(row[0]) if row else None
294
+
295
+ @staticmethod
296
+ def _row_values(job: Job) -> tuple[str, str | None, str, int, str, str | None, str | None, str]:
297
+ return (
298
+ _job_sort_timestamp(job),
299
+ _job_finish_sort_timestamp(job),
300
+ job.request.project,
301
+ job.request.chat_id,
302
+ job.status.value,
303
+ job.branch,
304
+ job.commit_hash,
305
+ _job_to_payload(job),
306
+ )
307
+
308
+ def _insert_job(self, conn: sqlite3.Connection, job: Job) -> None:
309
+ conn.execute(
310
+ """
311
+ INSERT INTO jobs (
312
+ job_id,
313
+ created_at,
314
+ finished_at,
315
+ request_project,
316
+ request_chat_id,
317
+ status,
318
+ branch,
319
+ commit_hash,
320
+ payload
321
+ )
322
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
323
+ """,
324
+ (job.id,) + self._row_values(job),
325
+ )
326
+
327
+ def _fetch_jobs(self, query: str, params: tuple[object, ...]) -> list[Job]:
328
+ with self._lock:
329
+ conn = sqlite3.connect(self._db_path)
330
+ try:
331
+ rows = conn.execute(query, params).fetchall()
332
+ finally:
333
+ conn.close()
334
+ return [_payload_to_job(str(row[0])) for row in rows]