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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- 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()
|