draft-board 0.1.0-beta.0

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 (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. package/package.json +37 -0
@@ -0,0 +1,342 @@
1
+ """Service for parsing raw agent logs into structured entries."""
2
+
3
+ import difflib
4
+ import re
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from app.models.normalized_log import LogEntryType, NormalizedLogEntry
13
+
14
+
15
+ @dataclass
16
+ class ParsedEntry:
17
+ """Temporary structure before DB insert."""
18
+
19
+ entry_type: LogEntryType
20
+ content: str
21
+ metadata: dict[str, Any]
22
+ timestamp: datetime | None = None
23
+
24
+
25
+ class ClaudeLogParser:
26
+ """Parser for Claude Code CLI output format."""
27
+
28
+ # Regex patterns for Claude's output format
29
+ THINKING_PATTERN = re.compile(r"<thinking>(.*?)</thinking>", re.DOTALL)
30
+ TOOL_USE_PATTERN = re.compile(r"<tool_use>(.*?)</tool_use>", re.DOTALL)
31
+ FILE_EDIT_PATTERN = re.compile(
32
+ r"<file_edit>\s*<path>(.*?)</path>\s*<content>(.*?)</content>\s*</file_edit>",
33
+ re.DOTALL,
34
+ )
35
+ FILE_CREATE_PATTERN = re.compile(
36
+ r"<file_create>\s*<path>(.*?)</path>\s*<content>(.*?)</content>\s*</file_create>",
37
+ re.DOTALL,
38
+ )
39
+ FILE_DELETE_PATTERN = re.compile(
40
+ r"<file_delete>\s*<path>(.*?)</path>\s*</file_delete>", re.DOTALL
41
+ )
42
+ COMMAND_PATTERN = re.compile(r"<command>(.*?)</command>", re.DOTALL)
43
+ ERROR_PATTERN = re.compile(r"Error:(.*?)(?=\n\n|\Z)", re.DOTALL)
44
+
45
+ def parse(self, raw_log: str) -> list[ParsedEntry]:
46
+ """Parse raw log into structured entries."""
47
+ entries: list[ParsedEntry] = []
48
+ position = 0
49
+
50
+ # Split log by major sections
51
+ while position < len(raw_log):
52
+ # Try to match patterns in order of priority
53
+
54
+ # 1. Tool use (contains file edits, commands, etc.)
55
+ tool_match = self.TOOL_USE_PATTERN.search(raw_log, position)
56
+ if tool_match and tool_match.start() == position:
57
+ tool_content = tool_match.group(1)
58
+ entries.extend(self._parse_tool_use(tool_content))
59
+ position = tool_match.end()
60
+ continue
61
+
62
+ # 2. Thinking blocks
63
+ thinking_match = self.THINKING_PATTERN.search(raw_log, position)
64
+ if thinking_match and thinking_match.start() == position:
65
+ thinking = thinking_match.group(1).strip()
66
+ entries.append(
67
+ ParsedEntry(
68
+ entry_type=LogEntryType.THINKING,
69
+ content=thinking,
70
+ metadata={"collapsed": True}, # Start collapsed
71
+ )
72
+ )
73
+ position = thinking_match.end()
74
+ continue
75
+
76
+ # 3. Errors
77
+ error_match = self.ERROR_PATTERN.search(raw_log, position)
78
+ if error_match and error_match.start() == position:
79
+ error_text = error_match.group(1).strip()
80
+ entries.append(
81
+ ParsedEntry(
82
+ entry_type=LogEntryType.ERROR,
83
+ content=error_text,
84
+ metadata={"highlight": True},
85
+ )
86
+ )
87
+ position = error_match.end()
88
+ continue
89
+
90
+ # 4. Plain text (system messages, outputs, etc.)
91
+ next_special = self._find_next_special(raw_log, position)
92
+ if next_special > position:
93
+ plain_text = raw_log[position:next_special].strip()
94
+ if plain_text:
95
+ entries.append(
96
+ ParsedEntry(
97
+ entry_type=LogEntryType.SYSTEM_MESSAGE,
98
+ content=plain_text,
99
+ metadata={},
100
+ )
101
+ )
102
+ position = next_special
103
+ else:
104
+ # No more special patterns, capture remaining text
105
+ remaining = raw_log[position:].strip()
106
+ if remaining:
107
+ entries.append(
108
+ ParsedEntry(
109
+ entry_type=LogEntryType.SYSTEM_MESSAGE,
110
+ content=remaining,
111
+ metadata={},
112
+ )
113
+ )
114
+ break
115
+
116
+ return entries
117
+
118
+ def _parse_tool_use(self, tool_content: str) -> list[ParsedEntry]:
119
+ """Parse tool use block (may contain multiple operations)."""
120
+ entries: list[ParsedEntry] = []
121
+
122
+ # File edits
123
+ for match in self.FILE_EDIT_PATTERN.finditer(tool_content):
124
+ file_path = match.group(1).strip()
125
+ content = match.group(2).strip()
126
+
127
+ # Generate diff if we have original file
128
+ diff = self._generate_diff(file_path, content)
129
+
130
+ entries.append(
131
+ ParsedEntry(
132
+ entry_type=LogEntryType.FILE_EDIT,
133
+ content=f"Edited {file_path}",
134
+ metadata={
135
+ "file_path": file_path,
136
+ "new_content": content,
137
+ "diff": diff,
138
+ "language": self._detect_language(file_path),
139
+ },
140
+ )
141
+ )
142
+
143
+ # File creates
144
+ for match in self.FILE_CREATE_PATTERN.finditer(tool_content):
145
+ file_path = match.group(1).strip()
146
+ content = match.group(2).strip()
147
+
148
+ entries.append(
149
+ ParsedEntry(
150
+ entry_type=LogEntryType.FILE_CREATE,
151
+ content=f"Created {file_path}",
152
+ metadata={
153
+ "file_path": file_path,
154
+ "new_content": content,
155
+ "language": self._detect_language(file_path),
156
+ },
157
+ )
158
+ )
159
+
160
+ # File deletes
161
+ for match in self.FILE_DELETE_PATTERN.finditer(tool_content):
162
+ file_path = match.group(1).strip()
163
+
164
+ entries.append(
165
+ ParsedEntry(
166
+ entry_type=LogEntryType.FILE_DELETE,
167
+ content=f"Deleted {file_path}",
168
+ metadata={"file_path": file_path},
169
+ )
170
+ )
171
+
172
+ # Commands
173
+ for match in self.COMMAND_PATTERN.finditer(tool_content):
174
+ command = match.group(1).strip()
175
+
176
+ # Try to extract result if present
177
+ result_pattern = re.compile(
178
+ rf"<command>{re.escape(command)}</command>.*?<result>(.*?)</result>",
179
+ re.DOTALL,
180
+ )
181
+ result_match = result_pattern.search(tool_content)
182
+ output = result_match.group(1).strip() if result_match else None
183
+
184
+ # Simple heuristic for exit code
185
+ exit_code = (
186
+ 0 if output and "error" not in output.lower() else 1 if output else 0
187
+ )
188
+
189
+ entries.append(
190
+ ParsedEntry(
191
+ entry_type=LogEntryType.COMMAND_RUN,
192
+ content=command,
193
+ metadata={
194
+ "command": command,
195
+ "output": output,
196
+ "exit_code": exit_code,
197
+ },
198
+ )
199
+ )
200
+
201
+ return entries
202
+
203
+ def _generate_diff(self, file_path: str, new_content: str) -> str | None:
204
+ """Generate unified diff if original file exists."""
205
+ try:
206
+ path = Path(file_path)
207
+ if not path.exists():
208
+ return None
209
+
210
+ original = path.read_text()
211
+ diff_lines = difflib.unified_diff(
212
+ original.splitlines(keepends=True),
213
+ new_content.splitlines(keepends=True),
214
+ fromfile=f"a/{file_path}",
215
+ tofile=f"b/{file_path}",
216
+ lineterm="",
217
+ )
218
+ return "".join(diff_lines)
219
+ except Exception:
220
+ return None
221
+
222
+ def _detect_language(self, file_path: str) -> str:
223
+ """Detect programming language from file extension."""
224
+ ext_map = {
225
+ ".py": "python",
226
+ ".js": "javascript",
227
+ ".ts": "typescript",
228
+ ".jsx": "javascript",
229
+ ".tsx": "typescript",
230
+ ".rs": "rust",
231
+ ".go": "go",
232
+ ".java": "java",
233
+ ".cpp": "cpp",
234
+ ".c": "c",
235
+ ".rb": "ruby",
236
+ ".php": "php",
237
+ ".sql": "sql",
238
+ ".sh": "bash",
239
+ ".yaml": "yaml",
240
+ ".yml": "yaml",
241
+ ".json": "json",
242
+ ".md": "markdown",
243
+ }
244
+ ext = Path(file_path).suffix.lower()
245
+ return ext_map.get(ext, "text")
246
+
247
+ def _find_next_special(self, text: str, start: int) -> int:
248
+ """Find the next position of any special pattern."""
249
+ patterns = [self.THINKING_PATTERN, self.TOOL_USE_PATTERN, self.ERROR_PATTERN]
250
+
251
+ min_pos = len(text)
252
+ for pattern in patterns:
253
+ match = pattern.search(text, start)
254
+ if match:
255
+ min_pos = min(min_pos, match.start())
256
+
257
+ return min_pos
258
+
259
+
260
+ class CursorLogParser:
261
+ """Parser for Cursor Agent CLI output."""
262
+
263
+ def parse(self, raw_log: str) -> list[ParsedEntry]:
264
+ """Parse Cursor Agent output (similar structure to Claude)."""
265
+ # For now, use similar parsing logic as Claude
266
+ # Can be customized later for Cursor-specific format
267
+ return ClaudeLogParser().parse(raw_log)
268
+
269
+
270
+ class LogNormalizerService:
271
+ """Main service for log normalization."""
272
+
273
+ def __init__(self):
274
+ self.parsers = {
275
+ "claude": ClaudeLogParser(),
276
+ "cursor": CursorLogParser(),
277
+ # Add more as needed
278
+ }
279
+
280
+ async def normalize_and_store(
281
+ self,
282
+ db: AsyncSession,
283
+ job_id: str,
284
+ raw_log: str,
285
+ agent_type: str = "claude",
286
+ ) -> list[NormalizedLogEntry]:
287
+ """
288
+ Parse raw log and store normalized entries.
289
+
290
+ Args:
291
+ db: Database session
292
+ job_id: The job ID
293
+ raw_log: Raw log content
294
+ agent_type: Type of agent (determines parser)
295
+
296
+ Returns:
297
+ List of created NormalizedLogEntry objects
298
+ """
299
+ parser = self.parsers.get(agent_type)
300
+ if not parser:
301
+ # Fallback to Claude parser
302
+ parser = self.parsers["claude"]
303
+
304
+ # Parse
305
+ parsed_entries = parser.parse(raw_log)
306
+
307
+ # Store in DB
308
+ db_entries = []
309
+ for i, parsed in enumerate(parsed_entries):
310
+ entry = NormalizedLogEntry(
311
+ job_id=job_id,
312
+ sequence=i,
313
+ entry_type=parsed.entry_type,
314
+ content=parsed.content,
315
+ entry_metadata=parsed.metadata,
316
+ timestamp=parsed.timestamp or datetime.utcnow(),
317
+ collapsed=parsed.metadata.get("collapsed", False),
318
+ highlight=parsed.metadata.get("highlight", False),
319
+ )
320
+ db.add(entry)
321
+ db_entries.append(entry)
322
+
323
+ await db.commit()
324
+
325
+ # Refresh to get IDs
326
+ for entry in db_entries:
327
+ await db.refresh(entry)
328
+
329
+ return db_entries
330
+
331
+ async def get_normalized_logs(
332
+ self, db: AsyncSession, job_id: str
333
+ ) -> list[NormalizedLogEntry]:
334
+ """Retrieve all normalized logs for a job."""
335
+ from sqlalchemy import select
336
+
337
+ result = await db.execute(
338
+ select(NormalizedLogEntry)
339
+ .where(NormalizedLogEntry.job_id == job_id)
340
+ .order_by(NormalizedLogEntry.sequence)
341
+ )
342
+ return list(result.scalars().all())
@@ -0,0 +1,276 @@
1
+ """Real-time log streaming service with ultra-low latency.
2
+
3
+ Uses in-memory broadcast for same-process subscribers (<1ms latency).
4
+ Worker runs in-process, so cross-process communication is not needed.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import threading
10
+ import time
11
+ from collections import defaultdict
12
+ from collections.abc import AsyncIterator
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC, datetime
15
+ from enum import StrEnum
16
+ from weakref import WeakSet
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class LogLevel(StrEnum):
22
+ """Log message levels."""
23
+
24
+ STDOUT = "stdout"
25
+ STDERR = "stderr"
26
+ INFO = "info"
27
+ ERROR = "error"
28
+ PROGRESS = "progress" # Progress updates
29
+ NORMALIZED = "normalized" # Normalized agent log entry (parsed JSON)
30
+ FINISHED = "finished"
31
+
32
+
33
+ @dataclass
34
+ class LogMessage:
35
+ """A single log message with optional metadata."""
36
+
37
+ level: LogLevel
38
+ content: str
39
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
40
+ # Optional progress metadata
41
+ progress_pct: int | None = None # 0-100
42
+ stage: str | None = None # e.g., "parsing", "generating", "applying"
43
+
44
+ def to_dict(self) -> dict:
45
+ """Serialize for Redis/JSON."""
46
+ d = {
47
+ "level": self.level.value,
48
+ "content": self.content,
49
+ "timestamp": self.timestamp.isoformat(),
50
+ }
51
+ if self.progress_pct is not None:
52
+ d["progress_pct"] = self.progress_pct
53
+ if self.stage:
54
+ d["stage"] = self.stage
55
+ return d
56
+
57
+ @classmethod
58
+ def from_dict(cls, data: dict) -> "LogMessage":
59
+ """Deserialize from Redis/JSON."""
60
+ return cls(
61
+ level=LogLevel(data["level"]),
62
+ content=data["content"],
63
+ timestamp=datetime.fromisoformat(data["timestamp"]),
64
+ progress_pct=data.get("progress_pct"),
65
+ stage=data.get("stage"),
66
+ )
67
+
68
+
69
+ def _get_channel_name(job_id: str) -> str:
70
+ return f"job_logs:{job_id}"
71
+
72
+
73
+ def _get_history_key(job_id: str) -> str:
74
+ return f"job_logs_history:{job_id}"
75
+
76
+
77
+ class InMemoryBroadcaster:
78
+ """In-memory pub/sub for same-process subscribers.
79
+
80
+ Provides <1ms latency for local subscribers.
81
+ Thread-safe for worker threads.
82
+ """
83
+
84
+ # History is retained for 5 minutes after a job finishes, then cleaned up.
85
+ _HISTORY_RETENTION_SECONDS = 300
86
+ # Run stale history cleanup every N pushes to amortize cost.
87
+ _CLEANUP_INTERVAL_PUSHES = 100
88
+
89
+ def __init__(self):
90
+ self._lock = threading.Lock()
91
+ # job_id -> set of async queues
92
+ self._subscribers: dict[str, WeakSet[asyncio.Queue]] = defaultdict(WeakSet)
93
+ # job_id -> list of messages (for history/catch-up)
94
+ self._history: dict[str, list[LogMessage]] = defaultdict(list)
95
+ self._max_history = 500
96
+ # job_id -> monotonic timestamp when the job finished
97
+ self._finished_at: dict[str, float] = {}
98
+ # Counter for amortized cleanup
99
+ self._push_count: int = 0
100
+
101
+ def push(self, job_id: str, msg: LogMessage) -> None:
102
+ """Push message to all local subscribers (thread-safe)."""
103
+ with self._lock:
104
+ # Store in history
105
+ history = self._history[job_id]
106
+ history.append(msg)
107
+ if len(history) > self._max_history:
108
+ self._history[job_id] = history[-self._max_history :]
109
+
110
+ # Broadcast to subscribers
111
+ subscribers = self._subscribers.get(job_id, set())
112
+ for queue in list(subscribers):
113
+ try:
114
+ queue.put_nowait(msg)
115
+ except asyncio.QueueFull:
116
+ pass # Drop if subscriber is slow
117
+
118
+ # Periodic cleanup of stale history
119
+ self._push_count += 1
120
+ if self._push_count >= self._CLEANUP_INTERVAL_PUSHES:
121
+ self._push_count = 0
122
+ self._cleanup_stale_history()
123
+
124
+ def subscribe(self, job_id: str) -> asyncio.Queue:
125
+ """Create a subscription queue for a job."""
126
+ queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
127
+ with self._lock:
128
+ self._subscribers[job_id].add(queue)
129
+ return queue
130
+
131
+ def unsubscribe(self, job_id: str, queue: asyncio.Queue) -> None:
132
+ """Remove a subscription."""
133
+ with self._lock:
134
+ if job_id in self._subscribers:
135
+ self._subscribers[job_id].discard(queue)
136
+
137
+ def get_history(self, job_id: str) -> list[LogMessage]:
138
+ """Get message history for catch-up."""
139
+ with self._lock:
140
+ return list(self._history.get(job_id, []))
141
+
142
+ def cleanup(self, job_id: str) -> None:
143
+ """Clean up subscribers and schedule history for deferred removal."""
144
+ with self._lock:
145
+ self._subscribers.pop(job_id, None)
146
+ # Mark job as finished so history is cleaned up after retention period
147
+ self._finished_at[job_id] = time.monotonic()
148
+
149
+ def _cleanup_stale_history(self) -> None:
150
+ """Remove history entries whose jobs finished more than retention seconds ago.
151
+
152
+ Must be called while self._lock is held.
153
+ """
154
+ now = time.monotonic()
155
+ stale_job_ids = [
156
+ job_id
157
+ for job_id, finished_ts in self._finished_at.items()
158
+ if (now - finished_ts) >= self._HISTORY_RETENTION_SECONDS
159
+ ]
160
+ for job_id in stale_job_ids:
161
+ self._history.pop(job_id, None)
162
+ del self._finished_at[job_id]
163
+
164
+
165
+ # Global in-memory broadcaster
166
+ _broadcaster = InMemoryBroadcaster()
167
+
168
+
169
+ class LogStreamPublisher:
170
+ """In-memory log publisher for same-process workers.
171
+
172
+ Used by workers to push logs to subscribers via InMemoryBroadcaster.
173
+ """
174
+
175
+ def push(
176
+ self,
177
+ job_id: str,
178
+ level: LogLevel,
179
+ content: str,
180
+ progress_pct: int | None = None,
181
+ stage: str | None = None,
182
+ ) -> None:
183
+ """Push a log message via in-memory broadcast (<1ms)."""
184
+ msg = LogMessage(
185
+ level=level,
186
+ content=content,
187
+ progress_pct=progress_pct,
188
+ stage=stage,
189
+ )
190
+ _broadcaster.push(job_id, msg)
191
+
192
+ def flush_all(self, job_id: str) -> None:
193
+ """No-op — in-memory broadcast is instant."""
194
+ pass
195
+
196
+ def push_stdout(self, job_id: str, content: str) -> None:
197
+ self.push(job_id, LogLevel.STDOUT, content)
198
+
199
+ def push_stderr(self, job_id: str, content: str) -> None:
200
+ self.push(job_id, LogLevel.STDERR, content)
201
+
202
+ def push_info(self, job_id: str, content: str) -> None:
203
+ self.push(job_id, LogLevel.INFO, content)
204
+
205
+ def push_error(self, job_id: str, content: str) -> None:
206
+ self.push(job_id, LogLevel.ERROR, content)
207
+
208
+ def push_progress(
209
+ self, job_id: str, pct: int, stage: str, content: str = ""
210
+ ) -> None:
211
+ """Push a progress update."""
212
+ self.push(job_id, LogLevel.PROGRESS, content, progress_pct=pct, stage=stage)
213
+
214
+ def push_finished(self, job_id: str) -> None:
215
+ """Signal job completion."""
216
+ self.push(job_id, LogLevel.FINISHED, "")
217
+ self.flush_all(job_id) # Ensure all messages are flushed
218
+ _broadcaster.cleanup(job_id)
219
+
220
+
221
+ class LogStreamSubscriber:
222
+ """In-memory subscriber for log streams.
223
+
224
+ Used by FastAPI SSE endpoints. Worker runs in-process so
225
+ in-memory broadcast provides instant delivery.
226
+ """
227
+
228
+ def get_history(self, job_id: str) -> list[LogMessage]:
229
+ """Get message history from in-memory broadcaster."""
230
+ return _broadcaster.get_history(job_id)
231
+
232
+ async def subscribe(
233
+ self, job_id: str, max_wait_seconds: int = 1800
234
+ ) -> AsyncIterator[LogMessage]:
235
+ """Subscribe to log stream with minimal latency.
236
+
237
+ 1. Yield history for catch-up
238
+ 2. Use in-memory broadcast (<1ms latency)
239
+ """
240
+ # First yield history
241
+ for msg in self.get_history(job_id):
242
+ yield msg
243
+ if msg.level == LogLevel.FINISHED:
244
+ return
245
+
246
+ queue = _broadcaster.subscribe(job_id)
247
+ start_time = time.monotonic()
248
+
249
+ try:
250
+ while (time.monotonic() - start_time) < max_wait_seconds:
251
+ try:
252
+ msg = queue.get_nowait()
253
+ yield msg
254
+ if msg.level == LogLevel.FINISHED:
255
+ return
256
+ continue
257
+ except asyncio.QueueEmpty:
258
+ pass
259
+
260
+ await asyncio.sleep(0.01) # 10ms poll interval
261
+
262
+ logger.warning(
263
+ f"Log stream subscription for job {job_id} timed out after {max_wait_seconds}s"
264
+ )
265
+ yield LogMessage(
266
+ level=LogLevel.INFO,
267
+ content=f"[Stream timeout after {max_wait_seconds}s - connection closed]",
268
+ )
269
+
270
+ finally:
271
+ _broadcaster.unsubscribe(job_id, queue)
272
+
273
+
274
+ # Global instances
275
+ log_stream_publisher = LogStreamPublisher()
276
+ log_stream_service = LogStreamSubscriber()