cloneloop 0.1.0__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 (118) hide show
  1. cloneloop-0.1.0.dist-info/METADATA +392 -0
  2. cloneloop-0.1.0.dist-info/RECORD +118 -0
  3. cloneloop-0.1.0.dist-info/WHEEL +4 -0
  4. cloneloop-0.1.0.dist-info/entry_points.txt +5 -0
  5. mcp_ai_supervisor/__init__.py +52 -0
  6. mcp_ai_supervisor/__main__.py +551 -0
  7. mcp_ai_supervisor/debug.py +15 -0
  8. mcp_ai_supervisor/desktop_app/__init__.py +29 -0
  9. mcp_ai_supervisor/desktop_app/desktop_app.py +390 -0
  10. mcp_ai_supervisor/helpers/__init__.py +4 -0
  11. mcp_ai_supervisor/helpers/feedback_helpers.py +140 -0
  12. mcp_ai_supervisor/helpers/image_helpers.py +73 -0
  13. mcp_ai_supervisor/i18n.py +14 -0
  14. mcp_ai_supervisor/log_writer.py +16 -0
  15. mcp_ai_supervisor/logging/__init__.py +8 -0
  16. mcp_ai_supervisor/logging/log_parser.py +293 -0
  17. mcp_ai_supervisor/logging/log_tailer.py +367 -0
  18. mcp_ai_supervisor/py.typed +0 -0
  19. mcp_ai_supervisor/server.py +654 -0
  20. mcp_ai_supervisor/utils/__init__.py +28 -0
  21. mcp_ai_supervisor/utils/error_handler.py +10 -0
  22. mcp_ai_supervisor/utils/memory_monitor.py +11 -0
  23. mcp_ai_supervisor/utils/paths.py +7 -0
  24. mcp_ai_supervisor/utils/resource_manager.py +15 -0
  25. mcp_ai_supervisor/utils/structure_checker.py +291 -0
  26. mcp_ai_supervisor/web/__init__.py +26 -0
  27. mcp_ai_supervisor/web/constants/__init__.py +8 -0
  28. mcp_ai_supervisor/web/constants/message_codes.py +173 -0
  29. mcp_ai_supervisor/web/core/__init__.py +30 -0
  30. mcp_ai_supervisor/web/core/agent_bridge.py +957 -0
  31. mcp_ai_supervisor/web/core/auto_responder.py +332 -0
  32. mcp_ai_supervisor/web/core/context_collector.py +124 -0
  33. mcp_ai_supervisor/web/core/conversation_recorder.py +751 -0
  34. mcp_ai_supervisor/web/core/delegate_manager.py +1193 -0
  35. mcp_ai_supervisor/web/core/feedback_mediator.py +259 -0
  36. mcp_ai_supervisor/web/core/message_channel.py +551 -0
  37. mcp_ai_supervisor/web/core/response_builder.py +333 -0
  38. mcp_ai_supervisor/web/core/scene_detector.py +184 -0
  39. mcp_ai_supervisor/web/core/task_state.py +194 -0
  40. mcp_ai_supervisor/web/locales/en/translation.json +643 -0
  41. mcp_ai_supervisor/web/locales/zh-CN/translation.json +629 -0
  42. mcp_ai_supervisor/web/locales/zh-TW/translation.json +648 -0
  43. mcp_ai_supervisor/web/main.py +747 -0
  44. mcp_ai_supervisor/web/managers/__init__.py +8 -0
  45. mcp_ai_supervisor/web/managers/desktop_manager.py +228 -0
  46. mcp_ai_supervisor/web/managers/server_manager.py +170 -0
  47. mcp_ai_supervisor/web/managers/session_manager.py +515 -0
  48. mcp_ai_supervisor/web/managers/workbench_connector.py +186 -0
  49. mcp_ai_supervisor/web/models/__init__.py +13 -0
  50. mcp_ai_supervisor/web/models/feedback_result.py +16 -0
  51. mcp_ai_supervisor/web/models/feedback_session.py +938 -0
  52. mcp_ai_supervisor/web/models/session_cleaner.py +245 -0
  53. mcp_ai_supervisor/web/models/session_commands.py +213 -0
  54. mcp_ai_supervisor/web/models/session_resolver.py +158 -0
  55. mcp_ai_supervisor/web/models/session_timer.py +242 -0
  56. mcp_ai_supervisor/web/routes/__init__.py +12 -0
  57. mcp_ai_supervisor/web/routes/main_routes.py +965 -0
  58. mcp_ai_supervisor/web/routes/settings_routes.py +195 -0
  59. mcp_ai_supervisor/web/routes/ws_handlers.py +270 -0
  60. mcp_ai_supervisor/web/static/css/audio-management.css +545 -0
  61. mcp_ai_supervisor/web/static/css/notification-settings.css +152 -0
  62. mcp_ai_supervisor/web/static/css/prompt-management.css +566 -0
  63. mcp_ai_supervisor/web/static/css/session-management.css +1428 -0
  64. mcp_ai_supervisor/web/static/css/styles.css +2267 -0
  65. mcp_ai_supervisor/web/static/favicon.ico +0 -0
  66. mcp_ai_supervisor/web/static/icon-192.png +0 -0
  67. mcp_ai_supervisor/web/static/icon.svg +11 -0
  68. mcp_ai_supervisor/web/static/index.html +37 -0
  69. mcp_ai_supervisor/web/static/js/app.js +1721 -0
  70. mcp_ai_supervisor/web/static/js/i18n.js +376 -0
  71. mcp_ai_supervisor/web/static/js/modules/audio/audio-manager.js +610 -0
  72. mcp_ai_supervisor/web/static/js/modules/audio/audio-settings-ui.js +732 -0
  73. mcp_ai_supervisor/web/static/js/modules/connection-monitor.js +435 -0
  74. mcp_ai_supervisor/web/static/js/modules/constants/message-codes.js +168 -0
  75. mcp_ai_supervisor/web/static/js/modules/countdown-manager.js +273 -0
  76. mcp_ai_supervisor/web/static/js/modules/drag-drop-handler.js +677 -0
  77. mcp_ai_supervisor/web/static/js/modules/file-upload-manager.js +555 -0
  78. mcp_ai_supervisor/web/static/js/modules/image-handler.js +199 -0
  79. mcp_ai_supervisor/web/static/js/modules/logger.js +404 -0
  80. mcp_ai_supervisor/web/static/js/modules/notification/notification-manager.js +360 -0
  81. mcp_ai_supervisor/web/static/js/modules/notification/notification-settings.js +344 -0
  82. mcp_ai_supervisor/web/static/js/modules/prompt/prompt-input-buttons.js +427 -0
  83. mcp_ai_supervisor/web/static/js/modules/prompt/prompt-manager.js +414 -0
  84. mcp_ai_supervisor/web/static/js/modules/prompt/prompt-modal.js +458 -0
  85. mcp_ai_supervisor/web/static/js/modules/prompt/prompt-settings-ui.js +524 -0
  86. mcp_ai_supervisor/web/static/js/modules/session/session-data-manager.js +1042 -0
  87. mcp_ai_supervisor/web/static/js/modules/session/session-details-modal.js +594 -0
  88. mcp_ai_supervisor/web/static/js/modules/session/session-ui-renderer.js +836 -0
  89. mcp_ai_supervisor/web/static/js/modules/session-manager.js +1059 -0
  90. mcp_ai_supervisor/web/static/js/modules/settings-manager.js +1002 -0
  91. mcp_ai_supervisor/web/static/js/modules/tab-manager.js +235 -0
  92. mcp_ai_supervisor/web/static/js/modules/textarea-height-manager.js +267 -0
  93. mcp_ai_supervisor/web/static/js/modules/ui-manager.js +578 -0
  94. mcp_ai_supervisor/web/static/js/modules/utils/dom-utils.js +392 -0
  95. mcp_ai_supervisor/web/static/js/modules/utils/status-utils.js +403 -0
  96. mcp_ai_supervisor/web/static/js/modules/utils/time-utils.js +440 -0
  97. mcp_ai_supervisor/web/static/js/modules/utils.js +557 -0
  98. mcp_ai_supervisor/web/static/js/modules/websocket-manager.js +875 -0
  99. mcp_ai_supervisor/web/static/js/modules/workbench-notification.js +103 -0
  100. mcp_ai_supervisor/web/static/js/vendor/marked.min.js +6 -0
  101. mcp_ai_supervisor/web/static/js/vendor/purify.min.js +3 -0
  102. mcp_ai_supervisor/web/templates/components/image-upload.html +43 -0
  103. mcp_ai_supervisor/web/templates/components/settings-card.html +58 -0
  104. mcp_ai_supervisor/web/templates/components/status-indicator.html +31 -0
  105. mcp_ai_supervisor/web/templates/components/toggle-switch.html +19 -0
  106. mcp_ai_supervisor/web/templates/feedback.html +1198 -0
  107. mcp_ai_supervisor/web/templates/index.html +378 -0
  108. mcp_ai_supervisor/web/utils/__init__.py +12 -0
  109. mcp_ai_supervisor/web/utils/browser.py +35 -0
  110. mcp_ai_supervisor/web/utils/compression_config.py +195 -0
  111. mcp_ai_supervisor/web/utils/compression_monitor.py +314 -0
  112. mcp_ai_supervisor/web/utils/ide_opener.py +286 -0
  113. mcp_ai_supervisor/web/utils/network.py +66 -0
  114. mcp_ai_supervisor/web/utils/port_manager.py +340 -0
  115. mcp_ai_supervisor/web/utils/session_cleanup_manager.py +543 -0
  116. mcp_ai_supervisor/web/utils/workbench_client.py +899 -0
  117. mcp_ai_supervisor/workbench/__init__.py +5 -0
  118. mcp_ai_supervisor/workbench/__main__.py +5 -0
