monoco-toolkit 0.1.7__py3-none-any.whl → 0.2.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.
monoco/core/workspace.py CHANGED
@@ -1,36 +1,99 @@
1
1
  from pathlib import Path
2
- from typing import List, Optional
2
+ from typing import List, Optional, Dict
3
+ from pydantic import BaseModel, Field, ConfigDict
4
+
5
+ from monoco.core.config import get_config, MonocoConfig
6
+
7
+ class MonocoProject(BaseModel):
8
+ """
9
+ Representation of a single Monoco project.
10
+ """
11
+ id: str # Unique ID within the workspace (usually the directory name)
12
+ name: str
13
+ path: Path
14
+ config: MonocoConfig
15
+
16
+ @property
17
+ def issues_root(self) -> Path:
18
+ issues_path = Path(self.config.paths.issues)
19
+ if issues_path.is_absolute():
20
+ return issues_path
21
+ return (self.path / issues_path).resolve()
22
+
23
+ model_config = ConfigDict(arbitrary_types_allowed=True)
3
24
 
4
25
  def is_project_root(path: Path) -> bool:
5
26
  """
6
27
  Check if a directory serves as a Monoco project root.
7
28
  Criteria:
8
- - has .monoco/ directory
29
+ - has .monoco/ directory (which should contain config.yaml)
9
30
  """
10
31
  if not path.is_dir():
11
32
  return False
12
33
 
13
34
  return (path / ".monoco").is_dir()
14
35
 
15
- def find_projects(workspace_root: Path) -> List[Path]:
36
+ def load_project(path: Path) -> Optional[MonocoProject]:
37
+ """Load a project from a path if it is a valid project root."""
38
+ if not is_project_root(path):
39
+ return None
40
+
41
+ try:
42
+ config = get_config(str(path))
43
+ # If name is default, use directory name
44
+ name = config.project.name
45
+ if name == "Monoco Project":
46
+ name = path.name
47
+
48
+ return MonocoProject(
49
+ id=path.name,
50
+ name=name,
51
+ path=path,
52
+ config=config
53
+ )
54
+ except Exception:
55
+ return None
56
+
57
+ def find_projects(workspace_root: Path) -> List[MonocoProject]:
16
58
  """
17
59
  Scan for projects in a workspace.
18
- Returns list of paths that are project roots.
60
+ Returns list of MonocoProject instances.
19
61
  """
20
62
  projects = []
21
63
 
22
64
  # 1. Check workspace root itself
23
- if is_project_root(workspace_root):
24
- projects.append(workspace_root)
65
+ root_project = load_project(workspace_root)
66
+ if root_project:
67
+ projects.append(root_project)
25
68
 
26
69
  # 2. Check first-level subdirectories
27
- # Prevent scanning giant node_modules or .git
28
70
  for item in workspace_root.iterdir():
29
71
  if item.is_dir() and not item.name.startswith('.'):
30
- if is_project_root(item):
31
- # If workspace root is also a project, we might deduce duplicates
32
- # But here we just append items.
33
- # If workspace_root == item (impossible for iterdir child), no risk.
34
- projects.append(item)
72
+ # Avoid re-adding root if it was already added (unlikely due to iterdir)
73
+ if item == workspace_root: continue
74
+
75
+ p = load_project(item)
76
+ if p:
77
+ projects.append(p)
35
78
 
36
79
  return projects
80
+
81
+ class Workspace(BaseModel):
82
+ """
83
+ Standardized Workspace primitive.
84
+ """
85
+ root: Path
86
+ projects: List[MonocoProject] = []
87
+
88
+ @classmethod
89
+ def discover(cls, root: Path) -> "Workspace":
90
+ projects = find_projects(root)
91
+ return cls(root=root, projects=projects)
92
+
93
+ def get_project(self, project_id: str) -> Optional[MonocoProject]:
94
+ for p in self.projects:
95
+ if p.id == project_id:
96
+ return p
97
+ return None
98
+
99
+ model_config = ConfigDict(arbitrary_types_allowed=True)
monoco/daemon/app.py CHANGED
@@ -6,7 +6,9 @@ import asyncio
6
6
  import logging
7
7
  import os
8
8
  from typing import Optional, List, Dict
9
- from monoco.daemon.services import Broadcaster, GitMonitor, ProjectManager
9
+ from monoco.daemon.services import Broadcaster, ProjectManager
10
+ from monoco.core.git import GitMonitor
11
+ from monoco.core.config import get_config, ConfigMonitor, ConfigScope, get_config_path
10
12
  from fastapi import FastAPI, Request, HTTPException, Query
