Upload 4 files
Browse files- Dockerfile +15 -0
- api.py +85 -0
- requirements.txt +5 -0
- skill_adapter.py +45 -0
Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
|
| 5 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 6 |
+
|
| 7 |
+
COPY requirements.txt .
|
| 8 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
|
| 10 |
+
COPY api.py .
|
| 11 |
+
COPY skill_adapter.py .
|
| 12 |
+
|
| 13 |
+
ENV PORT=7860
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
|
api.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException
|
| 2 |
+
from fastapi.responses import HTMLResponse
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Optional, Literal
|
| 5 |
+
import asyncio, os, uuid, requests
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from skill_adapter import build_full_flow_plan, build_open_url_plan
|
| 8 |
+
|
| 9 |
+
app = FastAPI()
|
| 10 |
+
|
| 11 |
+
agents, queues, jobs, results, waiters = {}, {}, {}, {}, {}
|
| 12 |
+
ADMIN_SECRET = os.environ.get("ADMIN_SECRET")
|
| 13 |
+
|
| 14 |
+
def now(): return datetime.utcnow().isoformat()+"Z"
|
| 15 |
+
|
| 16 |
+
def cond_for(agent_id):
|
| 17 |
+
if agent_id not in waiters: waiters[agent_id]=asyncio.Condition()
|
| 18 |
+
return waiters[agent_id]
|
| 19 |
+
|
| 20 |
+
def push_job(agent_id, payload):
|
| 21 |
+
job_id = str(uuid.uuid4())
|
| 22 |
+
job = {"id":job_id,"agent_id":agent_id,"payload":payload,"status":"queued","created_at":now(),"updated_at":now()}
|
| 23 |
+
jobs[job_id]=job
|
| 24 |
+
queues.setdefault(agent_id,[]).append(job)
|
| 25 |
+
async def notify():
|
| 26 |
+
async with cond_for(agent_id): cond_for(agent_id).notify_all()
|
| 27 |
+
asyncio.create_task(notify())
|
| 28 |
+
return job
|
| 29 |
+
|
| 30 |
+
class RegisterBody(BaseModel): display_name:str
|
| 31 |
+
class RegisterResp(BaseModel): agent_id:str; token:str
|
| 32 |
+
class EnqueuePlanBody(BaseModel): agent_id:str; plan:list; admin_secret:Optional[str]=None
|
| 33 |
+
class SubmitResultBody(BaseModel):
|
| 34 |
+
agent_id:str; token:str; job_id:str
|
| 35 |
+
status:Literal["completed","failed"]; message:Optional[str]=None; artifacts:Optional[dict]=None
|
| 36 |
+
class FullFlowReq(BaseModel): agent_id:str; email:str; password:str; project_id:str; admin_secret:Optional[str]=None
|
| 37 |
+
class OpenURLReq(BaseModel): agent_id:str; url:str; admin_secret:Optional[str]=None
|
| 38 |
+
|
| 39 |
+
@app.post("/register", response_model=RegisterResp)
|
| 40 |
+
async def register(body:RegisterBody):
|
| 41 |
+
agent_id, token = str(uuid.uuid4()), str(uuid.uuid4())
|
| 42 |
+
agents[agent_id]={"agent_id":agent_id,"token":token,"display_name":body.display_name,"registered_at":now(),"last_seen":now()}
|
| 43 |
+
queues[agent_id]=[]
|
| 44 |
+
return RegisterResp(agent_id=agent_id, token=token)
|
| 45 |
+
|
| 46 |
+
@app.get("/agents")
|
| 47 |
+
async def list_agents(): return {"agents":list(agents.values())}
|
| 48 |
+
|
| 49 |
+
@app.get("/next-job")
|
| 50 |
+
async def next_job(agent_id:str, token:str, wait:int=25):
|
| 51 |
+
a=agents.get(agent_id)
|
| 52 |
+
if not a or a["token"]!=token: raise HTTPException(status_code=401, detail="bad agent/token")
|
| 53 |
+
a["last_seen"]=now(); q=queues.get(agent_id,[])
|
| 54 |
+
if q: job=q.pop(0); job["status"]="taken"; job["updated_at"]=now(); return {"job":job}
|
| 55 |
+
c=cond_for(agent_id)
|
| 56 |
+
try:
|
| 57 |
+
async with asyncio.timeout(wait):
|
| 58 |
+
async with c: await c.wait()
|
| 59 |
+
q=queues.get(agent_id,[])
|
| 60 |
+
if q: job=q.pop(0); job["status"]="taken"; job["updated_at"]=now(); return {"job":job}
|
| 61 |
+
except: pass
|
| 62 |
+
return {}
|
| 63 |
+
|
| 64 |
+
@app.post("/submit-result")
|
| 65 |
+
async def submit_result(body:SubmitResultBody):
|
| 66 |
+
j=jobs.get(body.job_id);
|
| 67 |
+
if not j: raise HTTPException(status_code=404, detail="no job")
|
| 68 |
+
j["status"]=body.status; j["updated_at"]=now()
|
| 69 |
+
results[body.job_id]={"status":body.status,"message":body.message,"artifacts":body.artifacts,"submitted_at":now()}
|
| 70 |
+
return {"ok":True}
|
| 71 |
+
|
| 72 |
+
@app.post("/skill/full-flow")
|
| 73 |
+
async def skill_full_flow(body:FullFlowReq):
|
| 74 |
+
if ADMIN_SECRET and body.admin_secret!=ADMIN_SECRET: raise HTTPException(status_code=401, detail="bad secret")
|
| 75 |
+
plan=build_full_flow_plan(body.email, body.password, body.project_id)
|
| 76 |
+
job=push_job(body.agent_id, {"type":"plan","steps":plan}); return {"job":job}
|
| 77 |
+
|
| 78 |
+
@app.post("/skill/open-url")
|
| 79 |
+
async def skill_open_url(body:OpenURLReq):
|
| 80 |
+
if ADMIN_SECRET and body.admin_secret!=ADMIN_SECRET: raise HTTPException(status_code=401, detail="bad secret")
|
| 81 |
+
plan=build_open_url_plan(body.url)
|
| 82 |
+
job=push_job(body.agent_id, {"type":"plan","steps":plan}); return {"job":job}
|
| 83 |
+
|
| 84 |
+
@app.get("/")
|
| 85 |
+
async def home(): return HTMLResponse("<h3>SkillFlow Server Running</h3>")
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.30.1
|
| 3 |
+
pydantic==2.7.4
|
| 4 |
+
jinja2==3.1.4
|
| 5 |
+
requests==2.32.3
|
skill_adapter.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict
|
| 2 |
+
|
| 3 |
+
LAB_URL = "https://www.cloudskillsboost.google/course_templates/976/labs/550878"
|
| 4 |
+
SIGN_IN_URL = "https://accounts.google.com/signin/v2/identifier"
|
| 5 |
+
MARKETPLACE_URL = "https://console.cloud.google.com/marketplace/product/google/generativelanguage.googleapis.com"
|
| 6 |
+
|
| 7 |
+
def step(act, **kw) -> Dict:
|
| 8 |
+
d = {"act": act}; d.update(kw); return d
|
| 9 |
+
|
| 10 |
+
def wait_css(sel: str, timeout: int = 25) -> Dict:
|
| 11 |
+
return {"act": "wait", "by": "css", "sel": sel, "timeout": timeout}
|
| 12 |
+
|
| 13 |
+
def click_css(sel: str) -> Dict:
|
| 14 |
+
return {"act": "click", "by": "css", "sel": sel}
|
| 15 |
+
|
| 16 |
+
def type_css(sel: str, text: str) -> Dict:
|
| 17 |
+
return {"act": "type", "by": "css", "sel": sel, "text": text}
|
| 18 |
+
|
| 19 |
+
def screenshot(label: str) -> Dict:
|
| 20 |
+
return {"act": "screenshot", "label": label}
|
| 21 |
+
|
| 22 |
+
def click_xpath(xpath: str) -> Dict:
|
| 23 |
+
return {"act": "click", "by": "xpath", "sel": xpath}
|
| 24 |
+
|
| 25 |
+
def wait_xpath(xpath: str, timeout: int = 25) -> Dict:
|
| 26 |
+
return {"act": "wait", "by": "xpath", "sel": xpath, "timeout": timeout}
|
| 27 |
+
|
| 28 |
+
def build_open_url_plan(url: str) -> List[Dict]:
|
| 29 |
+
return [ step("get", sel=url), wait_css("body", 20), screenshot("opened") ]
|
| 30 |
+
|
| 31 |
+
def build_full_flow_plan(email: str, password: str, project_id: str) -> List[Dict]:
|
| 32 |
+
steps: List[Dict] = []
|
| 33 |
+
# NOTE: shortened for clarity; fill in with full plan steps from previous response
|
| 34 |
+
steps += [
|
| 35 |
+
step("get", sel=SIGN_IN_URL),
|
| 36 |
+
wait_css("input[type=email]"),
|
| 37 |
+
type_css("input[type=email]", email),
|
| 38 |
+
click_css("#identifierNext"),
|
| 39 |
+
wait_css("input[type=password]"),
|
| 40 |
+
type_css("input[type=password]", password),
|
| 41 |
+
click_css("#passwordNext"),
|
| 42 |
+
wait_css("body", 40),
|
| 43 |
+
screenshot("after_login")
|
| 44 |
+
]
|
| 45 |
+
return steps
|