Skip to content

Commit ae875e6

Browse files
Homer QuanHomer Quan
authored andcommitted
auto: update all repos
1 parent 0fee2f7 commit ae875e6

21 files changed

Lines changed: 1341 additions & 548 deletions

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ The API is a FastAPI/Uvicorn service that exposes runtime operations over REST a
77
## Features
88

99
- Health and runtime summary endpoints.
10+
- Blueprint catalog list/detail/install/run endpoints for desktop Worker Hub clients.
1011
- Job submission from JSON manifests or uploaded bundle ZIP files.
1112
- Job listing, status, event, graph, metrics, and dead-letter endpoints.
1213
- Job lifecycle controls for cancel, pause, resume, and cleanup.
@@ -63,6 +64,7 @@ All runtime configuration uses environment variables.
6364
| `MN_GRPC_TIMEOUT_SECONDS` | `10` | SDK call timeout. Use `0` or `none` to disable. |
6465
| `MN_API_REQUEST_SIZE_LIMIT_BYTES` | `5242880` | Maximum request body size. |
6566
| `MN_API_CORS_ALLOW_ORIGINS` | unset | Comma-separated CORS allowlist. |
67+
| `MN_BLUEPRINT_REPO` | `~/Projects/otterdesk-blueprints` | Blueprint catalog repository used by `/blueprints` endpoints. |
6668
| `MN_API_LOG_PATH` | `~/.mn/logs/api.log` | API log file path. |
6769
| `MN_LOG_LEVEL` | package default | Log level used by shared logging setup. |
6870
| `MN_LOG_MAX_BYTES` | package default | Rotating log size limit. |
@@ -100,6 +102,10 @@ Base path: `/api/v1`
100102
| `GET` | `/health` | Service health check. |
101103
| `GET` | `/system/summary` | Runtime hardware and pool summary. |
102104
| `GET` | `/metrics` | Runtime metrics summary. |
105+
| `GET` | `/blueprints` | List normalized blueprint catalog entries from `MN_BLUEPRINT_REPO`. |
106+
| `GET` | `/blueprints/{blueprint_id}` | Fetch one normalized blueprint. |
107+
| `POST` | `/blueprints/{blueprint_id}/install` | Validate/cache a blueprint bundle for local use. |
108+
| `POST` | `/blueprints/{blueprint_id}/runs` | Prepare and submit a blueprint bundle, returning `job_id` and `run_id`. |
103109
| `POST` | `/jobs` | Submit a workflow from a JSON manifest. |
104110
| `POST` | `/bundles/upload` | Upload and submit a bundle ZIP. |
105111
| `GET` | `/jobs` | List jobs. |