11
13
 
12
14
  # Configure logging
@@ -15,6 +17,7 @@ logger = logging.getLogger("monoco.daemon")
15
17
  from pathlib import Path
16
18
  from monoco.core.config import get_config
17
19
  from monoco.features.issue.core import list_issues
20
+ from monoco.core.execution import scan_execution_profiles, get_profile_detail, ExecutionProfile
18
21
 
19
22
  description = """
20
23
  Monoco Daemon Process
@@ -25,7 +28,8 @@ Monoco Daemon Process
25
28
 
26
29
  # Service Instances
27
30
  broadcaster = Broadcaster()
28
- git_monitor = GitMonitor(broadcaster)
31
+ git_monitor: GitMonitor | None = None
32
+ config_monitor: ConfigMonitor | None = None
29
33
  project_manager: ProjectManager | None = None
30
34
 
31
35
  @asynccontextmanager
@@ -33,23 +37,45 @@ async def lifespan(app: FastAPI):
33
37
  # Startup
34
38
  logger.info("Starting Monoco Daemon services...")
35
39
 
36
- global project_manager
40
+ global project_manager, git_monitor, config_monitor
37
41
  # Use MONOCO_SERVER_ROOT if set, otherwise CWD
38
42
  env_root = os.getenv("MONOCO_SERVER_ROOT")
39
43
  workspace_root = Path(env_root) if env_root else Path.cwd()
40
44
  logger.info(f"Workspace Root: {workspace_root}")
41
45
  project_manager = ProjectManager(workspace_root, broadcaster)
42
46
 
47
+ async def on_git_change(new_hash: str):
48
+ await broadcaster.broadcast("HEAD_UPDATED", {
49
+ "ref": "HEAD",
50
+ "hash": new_hash
51
+ })
52
+
53
+ async def on_config_change():
54
+ logger.info("Config file changed, broadcasting update...")
55
+ await broadcaster.broadcast("CONFIG_UPDATED", {
56
+ "scope": "workspace",
57
+ "path": str(workspace_root / ".monoco" / "config.yaml")
58
+ })
59
+
60
+ git_monitor = GitMonitor(workspace_root, on_git_change)
61
+
62
+ config_path = get_config_path(ConfigScope.PROJECT, workspace_root)
63
+ config_monitor = ConfigMonitor(config_path, on_config_change)
64
+
43
65
  await project_manager.start_all()
44
- monitor_task = asyncio.create_task(git_monitor.start())
66
+ git_task = asyncio.create_task(git_monitor.start())
67
+ config_task = asyncio.create_task(config_monitor.start())
45
68
 
46
69
  yield
47
70
  # Shutdown
48
71
  logger.info("Shutting down Monoco Daemon services...")
49
- git_monitor.stop()
72
+ if git_monitor:
73
+ git_monitor.stop()
74
+ if config_monitor:
75
+ config_monitor.stop()
50
76
  if project_manager:
51
77
  project_manager.stop_all()
52
- await monitor_task
78
+ await asyncio.gather(git_task, config_task)
53
79
 
54
80
  app = FastAPI(
55
81
  title="Monoco Daemon",
@@ -160,10 +186,33 @@ async def sse_endpoint(request: Request):
160
186
  return EventSourceResponse(event_generator())
161
187
 
162
188
  @app.get("/api/v1/issues")
163
- async def get_issues(project_id: Optional[str] = None):
164
- """
165
- List all issues in the project.
189
+ async def get_issues(
190
+ project_id: Optional[str] = None,
191
+ path: Optional[str] = Query(None, description="Absolute file path for reverse lookup")
192
+ ):
166
193
  """
194
+ List all issues in the project, or get a single issue by file path.
195
+
196
+ Query Parameters:
197
+ - project_id: Optional project filter
198
+ - path: Optional absolute file path for reverse lookup (returns single issue)
199
+
200
+ If 'path' is provided, returns a single IssueMetadata object.
201
+ Otherwise, returns a list of all issues.
202
+ """
203
+ # Reverse lookup by path
204
+ if path:
205
+ p = Path(path)
206
+ if not p.exists():
207
+ raise HTTPException(status_code=404, detail=f"File {path} not found")
208
+
209
+ issue = parse_issue(p)
210
+ if not issue:
211
+ raise HTTPException(status_code=400, detail=f"File {path} is not a valid Monoco issue")
212
+
213
+ return issue
214
+
215
+ # Standard list operation
167
216
  project = get_project_or_404(project_id)
