monoco-toolkit 0.1.6__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/agent/__init__.py +5 -0
- monoco/core/agent/action.py +144 -0
- monoco/core/agent/adapters.py +106 -0
- monoco/core/agent/protocol.py +31 -0
- monoco/core/agent/state.py +117 -0
- monoco/core/config.py +42 -1
- monoco/core/execution.py +62 -0
- monoco/core/git.py +51 -2
- monoco/core/output.py +13 -2
- monoco/core/state.py +53 -0
- monoco/core/workspace.py +75 -12
- monoco/daemon/app.py +120 -57
- monoco/daemon/models.py +4 -0
- monoco/daemon/services.py +56 -155
- monoco/features/agent/commands.py +160 -0
- monoco/features/agent/doctor.py +30 -0
- monoco/features/issue/core.py +80 -47
- monoco/features/issue/executions/refine.md +26 -0
- monoco/features/issue/models.py +18 -6
- monoco/features/issue/monitor.py +94 -0
- monoco/main.py +13 -0
- {monoco_toolkit-0.1.6.dist-info → monoco_toolkit-0.2.0.dist-info}/METADATA +1 -1
- {monoco_toolkit-0.1.6.dist-info → monoco_toolkit-0.2.0.dist-info}/RECORD +26 -15
- {monoco_toolkit-0.1.6.dist-info → monoco_toolkit-0.2.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.1.6.dist-info → monoco_toolkit-0.2.0.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.1.6.dist-info → monoco_toolkit-0.2.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
60
|
+
Returns list of MonocoProject instances.
|
|
19
61
|
"""
|
|
20
62
|
projects = []
|
|
21
63
|
|
|
22
64
|
# 1. Check workspace root itself
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
-
# ---
|
|
315
|
-
import json
|
|
316
|
-
from pydantic import BaseModel
|
|
389
|
+
# --- Execution Profiles ---
|
|
317
390
|
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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,
|
|
107
|
-
self.
|
|
108
|
-
self.
|
|
109
|
-
self.
|
|
110
|
-
self.
|
|
111
|
-
self.issues_root =
|
|
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
|
|
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
|
-
|
|
91
|
+
workspace = Workspace.discover(self.workspace_root)
|
|
136
92
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
Holds the runtime state for a single project.
|
|
127
|
+
Now wraps the core MonocoProject primitive.
|
|
240
128
|
"""
|
|
241
|
-
def __init__(self,
|
|
242
|
-
self.
|
|
243
|
-
self.
|
|
244
|
-
self.
|
|
245
|
-
self.
|
|
246
|
-
self.
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
self.
|
|
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
|
-
|
|
263
|
-
self.observer.stop()
|
|
264
|
-
self.observer.join()
|
|
265
|
-
logger.info("Issue Monitor stopped.")
|
|
166
|
+
self.monitor.stop()
|