‎mn_api/agent_graph.py‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Dict, Optional
4+
import json
5+
import logging
6+
from pathlib import Path
7+
8+
9+
logger = logging.getLogger("mn-api")
10+
11+
12+
def build_agent_graph(job_id: str, details: Dict[str, Any], events: list[Dict[str, Any]]):
13+
agents = details.get("agents", []) or []
14+
job = details.get("job", {}) or {}
15+
manifest = job.get("topology") or load_manifest_for_job(job)
16+
agent_by_id: Dict[str, Dict[str, Any]] = {}
17+
18+
for agent in agents:
19+
agent_id = agent.get("agent_id") or agent.get("node_id")
20+
if agent_id:
21+
agent_by_id[agent_id] = agent
22+
23+
for node in manifest.get("nodes", []) if isinstance(manifest, dict) else []:
24+
node_id = node.get("node_id") or node.get("agent_id")
25+
if node_id:
26+
agent_by_id.setdefault(
27+
node_id,
28+
{
29+
"agent_id": node_id,
30+
"agent_type": node.get("agent_type") or "unknown",
31+
"type": node.get("type") or "unknown",
32+
"status": "declared",
33+
"assigned_node": "unassigned",
34+
"processed_messages": 0,
35+
"mailbox_depth": 0,
36+
},
37+
)
38+
39+
edge_counts: Dict[tuple[str, str, str], Dict[str, Any]] = {}
40+
41+
for edge in manifest.get("edges", []) if isinstance(manifest, dict) else []:
42+
source = edge.get("from_node")
43+
target = edge.get("to_node")
44+
message_type = edge.get("message_type") or "*"
45+
if not source or not target:
46+
continue
47+
48+
ensure_graph_agent(agent_by_id, source)
49+
ensure_graph_agent(agent_by_id, target)
50+
key = (source, target, message_type)
51+
edge_counts.setdefault(
52+
key,
53+
{
54+
"id": edge.get("edge_id") or f"{source}->{target}:{message_type}",
55+
"source": source,
56+
"target": target,
57+
"message_type": message_type,
58+
"count": 0,
59+
"last_seen_at": None,
60+
"source_event": "manifest",
61+
},
62+
)
63+
64+
for event in events:
65+
message = event_message_summary(event)
66+
if not message:
67+
continue
68+
69+
source = message.get("from")
70+
target = message.get("to") or event.get("agent_id")
71+
message_type = message.get("type") or event.get("type") or "message"
72+
73+
if not source or not target:
74+
continue
75+
76+
ensure_graph_agent(agent_by_id, source)
77+
ensure_graph_agent(agent_by_id, target)
78+
key = (source, target, message_type)
79+
existing = edge_counts.setdefault(
80+
key,
81+
{
82+
"id": f"{source}->{target}:{message_type}",
83+
"source": source,
84+
"target": target,
85+
"message_type": message_type,
86+
"count": 0,
87+
"last_seen_at": None,
88+
"source_event": "agent_message_received",
89+
},
90+
)
91+
existing["count"] += 1
92+
existing["last_seen_at"] = event.get("timestamp") or existing["last_seen_at"]
93+
if existing.get("source_event") == "manifest":
94+
existing["source_event"] = "manifest+events"
95+
96+
for agent in agents:
97+
source = agent.get("agent_id") or agent.get("node_id")
98+
outbound_edges = (agent.get("metadata") or {}).get("outbound_edges") or []
99+
for target in outbound_edges:
100+
if not source or not target:
101+
continue
102+
ensure_graph_agent(agent_by_id, source)
103+
ensure_graph_agent(agent_by_id, target)
104+
key = (source, target, "*")
105+
edge_counts.setdefault(
106+
key,
107+
{
108+
"id": f"{source}->{target}:*",
109+
"source": source,
110+
"target": target,
111+
"message_type": "*",
112+
"count": 0,
113+
"last_seen_at": None,
114+
"source_event": "outbound_edges",
115+
},
116+
)
117+
118+
nodes = [
119+
{
120+
"id": agent_id,
121+
"label": agent_id,
122+
"agent_type": agent.get("agent_type") or "unknown",
123+
"type": agent.get("type") or "unknown",
124+
"status": agent.get("status") or "unknown",
125+
"assigned_node": agent.get("assigned_node") or "unassigned",
126+
"processed_messages": agent.get("processed_messages", 0),
127+
"mailbox_depth": agent.get("mailbox_depth", 0),
128+
}
129+
for agent_id, agent in sorted(agent_by_id.items())
130+
]
131+
132+
edges = sorted(edge_counts.values(), key=lambda edge: (edge["source"], edge["target"], edge["message_type"]))
133+
134+
return {
135+
"job_id": job_id,
136+
"graph_id": job.get("graph_id") or (details.get("summary") or {}).get("graph_id"),
137+
"status": job.get("status") or "unknown",
138+
"nodes": nodes,
139+
"edges": edges,
140+
"stats": {
141+
"agent_count": len(nodes),
142+
"edge_count": len(edges),
143+
"message_count": sum(edge.get("count", 0) for edge in edges),
144+
"event_count": len(events),
145+
},
146+
}
147+
148+
149+
def load_manifest_for_job(job: Dict[str, Any]) -> Dict[str, Any]:
150+
manifest_ref = job.get("manifest_ref") or {}
151+
manifest_path = manifest_ref.get("manifest_path")
152+
if not manifest_path:
153+
return {}
154+
155+
path = Path(manifest_path)
156+
if not path.is_file():
157+
return {}
158+
159+
try:
160+
manifest = json.loads(path.read_text())
161+
except Exception:
162+
logger.exception("Failed to load manifest for graph from %s", manifest_path)
163+
return {}
164+
165+
return manifest if isinstance(manifest, dict) else {}
166+
167+
168+
def event_message_summary(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
169+
payload = event.get("payload")
170+
if event.get("type") == "agent_message_received" and isinstance(payload, dict):
171+
return payload
172+
173+
if event.get("type") in {"backpressure_signal", "delivery_failed", "backpressure_rejected"} and isinstance(payload, dict):
174+
return payload
175+
176+
message = event.get("message")
177+
if isinstance(message, dict):
178+
envelope = message.get("envelope")
179+
if isinstance(envelope, dict):
180+
return envelope
181+
return message
182+
183+
return None
184+
185+
186+
def ensure_graph_agent(agent_by_id: Dict[str, Dict[str, Any]], agent_id: str):
187+
agent_by_id.setdefault(
188+
agent_id,
189+
{
190+
"agent_id": agent_id,
191+
"agent_type": "external",
192+
"type": "message",
193+
"status": "observed",
194+
"assigned_node": "unknown",
195+
"processed_messages": 0,
196+
"mailbox_depth": 0,
197+
},
198+
)

‎mn_api/app.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from __future__ import annotations
2+
3+
from fastapi import FastAPI
4+
from fastapi.middleware.cors import CORSMiddleware
5+
6+
from mn_api import state
7+
from mn_api.dependencies import enforce_request_size
8+
from mn_api.routes import blueprints, bundles, jobs, system
9+
10+
11+
def create_app() -> FastAPI:
12+
app = FastAPI(title="MirrorNeuron API", version="1.0")
13+
14+
if state.config.cors_allow_origins:
15+
app.add_middleware(
16+
CORSMiddleware,
17+
allow_origins=state.config.cors_allow_origins,
18+
allow_credentials=True,
19+
allow_methods=["*"],
20+
allow_headers=["*"],
21+
)
22+
23+
app.middleware("http")(enforce_request_size)
24+
app.include_router(system.router)
25+
app.include_router(blueprints.router)
26+
app.include_router(bundles.router)
27+
app.include_router(jobs.router)
28+
return app

0 commit comments

Comments
 (0)