168
217
  issues = list_issues(project.issues_root)
169
218
  return issues
@@ -221,11 +270,22 @@ async def create_issue_endpoint(payload: CreateIssueRequest):
221
270
  @app.get("/api/v1/issues/{issue_id}", response_model=IssueDetail)
222
271
  async def get_issue_endpoint(issue_id: str, project_id: Optional[str] = None):
223
272
  """
224
- Get issue details by ID.
273
+ Get issue details by ID. Supports cross-project search if project_id is omitted.
225
274
  """
226
- project = get_project_or_404(project_id)
227
-
228
- path = find_issue_path(project.issues_root, issue_id)
275
+ path = None
276
+ if project_id:
277
+ project = get_project_or_404(project_id)
278
+ path = find_issue_path(project.issues_root, issue_id)
279
+ else:
280
+ # Global Search across all projects in the workspace
281
+ if not project_manager:
282
+ raise HTTPException(status_code=503, detail="Daemon not fully initialized")
283
+
284
+ for p_ctx in project_manager.projects.values():
285
+ path = find_issue_path(p_ctx.issues_root, issue_id)
286
+ if path:
287
+ break
288
+
229
289
  if not path:
230
290
  raise HTTPException(status_code=404, detail=f"Issue {issue_id} not found")
231
291
 
@@ -235,21 +295,36 @@ async def get_issue_endpoint(issue_id: str, project_id: Optional[str] = None):
235
295
 
236
296
  return issue
237
297
 
298
+
238
299
  @app.patch("/api/v1/issues/{issue_id}", response_model=IssueMetadata)
239
300
  async def update_issue_endpoint(issue_id: str, payload: UpdateIssueRequest):
240
301
  """
241
- Update an issue logic state (Status, Stage, Solution).
302
+ Update an issue's metadata (Status, Stage, Solution, Parent, Dependencies, etc.).
242
303
  """
243
304
  project = get_project_or_404(payload.project_id)
244
305
 
245
306
  try:
307
+ # Pre-lookup to get the current path for move detection
308
+ old_path_obj = find_issue_path(project.issues_root, issue_id)
309
+ old_path = str(old_path_obj.absolute()) if old_path_obj else None
310
+
246
311
  issue = update_issue(
247
312
  project.issues_root,
248
313
  issue_id,
249
314
  status=payload.status,
250
315
  stage=payload.stage,
251
- solution=payload.solution
316
+ solution=payload.solution,
317
+ parent=payload.parent,
318
+ dependencies=payload.dependencies,
319
+ related=payload.related,
320
+ tags=payload.tags
252
321
  )
322
+
323
+ # Post-update: check if path changed
324
+ if old_path and issue.path != old_path:
325
+ # Trigger a specialized move event to help editors redirect
326
+ await project.notify_move(old_path, issue.path, issue.model_dump(mode='json'))
327
+
253
328
  return issue
254
329
  except FileNotFoundError:
255
330
  raise HTTPException(status_code=404, detail=f"Issue {issue_id} not found")
@@ -311,12 +386,34 @@ async def refresh_monitor():
311
386
  # Or just returning the hash confirms the daemon sees it.
312
387
  return {"status": "refreshed", "head": current_hash}
313
388
 
314
- # --- Workspace State Management ---
315
- import json
316
- from pydantic import BaseModel
389
+ # --- Execution Profiles ---
317
390
 
318
- class WorkspaceState(BaseModel):
319
- last_active_project_id: Optional[str] = None
391
+ @app.get("/api/v1/execution/profiles", response_model=List[ExecutionProfile])
392
+ async def get_execution_profiles(project_id: Optional[str] = None):
393
+ """
394
+ List all execution profiles available for the project/workspace.
395
+ """
396
+ project = None
397
+ if project_id:
398
+ project = get_project_or_404(project_id)
399
+ elif project_manager and project_manager.projects:
400
+ # Fallback to first project if none specified
401
+ project = list(project_manager.projects.values())[0]
402
+
403
+ return scan_execution_profiles(project.path if project else None)
404
+
405
+ @app.get("/api/v1/execution/profiles/detail", response_model=ExecutionProfile)
406
+ async def get_execution_profile_detail(path: str):
407
+ """
408
+ Get full content of an execution profile.
409
+ """
410
+ profile = get_profile_detail(path)
411
+ if not profile:
412
+ raise HTTPException(status_code=404, detail="Profile not found")
413
+ return profile
414
+
415
+ # --- Workspace State Management ---
416
+ from monoco.core.state import WorkspaceState
320
417
 