@@ -0,0 +1,293 @@
1
+ """
2
+ 日志解析模块 (LogParser)
3
+ ========================
4
+
5
+ 解析 LogWriter 生成的结构化日志文件,支持:
6
+ - 单行正则解析(匹配 LogWriter 的输出格式)
7
+ - 多行合并(堆栈跟踪续行归并到前一条日志)
8
+ - 级别过滤(DEBUG < INFO < WARN < ERROR)
9
+ - 分页查询(offset + limit)
10
+ - tail 模式(从文件末尾反向读取)
11
+ - 快速行数统计(不解析内容)
12
+
13
+ 日志格式规范(与 LogWriter 一致):
14
+ {timestamp} [{level}] [{module}] [{context}] {message}
15
+ 示例:2026-07-13T14:30:22.456 [INFO ] [feedback] [project=test] 消息内容
16
+
17
+ 依赖:
18
+ - 无外部依赖,纯标准库实现
19
+ - 日志格式定义来自 L1 LogWriter
20
+
21
+ 设计文档: docs/控制台方案/技术方案/日志系统/设计文档/日志查看器.md
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import NamedTuple
31
+
32
+ # ── 常量 ──────────────────────────────────────────────────────
33
+
34
+ # 日志级别优先级映射(与 LogWriter 一致)
35
+ _LEVEL_PRIORITY = {
36
+ "DEBUG": 0,
37
+ "INFO": 1,
38
+ "WARN": 2,
39
+ "ERROR": 3,
40
+ }
41
+
42
+ # 预编译正则:匹配 LogWriter 的输出格式
43
+ # 格式: {timestamp} [{level}] [{module}] [{context}] {message}
44
+ # 示例: 2026-07-13T14:30:22.456 [INFO ] [feedback] [project=test] 消息内容
45
+ _LOG_LINE_RE = re.compile(
46
+ r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})" # 时间戳
47
+ r"\s+\[(\w+)\s*\]" # 级别(可能有尾部空格)
48
+ r"\s+\[([^\]]*)\]" # 模块名(可为空)
49
+ r"\s+\[([^\]]*)\]" # 上下文(可为空)
50
+ r"\s+(.*)" # 消息(余下所有内容)
51
+ )
52
+
53
+ # 续行判断:以空格/Tab/Traceback 开头的行视为前一条的延续
54
+ _CONTINUATION_RE = re.compile(r"^[\s\t]|^Traceback ")
55
+
56
+ # 快速行统计的读取块大小
57
+ _COUNT_BLOCK_SIZE = 65536
58
+
59
+
60
+ # ── 数据结构 ──────────────────────────────────────────────────
61
+
62
+
63
+ @dataclass
64
+ class LogLine:
65
+ """解析后的单条日志记录
66
+
67
+ Attributes:
68
+ line_number: 在原始文件中的行号(1-based)
69
+ timestamp: 原始时间戳字符串,如 "2026-07-13T14:30:22.456"
70
+ level: 日志级别 "DEBUG" | "INFO" | "WARN" | "ERROR"
71
+ module: 模块名,如 "feedback"、"workbench"
72
+ context: 原始上下文字符串,如 "project=test session=abc"
73
+ message: 消息正文(多行合并后可能包含换行符)
74
+ raw: 原始行文本(多行合并后包含所有原始行,换行连接)
75
+ """
76
+
77
+ line_number: int
78
+ timestamp: str
79
+ level: str
80
+ module: str
81
+ context: str
82
+ message: str
83
+ raw: str
84
+
85
+
86
+ class ParseResult(NamedTuple):
87
+ """parse_lines() 的返回结果
88
+
89
+ Attributes:
90
+ lines: 解析后的日志行列表
91
+ total_lines: 文件总行数(原始行,未合并)
92
+ has_more: 是否还有更多数据可加载
93
+ """
94
+
95
+ lines: list[LogLine]
96
+ total_lines: int
97
+ has_more: bool
98
+
99
+
100
+ # ── LogParser 核心类 ──────────────────────────────────────────
101
+
102
+
103
+ class LogParser:
104
+ """日志文件解析器
105
+
106
+ 提供对 LogWriter 生成的日志文件的解析能力,
107
+ 支持单行解析、多行合并、级别过滤、分页和 tail 模式。
108
+
109
+ 使用方式:
110
+ parser = LogParser()
111
+ result = parser.parse_lines("/path/to/log.log", level="INFO", tail=True, limit=100)
112
+ for line in result.lines:
113
+ print(f"{line.timestamp} [{line.level}] {line.message}")
114
+ """
115
+
116
+ # 最大允许返回行数
117
+ MAX_LIMIT = 5000
118
+
119
+ @staticmethod
120
+ def parse_single_line(raw_line: str, line_number: int = 0) -> LogLine | None:
121
+ """解析单行日志文本
122
+
123
+ Args:
124
+ raw_line: 原始日志行文本(不含末尾换行符)
125
+ line_number: 行号(1-based)
126
+
127
+ Returns:
128
+ 解析成功返回 LogLine 对象,格式不匹配返回 None
129
+ """
130
+ m = _LOG_LINE_RE.match(raw_line)
131
+ if not m:
132
+ return None
133
+ return LogLine(
134
+ line_number=line_number,
135
+ timestamp=m.group(1),
136
+ level=m.group(2).strip(),
137
+ module=m.group(3),
138
+ context=m.group(4),
139
+ message=m.group(5),
140
+ raw=raw_line,
141
+ )
142
+
143
+ @staticmethod
144
+ def is_continuation(line: str) -> bool:
145
+ """判断一行是否为续行(堆栈跟踪等)
146
+
147
+ 续行规则:
148
+ - 以空格或 Tab 开头
149
+ - 以 "Traceback " 开头
150
+ """
151
+ return bool(_CONTINUATION_RE.match(line))
152
+
153
+ def parse_lines(
154
+ self,
155
+ filepath: str | Path,
156
+ *,
157
+ offset: int = 0,
158
+ limit: int = 500,
159
+ level: str = "INFO",
160
+ tail: bool = False,
161
+ ) -> ParseResult:
162
+ """从日志文件读取、解析、过滤并分页返回
163
+
164
+ Args:
165
+ filepath: 日志文件路径
166
+ offset: 起始偏移(在过滤后的结果上应用)
167
+ limit: 返回行数上限(最大 MAX_LIMIT)
168
+ level: 最低日志级别过滤 "DEBUG" | "INFO" | "WARN" | "ERROR"
169
+ tail: True 时从文件末尾向前读取
170
+
171
+ Returns:
172
+ ParseResult(lines, total_lines, has_more)
173
+ """
174
+ filepath = Path(filepath)
175
+ if not filepath.exists() or not filepath.is_file():
176
+ return ParseResult(lines=[], total_lines=0, has_more=False)
177
+
178
+ limit = min(limit, self.MAX_LIMIT)
179
+ min_priority = _LEVEL_PRIORITY.get(level.upper(), 1)
180
+
181
+ # 读取并合并多行
182
+ merged = self._read_and_merge(filepath, tail=tail)
183
+
184
+ # 统计原始文件总行数
185
+ total_lines = self.total_lines(filepath)
186
+
187
+ # 级别过滤
188
+ filtered = [
189
+ ll for ll in merged
190
+ if _LEVEL_PRIORITY.get(ll.level, 0) >= min_priority
191
+ ]
192
+
193
+ # 分页
194
+ total_filtered = len(filtered)
195
+ start = offset
196
+ end = offset + limit
197
+ page = filtered[start:end]
198
+ has_more = end < total_filtered
199
+
200
+ return ParseResult(lines=page, total_lines=total_lines, has_more=has_more)
201
+
202
+ @staticmethod
203
+ def total_lines(filepath: str | Path) -> int:
204
+ """快速统计日志文件的总行数(不解析内容)
205
+
206
+ 使用分块读取 + 统计换行符的方式,避免一次性加载整个文件。
207
+
208
+ Args:
209
+ filepath: 日志文件路径
210
+
211
+ Returns:
212
+ 文件总行数(0 表示空文件或文件不存在)
213
+ """
214
+ filepath = Path(filepath)
215
+ if not filepath.exists() or not filepath.is_file():
216
+ return 0
217
+
218
+ count = 0
219
+ try:
220
+ with open(filepath, "rb") as f:
221
+ while True:
222
+ block = f.read(_COUNT_BLOCK_SIZE)
223
+ if not block:
224
+ break
225
+ count += block.count(b"\n")
226
+ except OSError:
227
+ return 0
228
+ return count
229
+
230
+ # ── 内部方法 ──────────────────────────────────────────────
231
+
232
+ def _read_and_merge(
233
+ self, filepath: Path, *, tail: bool = False
234
+ ) -> list[LogLine]:
235
+ """读取日志文件并执行多行合并
236
+
237
+ 多行合并规则:
238
+ - 以时间戳开头的行 → 新日志记录
239
+ - 续行(空格/Tab/Traceback 开头)→ 追加到前一条记录的 message
240
+
241
+ Args:
242
+ filepath: 日志文件路径
243
+ tail: True 时从文件末尾读取(先全部读入,结果保持时间顺序)
244
+
245
+ Returns:
246
+ 合并后的 LogLine 列表(按时间顺序排列)
247
+ """
248
+ try:
249
+ text = filepath.read_text(encoding="utf-8", errors="replace")
250
+ except OSError:
251
+ return []
252
+
253
+ raw_lines = text.splitlines()
254
+ if not raw_lines:
255
+ return []
256
+
257
+ result: list[LogLine] = []
258
+ current: LogLine | None = None
259
+
260
+ for idx, raw in enumerate(raw_lines, start=1):
261
+ if not raw.strip():
262
+ # 空行视为续行(如果有当前记录),否则跳过
263
+ if current is not None:
264
+ current.message += "\n"
265
+ current.raw += "\n"
266
+ continue
267
+
268
+ if self.is_continuation(raw):
269
+ # 续行 → 追加到当前记录
270
+ if current is not None:
271
+ current.message += "\n" + raw
272
+ current.raw += "\n" + raw
273
+ continue
274
+
275
+ # 尝试解析为新日志行
276
+ parsed = self.parse_single_line(raw, line_number=idx)
277
+ if parsed is not None:
278
+ # 保存上一条,开始新记录
279
+ if current is not None:
280
+ result.append(current)
281
+ current = parsed
282
+ else:
283
+ # 格式不匹配 → 视为续行或跳过
284
+ if current is not None:
285
+ current.message += "\n" + raw
286
+ current.raw += "\n" + raw
287
+ # 无当前记录时忽略格式错误行
288
+
289
+ # 不要忘记最后一条
290
+ if current is not None:
291
+ result.append(current)
292
+
293
+ return result
@@ -0,0 +1,367 @@
1
+ """
2
+ 日志实时跟踪模块 (LogTailer)
3
+ ============================
4
+
5
+ 实现 tail -f 语义的日志文件实时跟踪,支持:
6
+ - 订阅/取消订阅机制(多 client 共享同一文件的读取)
7
+ - asyncio 定时轮询文件大小变化
8
+ - 新增内容通过 LogParser 解析后回调推送
9
+ - 级别过滤(仅推送 >= 指定级别的日志行)
10
+ - 批处理推送(100ms 内的多行合并为一次回调)
11
+ - 跨天文件切换(日期变更时自动切换到新文件)
12
+ - 背压保护(队列上限 + 回调超时防护)
13
+
14
+ 依赖:
15
+ - L3 LogParser(日志行解析)
16
+ - asyncio(异步轮询循环)
17
+
18
+ 设计文档: docs/控制台方案/技术方案/日志系统/设计文档/日志查看器.md
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import os
25
+ from collections import defaultdict
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Any, Awaitable, Callable
30
+
31
+ from .log_parser import LogLine, LogParser
32
+
33
+ # ── 常量 ──────────────────────────────────────────────────────
34
+
35
+ # 轮询间隔(秒)
36
+ DEFAULT_POLL_INTERVAL = 1.0
37
+
38
+ # 推送队列上限:超过此数量丢弃最旧的行
39
+ MAX_PENDING_LINES = 1000
40
+
41
+ # 回调超时(秒):防止慢回调阻塞轮询循环
42
+ CALLBACK_TIMEOUT = 5.0
43
+
44
+ # 日志级别优先级映射(与 LogParser 一致)
45
+ _LEVEL_PRIORITY = {
46
+ "DEBUG": 0,
47
+ "INFO": 1,
48
+ "WARN": 2,
49
+ "ERROR": 3,
50
+ }
51
+
52
+
53
+ # ── 数据结构 ──────────────────────────────────────────────────
54
+
55
+
56
+ @dataclass
57
+ class TailSubscription:
58
+ """单个客户端的订阅信息
59
+
60
+ Attributes:
61
+ client_id: 订阅者唯一标识
62
+ source: 日志源标识(如 "workbench"、"agent-xxx")
63
+ level: 最低日志级别过滤
64
+ callback: 推送回调函数,接收 list[LogLine]
65
+ file_path: 当前监控的日志文件路径
66
+ file_offset: 上次读取到的文件字节位置
67
+ """
68
+
69
+ client_id: str
70
+ source: str
71
+ level: str
72
+ callback: Callable[[list[LogLine]], Awaitable[None]]
73
+ file_path: Path
74
+ file_offset: int = 0
75
+
76
+
77
+ # ── LogTailer 核心类 ──────────────────────────────────────────
78
+
79
+
80
+ class LogTailer:
81
+ """日志文件实时跟踪器
82
+
83
+ 通过 asyncio 定时轮询监控日志文件变化,
84
+ 检测到新增内容后解析并推送给所有订阅者。
85
+
86
+ 同一 source 的多个 client 共享一次文件读取,各自独立过滤级别。
87
+
88
+ 使用方式:
89
+ tailer = LogTailer(log_base_dir=Path("~/.mcp-ai-supervisor/logs"))
90
+ tailer.subscribe("ws-client-1", "workbench", "INFO", my_callback)
91
+ await tailer.start()
92
+ # ... 当不再需要时 ...
93
+ await tailer.stop()
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ log_base_dir: Path | str | None = None,
99
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
100
+ ) -> None:
101
+ """初始化 LogTailer
102
+
103
+ Args:
104
+ log_base_dir: 日志根目录(默认 ~/.mcp-ai-supervisor/logs)
105
+ poll_interval: 轮询间隔(秒)
106
+ """
107
+ if log_base_dir is None:
108
+ log_base_dir = Path.home() / ".mcp-ai-supervisor" / "logs"
109
+ self._log_base_dir = Path(log_base_dir)
110
+ self._poll_interval = poll_interval
111
+ self._subscriptions: dict[str, TailSubscription] = {}
112
+ self._poll_task: asyncio.Task[None] | None = None
113
+ self._parser = LogParser()
114
+ self._running = False
115
+ # 按 source 分组的文件偏移量缓存(避免同一文件重复读取)
116
+ self._source_offsets: dict[str, int] = {}
117
+
118
+ # ── 公开接口 ──────────────────────────────────────────────
119
+
120
+ def subscribe(
121
+ self,
122
+ client_id: str,
123
+ source: str,
124
+ level: str,
125
+ callback: Callable[[list[LogLine]], Awaitable[None]],
126
+ ) -> None:
127
+ """订阅日志源的实时推送
128
+
129
+ Args:
130
+ client_id: 订阅者唯一标识
131
+ source: 日志源(如 "workbench"、"agent-xxx")
132
+ level: 最低日志级别 "DEBUG" | "INFO" | "WARN" | "ERROR"
133
+ callback: 推送回调,接收解析后的日志行列表
134
+ """
135
+ file_path = self._resolve_log_path(source)
136
+ offset = 0
137
+ if file_path.exists():
138
+ offset = file_path.stat().st_size
139
+
140
+ sub = TailSubscription(
141
+ client_id=client_id,
142
+ source=source,
143
+ level=level.upper(),
144
+ callback=callback,
145
+ file_path=file_path,
146
+ file_offset=offset,
147
+ )
148
+ self._subscriptions[client_id] = sub
149
+
150
+ def unsubscribe(self, client_id: str) -> None:
151
+ """取消订阅
152
+
153
+ 如果某个 source 没有订阅者了,其文件偏移缓存也被清除。
154
+
155
+ Args:
156
+ client_id: 订阅者唯一标识
157
+ """
158
+ sub = self._subscriptions.pop(client_id, None)
159
+ if sub is None:
160
+ return
161
+
162
+ # 检查该 source 是否还有其他订阅者
163
+ remaining = any(
164
+ s.source == sub.source for s in self._subscriptions.values()
165
+ )
166
+ if not remaining:
167
+ self._source_offsets.pop(sub.source, None)
168
+
169
+ async def start(self) -> None:
170
+ """启动轮询循环
171
+
172
+ 可在 subscribe() 之前或之后调用。
173
+ 重复调用 start() 不会创建多个轮询任务。
174
+ """
175
+ if self._running:
176
+ return
177
+ self._running = True
178
+ self._poll_task = asyncio.create_task(self._poll_loop())
179
+
180
+ async def stop(self) -> None:
181
+ """停止轮询循环
182
+
183
+ stop() 后可以再次调用 start() 重启。
184
+ """
185
+ self._running = False
186
+ if self._poll_task is not None:
187
+ self._poll_task.cancel()
188
+ try:
189
+ await self._poll_task
190
+ except asyncio.CancelledError:
191
+ pass
192
+ self._poll_task = None
193
+
194
+ @property
195
+ def is_running(self) -> bool:
196
+ """是否正在运行"""
197
+ return self._running
198
+
199
+ @property
200
+ def subscription_count(self) -> int:
201
+ """当前订阅者数量"""
202
+ return len(self._subscriptions)
203
+
204
+ def get_subscribed_sources(self) -> set[str]:
205
+ """获取所有被订阅的 source 集合"""
206
+ return {s.source for s in self._subscriptions.values()}
207
+
208
+ # ── 内部方法 ──────────────────────────────────────────────
209
+
210
+ async def _poll_loop(self) -> None:
211
+ """轮询循环主体
212
+
213
+ 每隔 poll_interval 秒检查所有被订阅文件的大小变化,
214
+ 检测到新增内容后解析并推送给订阅者。
215
+ """
216
+ while self._running:
217
+ try:
218
+ if self._subscriptions:
219
+ await self._check_all_sources()
220
+ await asyncio.sleep(self._poll_interval)
221
+ except asyncio.CancelledError:
222
+ break
223
+ except Exception:
224
+ # 轮询循环中的错误不应导致整个 tailer 停止
225
+ await asyncio.sleep(self._poll_interval)
226
+
227
+ async def _check_all_sources(self) -> None:
228
+ """检查所有被订阅 source 的文件变化
229
+
230
+ 同一 source 的多个 client 只读取文件一次,各自独立过滤级别。
231
+ """
232
+ # 按 source 分组订阅者
233
+ source_groups: dict[str, list[TailSubscription]] = defaultdict(list)
234
+ for sub in list(self._subscriptions.values()):
235
+ # 跨天文件切换:检查日期是否变化
236
+ current_path = self._resolve_log_path(sub.source)
237
+ if current_path != sub.file_path:
238
+ sub.file_path = current_path
239
+ sub.file_offset = 0
240
+ source_groups[sub.source].append(sub)
241
+
242
+ for source, subs in source_groups.items():
243
+ file_path = subs[0].file_path
244
+ if not file_path.exists():
245
+ continue
246
+
247
+ try:
248
+ current_size = file_path.stat().st_size
249
+ except OSError:
250
+ continue
251
+
252
+ # 取该 source 所有 client 中最小的 offset
253
+ min_offset = min(s.file_offset for s in subs)
254
+
255
+ if current_size <= min_offset:
256
+ continue
257
+
258
+ # 读取新增内容
259
+ new_lines = self._read_new_content(file_path, min_offset, current_size)
260
+ if not new_lines:
261
+ # 更新 offset 即使没有解析出有效行
262
+ for sub in subs:
263
+ sub.file_offset = current_size
264
+ continue
265
+
266
+ # 为每个 client 按级别过滤后推送
267
+ for sub in subs:
268
+ min_priority = _LEVEL_PRIORITY.get(sub.level, 1)
269
+ filtered = [
270
+ ll for ll in new_lines
271
+ if _LEVEL_PRIORITY.get(ll.level, 0) >= min_priority
272
+ ]
273
+ if filtered:
274
+ # 背压保护:限制推送数量
275
+ if len(filtered) > MAX_PENDING_LINES:
276
+ filtered = filtered[-MAX_PENDING_LINES:]
277
+
278
+ await self._safe_callback(sub, filtered)
279
+ sub.file_offset = current_size
280
+
281
+ def _read_new_content(
282
+ self, file_path: Path, offset: int, current_size: int
283
+ ) -> list[LogLine]:
284
+ """读取文件新增部分并解析为 LogLine 列表
285
+
286
+ Args:
287
+ file_path: 日志文件路径
288
+ offset: 上次读取到的字节位置
289
+ current_size: 当前文件大小
290
+
291
+ Returns:
292
+ 解析后的日志行列表
293
+ """
294
+ try:
295
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
296
+ f.seek(offset)
297
+ new_text = f.read(current_size - offset)
298
+ except OSError:
299
+ return []
300
+
301
+ if not new_text:
302
+ return []
303
+
304
+ raw_lines = new_text.splitlines()
305
+ result: list[LogLine] = []
306
+ current: LogLine | None = None
307
+
308
+ for raw in raw_lines:
309
+ if not raw.strip():
310
+ if current is not None:
311
+ current.message += "\n"
312
+ current.raw += "\n"
313
+ continue
314
+
315
+ if self._parser.is_continuation(raw):
316
+ if current is not None:
317
+ current.message += "\n" + raw
318
+ current.raw += "\n" + raw
319
+ continue
320
+
321
+ parsed = self._parser.parse_single_line(raw)
322
+ if parsed is not None:
323
+ if current is not None:
324
+ result.append(current)
325
+ current = parsed
326
+ else:
327
+ if current is not None:
328
+ current.message += "\n" + raw
329
+ current.raw += "\n" + raw
330
+
331
+ if current is not None:
332
+ result.append(current)
333
+
334
+ return result
335
+
336
+ @staticmethod
337
+ async def _safe_callback(
338
+ sub: TailSubscription, lines: list[LogLine]
339
+ ) -> None:
340
+ """安全调用推送回调 — 超时和异常不影响主循环
341
+
342
+ Args:
343
+ sub: 订阅信息
344
+ lines: 要推送的日志行列表
345
+ """
346
+ try:
347
+ await asyncio.wait_for(
348
+ sub.callback(lines), timeout=CALLBACK_TIMEOUT
349
+ )
350
+ except asyncio.TimeoutError:
351
+ pass
352
+ except Exception:
353
+ pass
354
+
355
+ def _resolve_log_path(self, source: str) -> Path:
356
+ """根据 source 和当前日期推算日志文件路径
357
+
358
+ 路径格式: {log_base_dir}/{source}/{YYYY-MM-DD}.log
359
+
360
+ Args:
361
+ source: 日志源标识
362
+
363
+ Returns:
364
+ 日志文件的完整路径
365
+ """
366
+ today = datetime.now().strftime("%Y-%m-%d")
367
+ return self._log_base_dir / source / f"{today}.log"
File without changes