monoco-toolkit 0.3.10__py3-none-any.whl → 0.3.12__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.
- monoco/__main__.py +8 -0
- monoco/core/artifacts/__init__.py +16 -0
- monoco/core/artifacts/manager.py +575 -0
- monoco/core/artifacts/models.py +161 -0
- monoco/core/automation/__init__.py +51 -0
- monoco/core/automation/config.py +338 -0
- monoco/core/automation/field_watcher.py +296 -0
- monoco/core/automation/handlers.py +723 -0
- monoco/core/config.py +31 -4
- monoco/core/executor/__init__.py +38 -0
- monoco/core/executor/agent_action.py +254 -0
- monoco/core/executor/git_action.py +303 -0
- monoco/core/executor/im_action.py +309 -0
- monoco/core/executor/pytest_action.py +218 -0
- monoco/core/git.py +38 -0
- monoco/core/hooks/context.py +74 -13
- monoco/core/ingestion/__init__.py +20 -0
- monoco/core/ingestion/discovery.py +248 -0
- monoco/core/ingestion/watcher.py +343 -0
- monoco/core/ingestion/worker.py +436 -0
- monoco/core/loader.py +633 -0
- monoco/core/registry.py +34 -25
- monoco/core/router/__init__.py +55 -0
- monoco/core/router/action.py +341 -0
- monoco/core/router/router.py +392 -0
- monoco/core/scheduler/__init__.py +63 -0
- monoco/core/scheduler/base.py +152 -0
- monoco/core/scheduler/engines.py +175 -0
- monoco/core/scheduler/events.py +171 -0
- monoco/core/scheduler/local.py +377 -0
- monoco/core/skills.py +119 -80
- monoco/core/watcher/__init__.py +57 -0
- monoco/core/watcher/base.py +365 -0
- monoco/core/watcher/dropzone.py +152 -0
- monoco/core/watcher/issue.py +303 -0
- monoco/core/watcher/memo.py +200 -0
- monoco/core/watcher/task.py +238 -0
- monoco/daemon/app.py +77 -1
- monoco/daemon/commands.py +10 -0
- monoco/daemon/events.py +34 -0
- monoco/daemon/mailroom_service.py +196 -0
- monoco/daemon/models.py +1 -0
- monoco/daemon/scheduler.py +207 -0
- monoco/daemon/services.py +27 -58
- monoco/daemon/triggers.py +55 -0
- monoco/features/agent/__init__.py +25 -7
- monoco/features/agent/adapter.py +17 -7
- monoco/features/agent/cli.py +91 -57
- monoco/features/agent/engines.py +31 -170
- monoco/{core/resources/en/skills/monoco_core → features/agent/resources/en/skills/monoco_atom_core}/SKILL.md +2 -2
- monoco/features/agent/resources/en/skills/{flow_engineer → monoco_workflow_agent_engineer}/SKILL.md +2 -2
- monoco/features/agent/resources/en/skills/{flow_manager → monoco_workflow_agent_manager}/SKILL.md +2 -2
- monoco/features/agent/resources/en/skills/{flow_planner → monoco_workflow_agent_planner}/SKILL.md +2 -2
- monoco/features/agent/resources/en/skills/{flow_reviewer → monoco_workflow_agent_reviewer}/SKILL.md +2 -2
- monoco/features/agent/resources/{roles/role-engineer.yaml → zh/roles/monoco_role_engineer.yaml} +3 -3
- monoco/features/agent/resources/{roles/role-manager.yaml → zh/roles/monoco_role_manager.yaml} +8 -8
- monoco/features/agent/resources/{roles/role-planner.yaml → zh/roles/monoco_role_planner.yaml} +8 -8
- monoco/features/agent/resources/{roles/role-reviewer.yaml → zh/roles/monoco_role_reviewer.yaml} +8 -8
- monoco/{core/resources/zh/skills/monoco_core → features/agent/resources/zh/skills/monoco_atom_core}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/{flow_engineer → monoco_workflow_agent_engineer}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/{flow_manager → monoco_workflow_agent_manager}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/{flow_planner → monoco_workflow_agent_planner}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/{flow_reviewer → monoco_workflow_agent_reviewer}/SKILL.md +2 -2
- monoco/features/agent/worker.py +1 -1
- monoco/features/artifact/__init__.py +0 -0
- monoco/features/artifact/adapter.py +33 -0
- monoco/features/artifact/resources/zh/AGENTS.md +14 -0
- monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +278 -0
- monoco/features/glossary/adapter.py +18 -7
- monoco/features/glossary/resources/en/skills/{monoco_glossary → monoco_atom_glossary}/SKILL.md +2 -2
- monoco/features/glossary/resources/zh/skills/{monoco_glossary → monoco_atom_glossary}/SKILL.md +2 -2
- monoco/features/hooks/__init__.py +11 -0
- monoco/features/hooks/adapter.py +67 -0
- monoco/features/hooks/commands.py +309 -0
- monoco/features/hooks/core.py +441 -0
- monoco/features/hooks/resources/ADDING_HOOKS.md +234 -0
- monoco/features/i18n/adapter.py +18 -5
- monoco/features/i18n/core.py +482 -17
- monoco/features/i18n/resources/en/skills/{monoco_i18n → monoco_atom_i18n}/SKILL.md +2 -2
- monoco/features/i18n/resources/en/skills/{i18n_scan_workflow → monoco_workflow_i18n_scan}/SKILL.md +2 -2
- monoco/features/i18n/resources/zh/skills/{monoco_i18n → monoco_atom_i18n}/SKILL.md +2 -2
- monoco/features/i18n/resources/zh/skills/{i18n_scan_workflow → monoco_workflow_i18n_scan}/SKILL.md +2 -2
- monoco/features/issue/adapter.py +19 -6
- monoco/features/issue/commands.py +352 -20
- monoco/features/issue/core.py +475 -16
- monoco/features/issue/engine/machine.py +114 -4
- monoco/features/issue/linter.py +60 -5
- monoco/features/issue/models.py +2 -2
- monoco/features/issue/resources/en/AGENTS.md +109 -0
- monoco/features/issue/resources/en/skills/{monoco_issue → monoco_atom_issue}/SKILL.md +2 -2
- monoco/features/issue/resources/en/skills/{issue_create_workflow → monoco_workflow_issue_creation}/SKILL.md +2 -2
- monoco/features/issue/resources/en/skills/{issue_develop_workflow → monoco_workflow_issue_development}/SKILL.md +2 -2
- monoco/features/issue/resources/en/skills/{issue_lifecycle_workflow → monoco_workflow_issue_management}/SKILL.md +2 -2
- monoco/features/issue/resources/en/skills/{issue_refine_workflow → monoco_workflow_issue_refinement}/SKILL.md +2 -2
- monoco/features/issue/resources/hooks/post-checkout.sh +39 -0
- monoco/features/issue/resources/hooks/pre-commit.sh +41 -0
- monoco/features/issue/resources/hooks/pre-push.sh +35 -0
- monoco/features/issue/resources/zh/AGENTS.md +109 -0
- monoco/features/issue/resources/zh/skills/{monoco_issue → monoco_atom_issue_lifecycle}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/{issue_create_workflow → monoco_workflow_issue_creation}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/{issue_develop_workflow → monoco_workflow_issue_development}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/{issue_lifecycle_workflow → monoco_workflow_issue_management}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/{issue_refine_workflow → monoco_workflow_issue_refinement}/SKILL.md +2 -2
- monoco/features/issue/validator.py +101 -1
- monoco/features/memo/adapter.py +21 -8
- monoco/features/memo/cli.py +103 -10
- monoco/features/memo/core.py +178 -92
- monoco/features/memo/models.py +53 -0
- monoco/features/memo/resources/en/skills/{monoco_memo → monoco_atom_memo}/SKILL.md +2 -2
- monoco/features/memo/resources/en/skills/{note_processing_workflow → monoco_workflow_note_processing}/SKILL.md +2 -2
- monoco/features/memo/resources/zh/skills/{monoco_memo → monoco_atom_memo}/SKILL.md +2 -2
- monoco/features/memo/resources/zh/skills/{note_processing_workflow → monoco_workflow_note_processing}/SKILL.md +2 -2
- monoco/features/spike/adapter.py +18 -5
- monoco/features/spike/commands.py +5 -3
- monoco/features/spike/resources/en/skills/{monoco_spike → monoco_atom_spike}/SKILL.md +2 -2
- monoco/features/spike/resources/en/skills/{research_workflow → monoco_workflow_research}/SKILL.md +2 -2
- monoco/features/spike/resources/zh/skills/{monoco_spike → monoco_atom_spike}/SKILL.md +2 -2
- monoco/features/spike/resources/zh/skills/{research_workflow → monoco_workflow_research}/SKILL.md +2 -2
- monoco/main.py +38 -1
- {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/METADATA +7 -1
- monoco_toolkit-0.3.12.dist-info/RECORD +202 -0
- monoco/features/agent/apoptosis.py +0 -44
- monoco/features/agent/manager.py +0 -91
- monoco/features/agent/session.py +0 -121
- monoco_toolkit-0.3.10.dist-info/RECORD +0 -156
- /monoco/{core → features/agent}/resources/en/AGENTS.md +0 -0
- /monoco/{core → features/agent}/resources/zh/AGENTS.md +0 -0
- {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Watcher Module - Layer 1 of the Event Automation Framework.
|
|
3
|
+
|
|
4
|
+
This module provides file system watching capabilities with event emission.
|
|
5
|
+
It is part of the three-layer architecture:
|
|
6
|
+
- Layer 1: File Watcher (this module)
|
|
7
|
+
- Layer 2: Action Router
|
|
8
|
+
- Layer 3: Action Executor
|
|
9
|
+
|
|
10
|
+
Example Usage:
|
|
11
|
+
>>> from monoco.core.watcher import IssueWatcher, WatchConfig
|
|
12
|
+
>>> from pathlib import Path
|
|
13
|
+
>>>
|
|
14
|
+
>>> config = WatchConfig(
|
|
15
|
+
... path=Path("./Issues"),
|
|
16
|
+
... patterns=["*.md"],
|
|
17
|
+
... recursive=True,
|
|
18
|
+
... )
|
|
19
|
+
>>> watcher = IssueWatcher(config)
|
|
20
|
+
>>> await watcher.start()
|
|
21
|
+
>>> # Events are automatically emitted to EventBus
|
|
22
|
+
>>> await watcher.stop()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .base import (
|
|
26
|
+
ChangeType,
|
|
27
|
+
FieldChange,
|
|
28
|
+
FileEvent,
|
|
29
|
+
FilesystemWatcher,
|
|
30
|
+
PollingWatcher,
|
|
31
|
+
WatchdogWatcher,
|
|
32
|
+
WatchConfig,
|
|
33
|
+
)
|
|
34
|
+
from .issue import IssueWatcher, IssueFileEvent
|
|
35
|
+
from .memo import MemoWatcher, MemoFileEvent
|
|
36
|
+
from .task import TaskWatcher, TaskFileEvent
|
|
37
|
+
from .dropzone import DropzoneWatcher, DropzoneFileEvent
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Base classes
|
|
41
|
+
"ChangeType",
|
|
42
|
+
"FieldChange",
|
|
43
|
+
"FileEvent",
|
|
44
|
+
"FilesystemWatcher",
|
|
45
|
+
"PollingWatcher",
|
|
46
|
+
"WatchdogWatcher",
|
|
47
|
+
"WatchConfig",
|
|
48
|
+
# Concrete watchers
|
|
49
|
+
"IssueWatcher",
|
|
50
|
+
"IssueFileEvent",
|
|
51
|
+
"MemoWatcher",
|
|
52
|
+
"MemoFileEvent",
|
|
53
|
+
"TaskWatcher",
|
|
54
|
+
"TaskFileEvent",
|
|
55
|
+
"DropzoneWatcher",
|
|
56
|
+
"DropzoneFileEvent",
|
|
57
|
+
]
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base abstractions for FilesystemWatcher - Layer 1 of the event automation framework.
|
|
3
|
+
|
|
4
|
+
This module defines the core abstractions for file system event watching:
|
|
5
|
+
- FilesystemWatcher: Abstract base class for all file watchers
|
|
6
|
+
- FileEvent: Dataclass representing a file system event
|
|
7
|
+
- WatchConfig: Configuration for file watching
|
|
8
|
+
- ChangeType: Enum for types of file changes
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import inspect
|
|
15
|
+
import logging
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from enum import Enum, auto
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
|
22
|
+
|
|
23
|
+
from monoco.core.scheduler import AgentEventType, EventBus, event_bus
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChangeType(Enum):
|
|
29
|
+
"""Types of file system changes."""
|
|
30
|
+
CREATED = "created"
|
|
31
|
+
MODIFIED = "modified"
|
|
32
|
+
DELETED = "deleted"
|
|
33
|
+
MOVED = "moved"
|
|
34
|
+
RENAMED = "renamed"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class FileEvent:
|
|
39
|
+
"""
|
|
40
|
+
Represents a file system event.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
path: Path to the file or directory
|
|
44
|
+
change_type: Type of change (created, modified, deleted, etc.)
|
|
45
|
+
watcher_name: Name of the watcher that emitted this event
|
|
46
|
+
old_path: Original path for move/rename events
|
|
47
|
+
old_content: Previous content hash or snapshot (for content tracking)
|
|
48
|
+
new_content: Current content hash or snapshot
|
|
49
|
+
metadata: Additional event metadata
|
|
50
|
+
timestamp: Event timestamp
|
|
51
|
+
"""
|
|
52
|
+
path: Path
|
|
53
|
+
change_type: ChangeType
|
|
54
|
+
watcher_name: str
|
|
55
|
+
old_path: Optional[Path] = None
|
|
56
|
+
old_content: Optional[str] = None
|
|
57
|
+
new_content: Optional[str] = None
|
|
58
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
60
|
+
|
|
61
|
+
def to_agent_event_type(self) -> Optional[AgentEventType]:
|
|
62
|
+
"""Convert FileEvent to AgentEventType if applicable."""
|
|
63
|
+
# This will be overridden by specific watchers
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def to_payload(self) -> Dict[str, Any]:
|
|
67
|
+
"""Convert to payload dict for EventBus."""
|
|
68
|
+
return {
|
|
69
|
+
"path": str(self.path),
|
|
70
|
+
"change_type": self.change_type.value,
|
|
71
|
+
"watcher_name": self.watcher_name,
|
|
72
|
+
"old_path": str(self.old_path) if self.old_path else None,
|
|
73
|
+
"old_content": self.old_content,
|
|
74
|
+
"new_content": self.new_content,
|
|
75
|
+
"metadata": self.metadata,
|
|
76
|
+
"timestamp": self.timestamp.isoformat(),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class WatchConfig:
|
|
82
|
+
"""
|
|
83
|
+
Configuration for file watching.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
path: Path to watch (file or directory)
|
|
87
|
+
patterns: Glob patterns to match (e.g., "*.md", "*.yaml")
|
|
88
|
+
exclude_patterns: Patterns to exclude
|
|
89
|
+
recursive: Whether to watch recursively
|
|
90
|
+
field_extractors: Optional field extractors for content parsing
|
|
91
|
+
poll_interval: Polling interval in seconds (for polling-based watchers)
|
|
92
|
+
"""
|
|
93
|
+
path: Path
|
|
94
|
+
patterns: List[str] = field(default_factory=lambda: ["*"])
|
|
95
|
+
exclude_patterns: List[str] = field(default_factory=list)
|
|
96
|
+
recursive: bool = True
|
|
97
|
+
field_extractors: Dict[str, Callable[[str], Any]] = field(default_factory=dict)
|
|
98
|
+
poll_interval: float = 5.0
|
|
99
|
+
|
|
100
|
+
def should_watch(self, file_path: Path) -> bool:
|
|
101
|
+
"""Check if a file should be watched based on patterns."""
|
|
102
|
+
# Check exclude patterns first
|
|
103
|
+
for pattern in self.exclude_patterns:
|
|
104
|
+
if file_path.match(pattern):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# Check include patterns
|
|
108
|
+
for pattern in self.patterns:
|
|
109
|
+
if file_path.match(pattern):
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class FieldChange:
|
|
117
|
+
"""Represents a change in a specific field."""
|
|
118
|
+
field_name: str
|
|
119
|
+
old_value: Any
|
|
120
|
+
new_value: Any
|
|
121
|
+
change_type: ChangeType = ChangeType.MODIFIED
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class FilesystemWatcher(ABC):
|
|
125
|
+
"""
|
|
126
|
+
Abstract base class for file system watchers (Layer 1).
|
|
127
|
+
|
|
128
|
+
Responsibilities:
|
|
129
|
+
- Monitor file system changes
|
|
130
|
+
- Emit FileEvent objects
|
|
131
|
+
- Integrate with EventBus for event publishing
|
|
132
|
+
|
|
133
|
+
Lifecycle:
|
|
134
|
+
1. Create watcher with config
|
|
135
|
+
2. Call start() to begin watching
|
|
136
|
+
3. File events are emitted via emit() or callbacks
|
|
137
|
+
4. Call stop() to cleanup
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
>>> config = WatchConfig(path=Path("./Issues"), patterns=["*.md"])
|
|
141
|
+
>>> watcher = IssueWatcher(config)
|
|
142
|
+
>>> await watcher.start()
|
|
143
|
+
>>> # Events are automatically emitted to EventBus
|
|
144
|
+
>>> await watcher.stop()
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
config: WatchConfig,
|
|
150
|
+
event_bus: Optional[EventBus] = None,
|
|
151
|
+
name: Optional[str] = None,
|
|
152
|
+
):
|
|
153
|
+
self.config = config
|
|
154
|
+
self.event_bus = event_bus or event_bus
|
|
155
|
+
self.name = name or self.__class__.__name__
|
|
156
|
+
self._running = False
|
|
157
|
+
self._callbacks: List[Callable[[FileEvent], None]] = []
|
|
158
|
+
self._state_cache: Dict[str, Any] = {} # For tracking state changes
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
async def start(self) -> None:
|
|
162
|
+
"""Start watching the file system."""
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
@abstractmethod
|
|
166
|
+
async def stop(self) -> None:
|
|
167
|
+
"""Stop watching and cleanup resources."""
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
def is_running(self) -> bool:
|
|
171
|
+
"""Check if the watcher is currently running."""
|
|
172
|
+
return self._running
|
|
173
|
+
|
|
174
|
+
def register_callback(self, callback: Callable[[FileEvent], None]) -> None:
|
|
175
|
+
"""Register a callback for file events."""
|
|
176
|
+
self._callbacks.append(callback)
|
|
177
|
+
|
|
178
|
+
def unregister_callback(self, callback: Callable[[FileEvent], None]) -> None:
|
|
179
|
+
"""Unregister a callback."""
|
|
180
|
+
if callback in self._callbacks:
|
|
181
|
+
self._callbacks.remove(callback)
|
|
182
|
+
|
|
183
|
+
async def emit(self, event: FileEvent) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Emit a file event to all registered callbacks and EventBus.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
event: The FileEvent to emit
|
|
189
|
+
"""
|
|
190
|
+
# Call local callbacks
|
|
191
|
+
for callback in self._callbacks:
|
|
192
|
+
try:
|
|
193
|
+
if inspect.iscoroutinefunction(callback):
|
|
194
|
+
await callback(event)
|
|
195
|
+
else:
|
|
196
|
+
callback(event)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"Error in callback for {event}: {e}")
|
|
199
|
+
|
|
200
|
+
# Publish to EventBus if available
|
|
201
|
+
if self.event_bus:
|
|
202
|
+
try:
|
|
203
|
+
agent_event_type = event.to_agent_event_type()
|
|
204
|
+
if agent_event_type:
|
|
205
|
+
await self.event_bus.publish(
|
|
206
|
+
agent_event_type,
|
|
207
|
+
event.to_payload(),
|
|
208
|
+
source=f"watcher.{self.name}",
|
|
209
|
+
)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error(f"Error publishing to EventBus: {e}")
|
|
212
|
+
|
|
213
|
+
def _get_file_hash(self, file_path: Path) -> Optional[str]:
|
|
214
|
+
"""Get a hash of file content for change detection."""
|
|
215
|
+
try:
|
|
216
|
+
import hashlib
|
|
217
|
+
content = file_path.read_text(encoding="utf-8")
|
|
218
|
+
return hashlib.md5(content.encode()).hexdigest()
|
|
219
|
+
except Exception:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
def _read_file_content(self, file_path: Path) -> Optional[str]:
|
|
223
|
+
"""Read file content safely."""
|
|
224
|
+
try:
|
|
225
|
+
return file_path.read_text(encoding="utf-8")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.debug(f"Could not read {file_path}: {e}")
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
231
|
+
"""Get watcher statistics."""
|
|
232
|
+
return {
|
|
233
|
+
"name": self.name,
|
|
234
|
+
"running": self._running,
|
|
235
|
+
"config": {
|
|
236
|
+
"path": str(self.config.path),
|
|
237
|
+
"patterns": self.config.patterns,
|
|
238
|
+
"recursive": self.config.recursive,
|
|
239
|
+
},
|
|
240
|
+
"callbacks": len(self._callbacks),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class PollingWatcher(FilesystemWatcher):
|
|
245
|
+
"""
|
|
246
|
+
Base class for polling-based file watchers.
|
|
247
|
+
|
|
248
|
+
Useful for watching specific files or when native file system
|
|
249
|
+
events are not available/reliable.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
config: WatchConfig,
|
|
255
|
+
event_bus: Optional[EventBus] = None,
|
|
256
|
+
name: Optional[str] = None,
|
|
257
|
+
):
|
|
258
|
+
super().__init__(config, event_bus, name)
|
|
259
|
+
self._poll_task: Optional[asyncio.Task] = None
|
|
260
|
+
self._file_states: Dict[Path, Dict[str, Any]] = {}
|
|
261
|
+
|
|
262
|
+
async def start(self) -> None:
|
|
263
|
+
"""Start polling loop."""
|
|
264
|
+
if self._running:
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
self._running = True
|
|
268
|
+
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
269
|
+
logger.info(f"Started polling watcher: {self.name}")
|
|
270
|
+
|
|
271
|
+
async def stop(self) -> None:
|
|
272
|
+
"""Stop polling loop."""
|
|
273
|
+
if not self._running:
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
self._running = False
|
|
277
|
+
|
|
278
|
+
if self._poll_task:
|
|
279
|
+
self._poll_task.cancel()
|
|
280
|
+
try:
|
|
281
|
+
await self._poll_task
|
|
282
|
+
except asyncio.CancelledError:
|
|
283
|
+
pass
|
|
284
|
+
self._poll_task = None
|
|
285
|
+
|
|
286
|
+
logger.info(f"Stopped polling watcher: {self.name}")
|
|
287
|
+
|
|
288
|
+
async def _poll_loop(self) -> None:
|
|
289
|
+
"""Main polling loop."""
|
|
290
|
+
while self._running:
|
|
291
|
+
try:
|
|
292
|
+
await self._check_changes()
|
|
293
|
+
await asyncio.sleep(self.config.poll_interval)
|
|
294
|
+
except asyncio.CancelledError:
|
|
295
|
+
break
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.error(f"Error in poll loop: {e}")
|
|
298
|
+
await asyncio.sleep(self.config.poll_interval)
|
|
299
|
+
|
|
300
|
+
@abstractmethod
|
|
301
|
+
async def _check_changes(self) -> None:
|
|
302
|
+
"""Check for changes - implement in subclass."""
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
def _scan_files(self) -> Dict[Path, Dict[str, Any]]:
|
|
306
|
+
"""Scan watched path and return file states."""
|
|
307
|
+
states = {}
|
|
308
|
+
|
|
309
|
+
if self.config.path.is_file():
|
|
310
|
+
files = [self.config.path]
|
|
311
|
+
else:
|
|
312
|
+
if self.config.recursive:
|
|
313
|
+
files = list(self.config.path.rglob("*"))
|
|
314
|
+
else:
|
|
315
|
+
files = list(self.config.path.glob("*"))
|
|
316
|
+
|
|
317
|
+
for file_path in files:
|
|
318
|
+
if not file_path.is_file():
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
if not self.config.should_watch(file_path):
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
stat = file_path.stat()
|
|
326
|
+
content = self._read_file_content(file_path)
|
|
327
|
+
states[file_path] = {
|
|
328
|
+
"mtime": stat.st_mtime,
|
|
329
|
+
"size": stat.st_size,
|
|
330
|
+
"content": content,
|
|
331
|
+
"hash": self._get_file_hash(file_path) if content else None,
|
|
332
|
+
}
|
|
333
|
+
except Exception as e:
|
|
334
|
+
logger.debug(f"Could not stat {file_path}: {e}")
|
|
335
|
+
|
|
336
|
+
return states
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class WatchdogWatcher(FilesystemWatcher):
|
|
340
|
+
"""
|
|
341
|
+
Base class for watchdog-based file watchers.
|
|
342
|
+
|
|
343
|
+
Uses the watchdog library for efficient native file system events.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
def __init__(
|
|
347
|
+
self,
|
|
348
|
+
config: WatchConfig,
|
|
349
|
+
event_bus: Optional[EventBus] = None,
|
|
350
|
+
name: Optional[str] = None,
|
|
351
|
+
):
|
|
352
|
+
super().__init__(config, event_bus, name)
|
|
353
|
+
self._observer: Optional[Any] = None
|
|
354
|
+
|
|
355
|
+
def _should_process(self, file_path: Path) -> bool:
|
|
356
|
+
"""Check if a file should be processed."""
|
|
357
|
+
# Skip hidden files
|
|
358
|
+
if file_path.name.startswith("."):
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
# Skip temporary files
|
|
362
|
+
if file_path.suffix in (".tmp", ".temp", ".part", ".swp", "~"):
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
return self.config.should_watch(file_path)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DropzoneWatcher - Adapter for the existing dropzone watcher.
|
|
3
|
+
|
|
4
|
+
Part of Layer 1 (File Watcher) in the event automation framework.
|
|
5
|
+
Wraps the existing ingestion watcher to fit the FilesystemWatcher interface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from monoco.core.scheduler import AgentEventType, EventBus, event_bus
|
|
16
|
+
from monoco.core.ingestion.watcher import (
|
|
17
|
+
DropzoneWatcher as LegacyDropzoneWatcher,
|
|
18
|
+
IngestionEvent,
|
|
19
|
+
IngestionEventType,
|
|
20
|
+
)
|
|
21
|
+
from monoco.core.artifacts.manager import ArtifactManager
|
|
22
|
+
|
|
23
|
+
from .base import (
|
|
24
|
+
ChangeType,
|
|
25
|
+
FileEvent,
|
|
26
|
+
FilesystemWatcher,
|
|
27
|
+
WatchConfig,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DropzoneFileEvent(FileEvent):
|
|
34
|
+
"""FileEvent specific to Dropzone."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
path: Path,
|
|
39
|
+
change_type: ChangeType,
|
|
40
|
+
ingestion_event_type: IngestionEventType,
|
|
41
|
+
artifact_id: Optional[str] = None,
|
|
42
|
+
**kwargs,
|
|
43
|
+
):
|
|
44
|
+
super().__init__(
|
|
45
|
+
path=path,
|
|
46
|
+
change_type=change_type,
|
|
47
|
+
watcher_name="DropzoneWatcher",
|
|
48
|
+
**kwargs,
|
|
49
|
+
)
|
|
50
|
+
self.ingestion_event_type = ingestion_event_type
|
|
51
|
+
self.artifact_id = artifact_id
|
|
52
|
+
|
|
53
|
+
def to_agent_event_type(self) -> Optional[AgentEventType]:
|
|
54
|
+
"""Dropzone events don't map directly to agent events."""
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def to_payload(self) -> Dict[str, Any]:
|
|
58
|
+
"""Convert to payload with Dropzone-specific fields."""
|
|
59
|
+
payload = super().to_payload()
|
|
60
|
+
payload["ingestion_event_type"] = self.ingestion_event_type.value
|
|
61
|
+
payload["artifact_id"] = self.artifact_id
|
|
62
|
+
return payload
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DropzoneWatcher(FilesystemWatcher):
|
|
66
|
+
"""
|
|
67
|
+
Adapter for the existing DropzoneWatcher.
|
|
68
|
+
|
|
69
|
+
Wraps the legacy ingestion watcher to provide a unified interface
|
|
70
|
+
while maintaining backward compatibility.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
>>> from monoco.core.artifacts.manager import ArtifactManager
|
|
74
|
+
>>> artifact_manager = ArtifactManager()
|
|
75
|
+
>>> config = WatchConfig(path=Path("./.monoco/dropzone"))
|
|
76
|
+
>>> watcher = DropzoneWatcher(config, artifact_manager)
|
|
77
|
+
>>> await watcher.start()
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
config: WatchConfig,
|
|
83
|
+
artifact_manager: ArtifactManager,
|
|
84
|
+
event_bus: Optional[EventBus] = None,
|
|
85
|
+
name: str = "DropzoneWatcher",
|
|
86
|
+
):
|
|
87
|
+
super().__init__(config, event_bus, name)
|
|
88
|
+
self.artifact_manager = artifact_manager
|
|
89
|
+
|
|
90
|
+
# Create legacy watcher
|
|
91
|
+
self._legacy_watcher = LegacyDropzoneWatcher(
|
|
92
|
+
dropzone_path=config.path,
|
|
93
|
+
artifact_manager=artifact_manager,
|
|
94
|
+
process_existing=False,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Set up event forwarding
|
|
98
|
+
self._legacy_watcher.set_event_callback(self._on_ingestion_event)
|
|
99
|
+
|
|
100
|
+
async def start(self) -> None:
|
|
101
|
+
"""Start watching the dropzone."""
|
|
102
|
+
if self._running:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
self._legacy_watcher.start()
|
|
106
|
+
self._running = True
|
|
107
|
+
logger.info(f"Started DropzoneWatcher: {self.config.path}")
|
|
108
|
+
|
|
109
|
+
async def stop(self) -> None:
|
|
110
|
+
"""Stop watching the dropzone."""
|
|
111
|
+
if not self._running:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
self._legacy_watcher.stop()
|
|
115
|
+
self._running = False
|
|
116
|
+
logger.info(f"Stopped DropzoneWatcher: {self.config.path}")
|
|
117
|
+
|
|
118
|
+
def _on_ingestion_event(self, event: IngestionEvent) -> None:
|
|
119
|
+
"""Handle ingestion events from legacy watcher."""
|
|
120
|
+
# Map ingestion event type to change type
|
|
121
|
+
change_type_map = {
|
|
122
|
+
IngestionEventType.FILE_DETECTED: ChangeType.CREATED,
|
|
123
|
+
IngestionEventType.CONVERSION_STARTED: ChangeType.MODIFIED,
|
|
124
|
+
IngestionEventType.CONVERSION_COMPLETED: ChangeType.MODIFIED,
|
|
125
|
+
IngestionEventType.CONVERSION_FAILED: ChangeType.MODIFIED,
|
|
126
|
+
IngestionEventType.ARTIFACT_REGISTERED: ChangeType.MODIFIED,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
change_type = change_type_map.get(event.event_type, ChangeType.MODIFIED)
|
|
130
|
+
|
|
131
|
+
# Create unified event
|
|
132
|
+
file_event = DropzoneFileEvent(
|
|
133
|
+
path=event.file_path,
|
|
134
|
+
change_type=change_type,
|
|
135
|
+
ingestion_event_type=event.event_type,
|
|
136
|
+
artifact_id=event.artifact_id,
|
|
137
|
+
metadata={
|
|
138
|
+
"task_id": event.task_id,
|
|
139
|
+
"error_message": event.error_message,
|
|
140
|
+
**event.metadata,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Emit synchronously (called from sync context)
|
|
145
|
+
asyncio.create_task(self.emit(file_event))
|
|
146
|
+
|
|
147
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
148
|
+
"""Get watcher statistics."""
|
|
149
|
+
stats = super().get_stats()
|
|
150
|
+
legacy_stats = self._legacy_watcher.get_stats()
|
|
151
|
+
stats.update(legacy_stats)
|
|
152
|
+
return stats
|