321
418
  @app.get("/api/v1/workspace/state", response_model=WorkspaceState)
322
419
  async def get_workspace_state():
@@ -326,21 +423,7 @@ async def get_workspace_state():
326
423
  if not project_manager:
327
424
  raise HTTPException(status_code=503, detail="Daemon not initialized")
328
425
 
329
- state_file = project_manager.workspace_root / ".monoco" / "state.json"
330
- if not state_file.exists():
331
- # Default empty state
332
- return WorkspaceState()
333
-
334
- try:
335
- content = state_file.read_text(encoding='utf-8')
336
- if not content.strip():
337
- return WorkspaceState()
338
- data = json.loads(content)
339
- return WorkspaceState(**data)
340
- except Exception as e:
341
- logger.error(f"Failed to read state file: {e}")
342
- # Return empty state instead of crashing, so frontend can fallback
343
- return WorkspaceState()
426
+ return WorkspaceState.load(project_manager.workspace_root)
344
427
 
345
428
  @app.post("/api/v1/workspace/state", response_model=WorkspaceState)
346
429
  async def update_workspace_state(state: WorkspaceState):
@@ -350,29 +433,9 @@ async def update_workspace_state(state: WorkspaceState):
350
433
  if not project_manager:
351
434
  raise HTTPException(status_code=503, detail="Daemon not initialized")
352
435
 
353
- state_file = project_manager.workspace_root / ".monoco" / "state.json"
354
-
355
- # Ensure directory exists
356
- if not state_file.parent.exists():
357
- state_file.parent.mkdir(parents=True, exist_ok=True)
358
-
359
436
  try:
360
- # We merge with existing state to avoid data loss if we extend model later
361
- current_data = {}
362
- if state_file.exists():
363
- try:
364
- content = state_file.read_text(encoding='utf-8')
365
- if content.strip():
366
- current_data = json.loads(content)
367
- except:
368
- pass # ignore read errors on write
369
-
370
- # Update with new values
371
- new_data = state.model_dump(exclude_unset=True)
372
- current_data.update(new_data)
373
-
374
- state_file.write_text(json.dumps(current_data, indent=2), encoding='utf-8')
375
- return WorkspaceState(**current_data)
437
+ state.save(project_manager.workspace_root)
438
+ return state
376
439
  except Exception as e:
377
440
  logger.error(f"Failed to write state file: {e}")
378
441
  raise HTTPException(status_code=500, detail=f"Failed to persist state: {str(e)}")
monoco/daemon/models.py CHANGED
@@ -17,6 +17,10 @@ class UpdateIssueRequest(BaseModel):
17
17
  status: Optional[IssueStatus] = None
18
18
  stage: Optional[IssueStage] = None
19
19
  solution: Optional[IssueSolution] = None
20
+ parent: Optional[str] = None
21
+ dependencies: Optional[List[str]] = None
22
+ related: Optional[List[str]] = None
23
+ tags: Optional[List[str]] = None
20
24
  project_id: Optional[str] = None
21
25
 
22
26
  class UpdateIssueContentRequest(BaseModel):
monoco/daemon/services.py CHANGED
@@ -46,69 +46,25 @@ class Broadcaster:
46
46
  logger.debug(f"Broadcasted {event_type} to {len(self.subscribers)} clients.")
47
47
 
48
48
 
49
- class GitMonitor:
50
- """
51
- Polls the Git repository for HEAD changes and triggers updates.
52
- """
53
- def __init__(self, broadcaster: Broadcaster, poll_interval: float = 2.0):
54
- self.broadcaster = broadcaster
55
- self.poll_interval = poll_interval
56
- self.last_head_hash: Optional[str] = None
57
- self.is_running = False
58
-
59
- async def get_head_hash(self) -> Optional[str]:
60
- try:
61
- # Run git rev-parse HEAD asynchronously
62
- process = await asyncio.create_subprocess_exec(
63
- "git", "rev-parse", "HEAD",
64
- stdout=asyncio.subprocess.PIPE,
65
- stderr=asyncio.subprocess.PIPE
66
- )
67
- stdout, _ = await process.communicate()
68
- if process.returncode == 0:
69
- return stdout.decode().strip()
70
- return None
71
- except Exception as e:
72
- logger.error(f"Git polling error: {e}")
73
- return None
74
-
75
- async def start(self):
76
- self.is_running = True
77
- logger.info("Git Monitor started.")
78
-
79
- # Initial check
80
- self.last_head_hash = await self.get_head_hash()
81
-
82
- while self.is_running:
83
- await asyncio.sleep(self.poll_interval)
84
- current_hash = await self.get_head_hash()
85
-
86
- if current_hash and current_hash != self.last_head_hash:
87
- logger.info(f"Git HEAD changed: {self.last_head_hash} -> {current_hash}")
88
- self.last_head_hash = current_hash
89
- await self.broadcaster.broadcast("HEAD_UPDATED", {
90
- "ref": "HEAD",
91
- "hash": current_hash
92
- })
93
-
94
- def stop(self):
95
- self.is_running = False
96
- logger.info("Git Monitor stopping...")
49
+ # Monitors moved to monoco.core.git and monoco.features.issue.monitor
97
50
 
