WORKFLOW-APP/scripts/task_worker.py

388 lines
15 KiB
Python
Raw Normal View History

feat: Task Kanban UI + Python task worker Adds the visible front-end and the worker back-end for the agent task system. The PocketBase schema, API routes, and ToastProvider were already in the previous commit; this commit completes the picture. UI - app/tasks/{page,KanbanBoard,TaskCard,CardModal,TaskDetail,api,columns} Full Kanban with six columns (Backlog, Ready, In Progress, Blocked, Done, Failed), drag-and-drop between columns, quick-add chips, type/priority filters, task detail panel with retry/cancel/ delete, 5s polling, transition toasts. - components/NavBar.tsx: 'Tasks' link in primary nav. - app/bizportal-2/BizPortal2Explorer.tsx: 'Send to Kanban' button on the result view — dispatches the current search to the agent queue as a 'ready' task. Back-end - scripts/task_worker.py: PocketBase polling worker with two lanes. Deterministic handles bizportal_search/reserve, pdf_generate, email_send. Custom lane writes to /tmp/agent_inbox/ for the LLM agent to pick up. Atomic claim via PB filter, auto-retry up to 3x, structured log on each task. - scripts/bizportal_service.py: bugfix — _is_logged_in was using a non-CSS selector so session-recovery was a no-op. Also made the login flow re-runnable mid-session (handles BizPortal's ~20-minute session timeout transparently). - scripts/init_pb_schema.py: duration_ms no longer capped at 0. Verified end-to-end: - CK search via worker: 1 company, 3 directors, ~10s - ID search via worker: 4 companies, 7 total directors, ~34s - Auto-retry + status transitions + toast fires
2026-08-17 16:57:02 +00:00
#!/usr/bin/env python3
"""
task_worker.py
==============
PocketBase task worker — pulls ready tasks and dispatches to the
right sidecar. Two lanes:
--lane deterministic
- bizportal_search → http://127.0.0.1:7001/search
- bizportal_reserve → http://127.0.0.1:7001/reserve
- pdf_generate → shell out to pdf-creator
- email_send → SMTP via the user's email config
(no LLM calls — runs fast, deterministic)
--lane custom
- custom → write a JSON file to /tmp/agent_inbox/ for
the LLM agent to pick up. The agent is
expected to PATCH the task back via the
/api/tasks/[id]/complete endpoint.
Both lanes share the same atomic claim protocol. Only one worker
ever holds a given task.
Usage:
python3 scripts/task_worker.py --lane deterministic
python3 scripts/task_worker.py --lane custom
"""
import argparse
import json
import logging
import os
import smtplib
import subprocess
import sys
import time
import urllib.request
import urllib.error
from email.mime.text import MIMEText
from pathlib import Path
from typing import Any, Dict, Optional
# ── Config ──────────────────────────────────────────────────────
ROOT = Path(__file__).resolve().parent.parent
ENV_FILE = ROOT / ".env.local"
DEFAULT_PB_URL = "https://pb.agent.apphub.co.za"
DEFAULT_TASKS_API = "http://127.0.0.1:6001" # the Next.js side
DEFAULT_BIZPORTAL_SERVICE = "http://127.0.0.1:7001"
DEFAULT_AGENT_INBOX = "/tmp/agent_inbox"
DEFAULT_PDF_CREATOR = "/Volumes/KIOXIA/.agents/skills/pdf-creator"
POLL_INTERVAL_S = 2.0
HTTP_TIMEOUT_S = 120
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
)
log = logging.getLogger(f"task-worker[{os.getpid()}]")
def load_env() -> Dict[str, str]:
env = dict(os.environ)
if ENV_FILE.exists():
for line in ENV_FILE.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
# ── HTTP helpers ────────────────────────────────────────────────
def http_json(url: str, method: str = "GET", headers: Optional[Dict] = None, body: Any = None):
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
h = dict(headers or {})
if data is not None and "Content-Type" not in h:
h["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
payload = resp.read().decode("utf-8")
return resp.status, json.loads(payload) if payload else None
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
try:
return e.code, json.loads(body)
except Exception:
return e.code, {"raw": body[:500]}
except Exception as e:
return 0, {"error": str(e)}
# ── Task lifecycle ─────────────────────────────────────────────
def list_ready(env: Dict[str, str], lane: str, limit: int = 1) -> list:
"""Fetch ready tasks. Filter by what the lane can handle."""
pb_url = env.get("PB_URL", DEFAULT_PB_URL)
# We go through the Next.js API so the auth flow is consistent.
api = env.get("TASKS_API", DEFAULT_TASKS_API)
qs = []
qs.append("status=ready")
if lane == "deterministic":
# Deterministic lane takes everything except custom
pass
elif lane == "custom":
# LLM lane takes only custom
qs.append("task_type=custom")
qs.append(f"limit={limit}")
url = f"{api}/api/tasks?{'&'.join(qs)}"
code, data = http_json(url, headers={"Authorization": env.get("PB_TOKEN", "")})
if code != 200:
log.warning(f"list ready failed: {code} {data}")
return []
if isinstance(data, dict) and "items" in data:
return data["items"]
return []
def claim_task(env: Dict[str, str], task_id: str) -> Optional[Dict]:
api = env.get("TASKS_API", DEFAULT_TASKS_API)
url = f"{api}/api/tasks/{task_id}/claim"
code, data = http_json(
url,
method="POST",
headers={"Authorization": env.get("PB_TOKEN", "")},
body={"claimed_by": f"task_worker[{os.getpid()}]:{lane_label(env)}"},
)
if code == 200 and isinstance(data, dict) and data.get("ok"):
return data.get("task")
return None
def complete_task(env: Dict[str, str], task_id: str, result: Any, log_lines: list, duration_ms: int) -> bool:
api = env.get("TASKS_API", DEFAULT_TASKS_API)
url = f"{api}/api/tasks/{task_id}/complete"
code, data = http_json(
url,
method="POST",
headers={"Authorization": env.get("PB_TOKEN", "")},
body={"result": result, "log": log_lines, "duration_ms": duration_ms},
)
return code == 200 and isinstance(data, dict) and data.get("ok")
def fail_task(env: Dict[str, str], task_id: str, error_msg: str, log_lines: list) -> bool:
api = env.get("TASKS_API", DEFAULT_TASKS_API)
url = f"{api}/api/tasks/{task_id}/fail"
code, data = http_json(
url,
method="POST",
headers={"Authorization": env.get("PB_TOKEN", "")},
body={"error": error_msg, "log": log_lines, "retry": True},
)
return code == 200 and isinstance(data, dict) and data.get("ok")
def lane_label(env: Dict[str, str]) -> str:
return env.get("WORKER_LANE", "deterministic")
# ── Handlers ────────────────────────────────────────────────────
def handle_bizportal_search(env: Dict[str, str], payload: Dict) -> tuple[Any, list]:
log = [f"bizportal_search payload={payload}"]
service = env.get("BIZPORTAL_SERVICE_URL", DEFAULT_BIZPORTAL_SERVICE)
url = f"{service}/search"
code, data = http_json(url, method="POST", body=payload)
log.append(f"service responded {code}")
if code != 200 or not isinstance(data, dict):
raise RuntimeError(f"bizportal service error: {code} {data}")
if not data.get("ok"):
raise RuntimeError(data.get("error") or "search failed")
log.append(f"parsed {len(data.get('results') or [])} companies")
return data, log
def handle_bizportal_reserve(env: Dict[str, str], payload: Dict) -> tuple[Any, list]:
log = [f"bizportal_reserve payload={payload}"]
service = env.get("BIZPORTAL_SERVICE_URL", DEFAULT_BIZPORTAL_SERVICE)
url = f"{service}/reserve"
code, data = http_json(url, method="POST", body=payload)
log.append(f"service responded {code}")
if code != 200 or not isinstance(data, dict):
raise RuntimeError(f"bizportal service error: {code} {data}")
if not data.get("ok"):
raise RuntimeError(data.get("error") or "reserve failed")
log.append(f"reservation status={data.get('status')}")
return data, log
def handle_pdf_generate(env: Dict[str, str], payload: Dict) -> tuple[Any, list]:
"""Shell out to the pdf-creator skill. The skill is a directory
that contains a runnable script — we invoke it with a JSON
payload via stdin and read the result file path from stdout."""
log = [f"pdf_generate payload={payload}"]
skill = env.get("PDF_CREATOR_PATH", DEFAULT_PDF_CREATOR)
if not Path(skill).exists():
raise RuntimeError(f"pdf-creator not found at {skill}")
# Look for an entry-point script
entry = None
for candidate in ("run.sh", "main.py", "pdf_creator.py", "index.js"):
p = Path(skill) / candidate
if p.exists():
entry = p
break
if entry is None:
raise RuntimeError(f"no entry-point script found in {skill}")
log.append(f"invoking {entry}")
proc = subprocess.run(
[str(entry)],
input=json.dumps(payload),
capture_output=True,
text=True,
timeout=300,
)
log.append(f"rc={proc.returncode}")
if proc.returncode != 0:
log.append(f"stderr={proc.stderr[:500]}")
raise RuntimeError(f"pdf-creator failed (rc={proc.returncode}): {proc.stderr[:200]}")
result = {"stdout": proc.stdout.strip(), "stderr": proc.stderr.strip()}
return result, log
def handle_email_send(env: Dict[str, str], payload: Dict) -> tuple[Any, list]:
log = [f"email_send payload keys={list(payload.keys())}"]
to = payload.get("to")
subject = payload.get("subject", "")
body = payload.get("body", "")
if not to or not isinstance(to, str):
raise RuntimeError("email_send: 'to' is required")
smtp_host = env.get("SMTP_HOST")
smtp_user = env.get("SMTP_USER")
smtp_pass = env.get("SMTP_PASSWORD") or env.get("SMTP_PASS")
smtp_from = env.get("SMTP_FROM", smtp_user or "noreply@example.com")
smtp_port = int(env.get("SMTP_PORT", "587"))
if not smtp_host:
raise RuntimeError("SMTP_HOST not configured in .env.local")
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = smtp_from
msg["To"] = to
log.append(f"connecting to {smtp_host}:{smtp_port} as {smtp_user}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as smtp:
smtp.starttls()
if smtp_user and smtp_pass:
smtp.login(smtp_user, smtp_pass)
smtp.send_message(msg)
log.append("sent OK")
return {"to": to, "subject": subject, "sent": True}, log
def handle_custom(env: Dict[str, str], task: Dict) -> tuple[Any, list]:
"""LLM lane — write the task to the agent's inbox directory. The
agent is expected to PATCH the task back via /complete or /fail.
We return a placeholder so the worker can mark this batch as
"dispatched" but the actual completion comes from the agent."""
inbox = Path(env.get("AGENT_INBOX", DEFAULT_AGENT_INBOX))
inbox.mkdir(parents=True, exist_ok=True)
payload = {
"task_id": task["id"],
"title": task["title"],
"description": task.get("description"),
"payload": task.get("payload"),
"result_endpoint": f"{env.get('TASKS_API', DEFAULT_TASKS_API)}/api/tasks/{task['id']}/complete",
"fail_endpoint": f"{env.get('TASKS_API', DEFAULT_TASKS_API)}/api/tasks/{task['id']}/fail",
}
out = inbox / f"{task['id']}.json"
out.write_text(json.dumps(payload, indent=2))
log_lines = [f"wrote {out}"]
# Do NOT mark complete — the LLM agent will do that. The task
# stays in 'in_progress' until the agent calls back.
raise _InboxDispatched(out, log_lines)
class _InboxDispatched(Exception):
"""Special exception signalling the LLM agent has been notified
and the worker should NOT call /complete — the agent will."""
def __init__(self, out_path: Path, log_lines: list):
self.out_path = out_path
self.log_lines = log_lines
HANDLERS = {
"bizportal_search": handle_bizportal_search,
"bizportal_reserve": handle_bizportal_reserve,
"pdf_generate": handle_pdf_generate,
"email_send": handle_email_send,
"custom": handle_custom,
}
# The custom lane only takes `custom` task type; the deterministic
# lane takes everything except `custom`.
DETERMINISTIC_TYPES = {"bizportal_search", "bizportal_reserve", "pdf_generate", "email_send"}
# ── Main loop ──────────────────────────────────────────────────
def run_one(env: Dict[str, str], lane: str, task: Dict) -> None:
"""Claim and execute a single task. Returns nothing — all state
transitions go through the API."""
task_type = task["task_type"]
title = task["title"]
if lane == "deterministic" and task_type not in DETERMINISTIC_TYPES:
log.debug(f"skip {task['id']} (type={task_type} not in deterministic lane)")
return
if lane == "custom" and task_type != "custom":
log.debug(f"skip {task['id']} (type={task_type} not in custom lane)")
return
log.info(f"claim {task_type} {task['id'][:12]}… '{title}'")
claimed = claim_task(env, task["id"])
if not claimed:
log.info(f" → already claimed by someone else")
return
handler = HANDLERS.get(task_type)
if handler is None:
fail_task(env, task["id"], f"no handler for task_type={task_type}", [f"unknown type {task_type}"])
return
t0 = time.time()
log_lines: list = []
try:
payload = claimed.get("payload") or {}
result, handler_log = handler(env, payload)
log_lines.extend(handler_log)
duration_ms = int((time.time() - t0) * 1000)
complete_task(env, task["id"], result, log_lines, duration_ms)
log.info(f" → done in {duration_ms}ms")
except _InboxDispatched as d:
# LLM agent has been notified — log and move on
log_lines.extend(d.log_lines)
log.info(f" → dispatched to agent inbox, awaiting callback")
except Exception as e:
log_lines.append(f"ERROR: {e}")
duration_ms = int((time.time() - t0) * 1000)
log.error(f" → failed after {duration_ms}ms: {e}")
fail_task(env, task["id"], str(e)[:4900], log_lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--lane", choices=["deterministic", "custom", "all"], default="all",
help="Which lane to run. 'all' = both as one process.")
parser.add_argument("--once", action="store_true", help="Run one pass and exit (for testing).")
args = parser.parse_args()
env = load_env()
env["WORKER_LANE"] = args.lane
lanes = ["deterministic", "custom"] if args.lane == "all" else [args.lane]
log.info(f"worker pid={os.getpid()} lane={lanes} poll={POLL_INTERVAL_S}s")
log.info(f"PB_URL={env.get('PB_URL', DEFAULT_PB_URL)}")
log.info(f"TASKS_API={env.get('TASKS_API', DEFAULT_TASKS_API)}")
log.info(f"BIZPORTAL_SERVICE_URL={env.get('BIZPORTAL_SERVICE_URL', DEFAULT_BIZPORTAL_SERVICE)}")
while True:
any_claimed = False
for lane in lanes:
try:
ready = list_ready(env, lane, limit=1)
except Exception as e:
log.warning(f"list_ready error: {e}")
continue
for task in ready:
any_claimed = True
try:
run_one(env, lane, task)
except Exception as e:
log.exception(f"run_one crashed: {e}")
if args.once:
break
if not any_claimed:
time.sleep(POLL_INTERVAL_S)
if __name__ == "__main__":
main()