98
51
  from watchdog.observers import Observer
99
52
  from watchdog.events import FileSystemEventHandler
100
53
  from monoco.core.config import MonocoConfig, get_config
101
54
 
55
+ from monoco.core.workspace import MonocoProject, Workspace
56
+
102
57
  class ProjectContext:
103
58
  """
104
59
  Holds the runtime state for a single project.
60
+ Now wraps the core MonocoProject primitive.
105
61
  """
106
- def __init__(self, path: Path, config: MonocoConfig, broadcaster: Broadcaster):
107
- self.path = path
108
- self.config = config
109
- self.id = path.name # Use directory name as ID for now
110
- self.name = config.project.name
111
- self.issues_root = path / config.paths.issues
62
+ def __init__(self, project: MonocoProject, broadcaster: Broadcaster):
63
+ self.project = project
64
+ self.id = project.id
65
+ self.name = project.name
66
+ self.path = project.path
67
+ self.issues_root = project.issues_root
112
68
  self.monitor = IssueMonitor(self.issues_root, broadcaster, project_id=self.id)
113
69
 
114
70
  async def start(self):
@@ -120,6 +76,7 @@ class ProjectContext:
120
76
  class ProjectManager:
121
77
  """
122
78
  Discovers and manages multiple Monoco projects within a workspace.
79
+ Uses core Workspace primitive for discovery.
123
80
  """
124
81
  def __init__(self, workspace_root: Path, broadcaster: Broadcaster):
125
82
  self.workspace_root = workspace_root
@@ -128,28 +85,16 @@ class ProjectManager:
128
85
 
129
86
  def scan(self):
130
87
  """
131
- Scans workspace for potential Monoco projects.
132
- A directory is a project if it has a .monoco/ directory.
88
+ Scans workspace for Monoco projects using core logic.
133
89
  """
134
90
  logger.info(f"Scanning workspace: {self.workspace_root}")
135
- from monoco.core.workspace import find_projects
91
+ workspace = Workspace.discover(self.workspace_root)
136
92
 
137
- projects = find_projects(self.workspace_root)
138
- for p in projects:
139
- self._register_project(p)
140
-
141
- def _register_project(self, path: Path):
142
- try:
143
- config = get_config(str(path))
144
- # If name is default, try to use directory name
145
- if config.project.name == "Monoco Project":
146
- config.project.name = path.name
147
-
148
- ctx = ProjectContext(path, config, self.broadcaster)
149
- self.projects[ctx.id] = ctx
150
- logger.info(f"Registered project: {ctx.id} ({ctx.path})")
151
- except Exception as e:
152
- logger.error(f"Failed to register project at {path}: {e}")
93
+ for project in workspace.projects:
94
+ if project.id not in self.projects:
95
+ ctx = ProjectContext(project, self.broadcaster)
96
+ self.projects[ctx.id] = ctx
97
+ logger.info(f"Registered project: {ctx.id} ({ctx.path})")
153
98
 
154
99
  async def start_all(self):
155
100
  self.scan()
@@ -174,92 +119,48 @@ class ProjectManager:
174
119
  for p in self.projects.values()
175
120
  ]
176
121
 
177
- class IssueEventHandler(FileSystemEventHandler):
178
- def __init__(self, loop, broadcaster: Broadcaster, project_id: str):
179
- self.loop = loop
180
- self.broadcaster = broadcaster
181
- self.project_id = project_id
182
-
183
- def _process_upsert(self, path_str: str):
184
- if not path_str.endswith(".md"):
185
- return
186
- asyncio.run_coroutine_threadsafe(self._handle_upsert(path_str), self.loop)
187
-
188
- async def _handle_upsert(self, path_str: str):
189
- try:
190
- path = Path(path_str)
191
- if not path.exists():
192
- return
193
- issue = parse_issue(path)
194
- if issue:
195
- await self.broadcaster.broadcast("issue_upserted", {
196
- "issue": issue.model_dump(mode='json'),
197
- "project_id": self.project_id
198
- })
199
- except Exception as e:
200
- logger.error(f"Error handling upsert for {path_str}: {e}")
122
+ from monoco.features.issue.monitor import IssueMonitor
201
123
 
202
- def _process_delete(self, path_str: str):
203
- if not path_str.endswith(".md"):
204
- return
205
- asyncio.run_coroutine_threadsafe(self._handle_delete(path_str), self.loop)
206
-
207
- async def _handle_delete(self, path_str: str):
208
- try:
209
- filename = Path(path_str).name
210
- match = re.match(r"([A-Z]+-\d{4})", filename)
211
- if match:
212
- issue_id = match.group(1)
213
- await self.broadcaster.broadcast("issue_deleted", {
214
- "id": issue_id,
215
- "project_id": self.project_id
216
- })
217
- except Exception as e:
218
- logger.error(f"Error handling delete for {path_str}: {e}")
219
-
220
- def on_created(self, event):
221
- if not event.is_directory:
222
- self._process_upsert(event.src_path)
223
-
224
- def on_modified(self, event):
225
- if not event.is_directory:
226
- self._process_upsert(event.src_path)
227
-
228
- def on_deleted(self, event):
229
- if not event.is_directory:
230
- self._process_delete(event.src_path)
231
-
232
- def on_moved(self, event):
233
- if not event.is_directory:
234
- self._process_delete(event.src_path)
235
- self._process_upsert(event.dest_path)
236
-
237
- class IssueMonitor:
124
+ class ProjectContext:
238
125
  """
239
- Monitor the Issues directory for changes using Watchdog and broadcast update events.
126
+ Holds the runtime state for a single project.
127
+ Now wraps the core MonocoProject primitive.
240
128
  """
241
- def __init__(self, issues_root: Path, broadcaster: Broadcaster, project_id: str):
242
- self.issues_root = issues_root
243
- self.broadcaster = broadcaster
244
- self.project_id = project_id
245
- self.observer = Observer()
246
- self.loop = None
247
-
248
- async def start(self):
249
- self.loop = asyncio.get_running_loop()
250
- event_handler = IssueEventHandler(self.loop, self.broadcaster, self.project_id)
129
+ def __init__(self, project: MonocoProject, broadcaster: Broadcaster):
130
+ self.project = project
131
+ self.id = project.id
132
+ self.name = project.name
133
+ self.path = project.path
134
+ self.issues_root = project.issues_root
251
135
 
252
- # Ensure directory exists
253
- if not self.issues_root.exists():
254
- logger.warning(f"Issues root {self.issues_root} does not exist. creating...")
255
- self.issues_root.mkdir(parents=True, exist_ok=True)
136
+ async def on_upsert(issue_data: dict):
137
+ await broadcaster.broadcast("issue_upserted", {
138
+ "issue": issue_data,
139
+ "project_id": self.id
140
+ })
141
+
142
+ async def on_delete(issue_data: dict):
143
+ # We skip broadcast here if it's part of a move?
144
+ # Actually, standard upsert/delete is fine, but we need a specialized event for MOVE
145
+ # to help VS Code redirect without closing/reopening.
146
+ await broadcaster.broadcast("issue_deleted", {
147
+ "id": issue_data["id"],
148
+ "project_id": self.id
149
+ })
150
+
151
+ self.monitor = IssueMonitor(self.issues_root, on_upsert, on_delete)
152
+
153
+ async def notify_move(self, old_path: str, new_path: str, issue_data: dict):
154
+ """Explicitly notify frontend about a logical move (Physical path changed)."""
155
+ await self.broadcaster.broadcast("issue_moved", {
156
+ "old_path": old_path,
157
+ "new_path": new_path,
158
+ "issue": issue_data,
159
+ "project_id": self.id
160
+ })
256
161
 
257
- self.observer.schedule(event_handler, str(self.issues_root), recursive=True)
258
- self.observer.start()
259
- logger.info(f"Issue Monitor started (Watchdog). Watching {self.issues_root}")
162
+ async def start(self):
163
+ await self.monitor.start()
260
164
 
261
165
  def stop(self):
262
- if self.observer.is_alive():
263
- self.observer.stop()
264
- self.observer.join()
265
- logger.info("Issue Monitor stopped.")
166
+ self.monitor.stop()