661 lines
21 KiB
Python
661 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Create a real swarm task, wait for start and half-progress, and render a poster page."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlencode
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
OUTPUT_DIR = ROOT / "artifacts" / "swarm-posters"
|
|
|
|
|
|
@dataclass
|
|
class ApiConfig:
|
|
base_url: str
|
|
token: str
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Create a swarm task, wait for real progress milestones, and build a poster.",
|
|
)
|
|
parser.add_argument(
|
|
"--base-url",
|
|
default=os.getenv("SWARM_BASE_URL", "http://52.139.240.116:8000"),
|
|
help="Base URL of the running swarm runtime.",
|
|
)
|
|
parser.add_argument(
|
|
"--runtime-token",
|
|
default=os.getenv("AGNET_RUNTIME_SERVICE_TOKEN"),
|
|
help="Runtime bearer token. If omitted, read from the Kubernetes secret.",
|
|
)
|
|
parser.add_argument(
|
|
"--budget-seconds",
|
|
type=int,
|
|
default=40,
|
|
help="Task budget duration used to compute the half-progress milestone.",
|
|
)
|
|
parser.add_argument(
|
|
"--poll-interval",
|
|
type=float,
|
|
default=3.0,
|
|
help="Polling interval in seconds.",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout-seconds",
|
|
type=int,
|
|
default=240,
|
|
help="Maximum time to wait for the half-progress milestone.",
|
|
)
|
|
parser.add_argument(
|
|
"--objective",
|
|
default=(
|
|
"Build a blog system MVP in the configured workspace repository. "
|
|
"Deliver a runnable implementation with a simple backend and frontend for posts: "
|
|
"list posts, view a post, create, edit, and delete. Keep setup minimal and include short run instructions."
|
|
),
|
|
help="High-level objective for the swarm.",
|
|
)
|
|
parser.add_argument(
|
|
"--task-description",
|
|
default=(
|
|
"Create a runnable blog system MVP in the target repository with post list, "
|
|
"post detail, and create/edit/delete flows. Keep the implementation pragmatic and "
|
|
"coherent with the existing repo. Commit and push the result branch when changes are ready."
|
|
),
|
|
help="Concrete task description sent to the agent.",
|
|
)
|
|
parser.add_argument(
|
|
"--poster-title",
|
|
default="Swarm Blog MVP Live Demo",
|
|
help="Poster title rendered into the HTML.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def iso_now() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
|
|
def to_human_time(timestamp: Optional[float]) -> str:
|
|
if not timestamp:
|
|
return "-"
|
|
return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
|
|
|
|
def read_runtime_token(explicit: Optional[str]) -> str:
|
|
if explicit:
|
|
return explicit
|
|
|
|
cmd = [
|
|
"kubectl",
|
|
"get",
|
|
"secret",
|
|
"-n",
|
|
"swarm-system",
|
|
"agnet-runtime-secrets",
|
|
"-o",
|
|
"jsonpath={.data.runtime-service-token}",
|
|
]
|
|
encoded = subprocess.check_output(cmd, text=True).strip()
|
|
if not encoded:
|
|
raise RuntimeError("Runtime token was not provided and could not be read from Kubernetes.")
|
|
return base64.b64decode(encoded).decode("utf-8")
|
|
|
|
|
|
def api_request(
|
|
config: ApiConfig,
|
|
path: str,
|
|
method: str = "GET",
|
|
body: Optional[dict[str, Any]] = None,
|
|
headers: Optional[dict[str, str]] = None,
|
|
query: Optional[dict[str, Any]] = None,
|
|
) -> dict[str, Any]:
|
|
query_string = f"?{urlencode(query)}" if query else ""
|
|
url = f"{config.base_url.rstrip('/')}{path}{query_string}"
|
|
request_headers = {
|
|
"Authorization": f"Bearer {config.token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
if headers:
|
|
request_headers.update(headers)
|
|
|
|
payload = None
|
|
if body is not None:
|
|
payload = json.dumps(body).encode("utf-8")
|
|
|
|
req = Request(url, data=payload, headers=request_headers, method=method)
|
|
try:
|
|
with urlopen(req, timeout=30) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except HTTPError as exc:
|
|
raw = exc.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"API request failed with {exc.code}: {raw}") from exc
|
|
except URLError as exc:
|
|
raise RuntimeError(f"API request failed: {exc}") from exc
|
|
|
|
|
|
def create_swarm(config: ApiConfig, args: argparse.Namespace) -> dict[str, Any]:
|
|
request_id = iso_now()
|
|
correlation_id = f"corr_poster_demo_{request_id}"
|
|
idempotency_key = f"idem_poster_demo_{request_id}"
|
|
manager_deployment_id = f"dep_poster_demo_{request_id}"
|
|
|
|
agents = [
|
|
{
|
|
"task_id": f"poster-blog-{request_id.lower()}",
|
|
"role": "fullstack",
|
|
"title": "Implement blog system MVP",
|
|
"description": args.task_description,
|
|
"depends_on": [],
|
|
}
|
|
]
|
|
|
|
body = {
|
|
"orchestration_plan": {
|
|
"objective": args.objective,
|
|
"sub_mode": "code",
|
|
"risk_level": "low",
|
|
"budget": {
|
|
"duration_seconds": args.budget_seconds,
|
|
"token_limit": 120000,
|
|
},
|
|
"agents": agents,
|
|
},
|
|
"agents": agents,
|
|
"callback": {
|
|
"url": f"{config.base_url.rstrip('/')}/health",
|
|
"subscribed_events": [
|
|
"deployment.status_changed",
|
|
"task.created",
|
|
"task.running",
|
|
"task.completed",
|
|
"task.failed",
|
|
"timeline.updated",
|
|
"artifact.created",
|
|
],
|
|
},
|
|
"metadata": {
|
|
"manager_deployment_id": manager_deployment_id,
|
|
"correlation_id": correlation_id,
|
|
},
|
|
}
|
|
response = api_request(
|
|
config,
|
|
"/api/swarms",
|
|
method="POST",
|
|
body=body,
|
|
headers={
|
|
"X-Correlation-Id": correlation_id,
|
|
"X-Idempotency-Key": idempotency_key,
|
|
},
|
|
)
|
|
if not response.get("success"):
|
|
raise RuntimeError(f"Swarm creation failed: {json.dumps(response, ensure_ascii=False)}")
|
|
return response["data"]
|
|
|
|
|
|
def fetch_tasks(config: ApiConfig, swarm_id: str) -> list[dict[str, Any]]:
|
|
response = api_request(config, f"/api/swarms/{swarm_id}/tasks")
|
|
return response["data"]["tasks"]
|
|
|
|
|
|
def fetch_logs(config: ApiConfig, swarm_id: str, limit: int = 100) -> list[dict[str, Any]]:
|
|
response = api_request(
|
|
config,
|
|
f"/api/swarms/{swarm_id}/logs",
|
|
query={"limit": limit},
|
|
)
|
|
return response["data"]["events"]
|
|
|
|
|
|
def fetch_metrics(config: ApiConfig, swarm_id: str) -> dict[str, Any]:
|
|
response = api_request(config, f"/api/swarms/{swarm_id}/metrics")
|
|
return response["data"]
|
|
|
|
|
|
def fetch_agents(base_url: str) -> list[dict[str, Any]]:
|
|
req = Request(f"{base_url.rstrip('/')}/agents")
|
|
with urlopen(req, timeout=15) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))["agents"]
|
|
|
|
|
|
def manual_assign(base_url: str, task_id: str, agent_id: str) -> bool:
|
|
payload = json.dumps({"task_id": task_id, "agent_id": agent_id}).encode("utf-8")
|
|
req = Request(
|
|
f"{base_url.rstrip('/')}/tasks/assign",
|
|
data=payload,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urlopen(req, timeout=15) as resp:
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
return bool(data.get("task_id") or data.get("status"))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def find_event(events: list[dict[str, Any]], event_type: str) -> Optional[dict[str, Any]]:
|
|
for event in events:
|
|
if event.get("event_type") == event_type:
|
|
return event
|
|
return None
|
|
|
|
|
|
def last_event(events: list[dict[str, Any]], event_type: str) -> Optional[dict[str, Any]]:
|
|
for event in reversed(events):
|
|
if event.get("event_type") == event_type:
|
|
return event
|
|
return None
|
|
|
|
|
|
def render_html(
|
|
poster_path: Path,
|
|
args: argparse.Namespace,
|
|
swarm_data: dict[str, Any],
|
|
task: dict[str, Any],
|
|
metrics: dict[str, Any],
|
|
started_at: float,
|
|
halfway_at: float,
|
|
halfway_observed_at: float,
|
|
latest_events: list[dict[str, Any]],
|
|
) -> None:
|
|
latest_status = task.get("status", "unknown")
|
|
latest_heartbeat = last_event(latest_events, "task.heartbeat")
|
|
heartbeat_time = latest_heartbeat.get("occurred_at") if latest_heartbeat else "-"
|
|
runtime_ratio = metrics.get("budget", {}).get("duration_ratio")
|
|
ratio_display = f"{runtime_ratio * 100:.0f}%" if isinstance(runtime_ratio, (int, float)) else "50%+"
|
|
|
|
html = f"""<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>{args.poster_title}</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #07111f;
|
|
--panel: rgba(10, 25, 47, 0.84);
|
|
--panel-strong: rgba(12, 32, 60, 0.95);
|
|
--line: rgba(125, 211, 252, 0.25);
|
|
--cyan: #7dd3fc;
|
|
--teal: #5eead4;
|
|
--lime: #bef264;
|
|
--text: #e6f0ff;
|
|
--muted: #9bb1c8;
|
|
--warn: #fbbf24;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
font-family: "Avenir Next", "PingFang SC", "Helvetica Neue", sans-serif;
|
|
color: var(--text);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(45, 212, 191, 0.18), transparent 32%),
|
|
radial-gradient(circle at top right, rgba(125, 211, 252, 0.24), transparent 28%),
|
|
linear-gradient(135deg, #050c16 0%, #07111f 42%, #0d1d35 100%);
|
|
min-height: 100vh;
|
|
}}
|
|
.canvas {{
|
|
width: 1600px;
|
|
min-height: 900px;
|
|
margin: 0 auto;
|
|
padding: 56px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}}
|
|
.grid {{
|
|
position: absolute;
|
|
inset: 0;
|
|
background-image:
|
|
linear-gradient(rgba(125, 211, 252, 0.05) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(125, 211, 252, 0.05) 1px, transparent 1px);
|
|
background-size: 56px 56px;
|
|
mask-image: linear-gradient(to bottom, rgba(0,0,0,.75), transparent);
|
|
pointer-events: none;
|
|
}}
|
|
.hero {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 28px;
|
|
align-items: flex-start;
|
|
margin-bottom: 28px;
|
|
}}
|
|
.hero h1 {{
|
|
margin: 0 0 12px 0;
|
|
font-size: 68px;
|
|
line-height: 0.95;
|
|
letter-spacing: -2px;
|
|
}}
|
|
.hero p {{
|
|
margin: 0;
|
|
max-width: 800px;
|
|
color: var(--muted);
|
|
font-size: 24px;
|
|
line-height: 1.5;
|
|
}}
|
|
.badge {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(6, 19, 36, 0.72);
|
|
border-radius: 999px;
|
|
padding: 12px 18px;
|
|
color: var(--cyan);
|
|
font-size: 16px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}}
|
|
.layout {{
|
|
display: grid;
|
|
grid-template-columns: 1.25fr 0.75fr;
|
|
gap: 28px;
|
|
}}
|
|
.panel {{
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 28px;
|
|
padding: 28px;
|
|
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.32);
|
|
backdrop-filter: blur(16px);
|
|
}}
|
|
.panel strong {{
|
|
display: block;
|
|
font-size: 16px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--cyan);
|
|
margin-bottom: 14px;
|
|
}}
|
|
.cards {{
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 18px;
|
|
margin-bottom: 28px;
|
|
}}
|
|
.metric {{
|
|
background: var(--panel-strong);
|
|
border: 1px solid rgba(190, 242, 100, 0.16);
|
|
border-radius: 22px;
|
|
padding: 20px;
|
|
}}
|
|
.metric .label {{
|
|
color: var(--muted);
|
|
font-size: 15px;
|
|
margin-bottom: 8px;
|
|
}}
|
|
.metric .value {{
|
|
font-size: 34px;
|
|
font-weight: 700;
|
|
letter-spacing: -1px;
|
|
}}
|
|
.timeline {{
|
|
display: grid;
|
|
gap: 16px;
|
|
margin-top: 8px;
|
|
}}
|
|
.step {{
|
|
position: relative;
|
|
padding: 18px 18px 18px 68px;
|
|
border-radius: 22px;
|
|
background: rgba(7, 18, 33, 0.9);
|
|
border: 1px solid rgba(125, 211, 252, 0.12);
|
|
}}
|
|
.step::before {{
|
|
content: "";
|
|
position: absolute;
|
|
left: 28px;
|
|
top: 26px;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, var(--teal), var(--cyan));
|
|
box-shadow: 0 0 0 8px rgba(94, 234, 212, 0.12);
|
|
}}
|
|
.step h3 {{
|
|
margin: 0 0 8px 0;
|
|
font-size: 24px;
|
|
}}
|
|
.step p {{
|
|
margin: 0;
|
|
color: var(--muted);
|
|
font-size: 16px;
|
|
line-height: 1.5;
|
|
}}
|
|
.code {{
|
|
font-family: "SF Mono", "JetBrains Mono", monospace;
|
|
color: var(--lime);
|
|
word-break: break-all;
|
|
}}
|
|
.sidebar {{
|
|
display: grid;
|
|
gap: 18px;
|
|
}}
|
|
.status {{
|
|
border-radius: 24px;
|
|
padding: 22px;
|
|
background: linear-gradient(160deg, rgba(94, 234, 212, 0.14), rgba(125, 211, 252, 0.08));
|
|
border: 1px solid rgba(94, 234, 212, 0.25);
|
|
}}
|
|
.status .headline {{
|
|
font-size: 18px;
|
|
color: var(--muted);
|
|
margin-bottom: 8px;
|
|
}}
|
|
.status .value {{
|
|
font-size: 44px;
|
|
font-weight: 700;
|
|
letter-spacing: -1px;
|
|
margin-bottom: 10px;
|
|
}}
|
|
.status .sub {{
|
|
color: var(--muted);
|
|
font-size: 16px;
|
|
line-height: 1.5;
|
|
}}
|
|
.meta-row {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 10px 0;
|
|
border-bottom: 1px solid rgba(125, 211, 252, 0.08);
|
|
font-size: 15px;
|
|
}}
|
|
.meta-row:last-child {{ border-bottom: none; }}
|
|
.meta-label {{ color: var(--muted); }}
|
|
.meta-value {{ text-align: right; max-width: 56%; }}
|
|
.footer {{
|
|
margin-top: 22px;
|
|
color: var(--warn);
|
|
font-size: 15px;
|
|
line-height: 1.5;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="canvas">
|
|
<div class="grid"></div>
|
|
<div class="hero">
|
|
<div>
|
|
<div class="badge">Live Swarm Poster</div>
|
|
<h1>{args.poster_title}</h1>
|
|
<p>真实蜂群任务已成功创建,并已记录到“任务开始”和“进行过半”两段里程碑。下面的内容全部来自当前运行中的 swarm 状态,而不是手工拼接。</p>
|
|
</div>
|
|
<div class="panel" style="min-width: 360px;">
|
|
<strong>Demo Goal</strong>
|
|
<p style="font-size: 18px; color: var(--text); line-height: 1.55;">在目标仓库里开发一个博客系统 MVP,包括文章列表、详情和基础增删改能力。</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<div class="panel">
|
|
<div class="cards">
|
|
<div class="metric">
|
|
<div class="label">Swarm</div>
|
|
<div class="value">{swarm_data["swarm_id"]}</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">Task Status</div>
|
|
<div class="value">{latest_status}</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="label">Budget Progress</div>
|
|
<div class="value">{ratio_display}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<strong>Timeline</strong>
|
|
<div class="timeline">
|
|
<div class="step">
|
|
<h3>任务开始</h3>
|
|
<p>任务已被 agent 领取并进入运行态。开始时间:<span class="code">{to_human_time(started_at)}</span></p>
|
|
</div>
|
|
<div class="step">
|
|
<h3>进行到一半</h3>
|
|
<p>脚本按 budget 的 50% 自动确认里程碑。预算过半时间:<span class="code">{to_human_time(halfway_at)}</span>,实际记录时间:<span class="code">{to_human_time(halfway_observed_at)}</span></p>
|
|
</div>
|
|
<div class="step">
|
|
<h3>当前执行中</h3>
|
|
<p>任务仍处于 <span class="code">{latest_status}</span>,最近心跳:<span class="code">{heartbeat_time}</span></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidebar">
|
|
<div class="status">
|
|
<div class="headline">Poster Snapshot</div>
|
|
<div class="value">Mid-Run</div>
|
|
<div class="sub">这张海报展示的是蜂群任务已经真正启动,并且已经跑到预算半程时的现场状态。</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<strong>Swarm Meta</strong>
|
|
<div class="meta-row"><span class="meta-label">Deployment</span><span class="meta-value code">{swarm_data["deployment_id"]}</span></div>
|
|
<div class="meta-row"><span class="meta-label">Task</span><span class="meta-value code">{task["task_id"]}</span></div>
|
|
<div class="meta-row"><span class="meta-label">Agent</span><span class="meta-value code">{task.get("assigned_agent_id") or "-"}</span></div>
|
|
<div class="meta-row"><span class="meta-label">Attempts</span><span class="meta-value">{task.get("attempt", 0)}</span></div>
|
|
<div class="meta-row"><span class="meta-label">Budget</span><span class="meta-value">{args.budget_seconds}s</span></div>
|
|
<div class="meta-row"><span class="meta-label">Output File</span><span class="meta-value code">{poster_path.name}</span></div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<strong>Latest Events</strong>
|
|
<div class="meta-row"><span class="meta-label">1</span><span class="meta-value code">{latest_events[-3]["event_type"] if len(latest_events) >= 3 else "-"}</span></div>
|
|
<div class="meta-row"><span class="meta-label">2</span><span class="meta-value code">{latest_events[-2]["event_type"] if len(latest_events) >= 2 else "-"}</span></div>
|
|
<div class="meta-row"><span class="meta-label">3</span><span class="meta-value code">{latest_events[-1]["event_type"] if latest_events else "-"}</span></div>
|
|
<div class="footer">注:如果后续继续执行,日志与状态还会变化;这张图锁定的是“开始后已过半”的那一刻。</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
poster_path.write_text(html, encoding="utf-8")
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
token = read_runtime_token(args.runtime_token)
|
|
config = ApiConfig(base_url=args.base_url, token=token)
|
|
|
|
swarm_data = create_swarm(config, args)
|
|
swarm_id = swarm_data["swarm_id"]
|
|
deadline = time.time() + args.timeout_seconds
|
|
|
|
started_at = None
|
|
task_snapshot = None
|
|
latest_events: list[dict[str, Any]] = []
|
|
|
|
while time.time() < deadline:
|
|
tasks = fetch_tasks(config, swarm_id)
|
|
latest_events = fetch_logs(config, swarm_id, limit=100)
|
|
if tasks:
|
|
task_snapshot = tasks[0]
|
|
if task_snapshot.get("status") == "pending" and not task_snapshot.get("assigned_agent_id"):
|
|
for agent in fetch_agents(config.base_url):
|
|
if agent.get("status") == "idle":
|
|
if manual_assign(config.base_url, task_snapshot["task_id"], agent["agent_id"]):
|
|
break
|
|
if task_snapshot.get("started_at"):
|
|
started_at = float(task_snapshot["started_at"])
|
|
break
|
|
running_event = find_event(latest_events, "task.running")
|
|
if running_event:
|
|
started_at = datetime.fromisoformat(
|
|
running_event["occurred_at"].replace("Z", "+00:00")
|
|
).timestamp()
|
|
break
|
|
time.sleep(args.poll_interval)
|
|
|
|
if started_at is None or task_snapshot is None:
|
|
raise RuntimeError("Task did not reach a running state before timeout.")
|
|
|
|
halfway_at = started_at + args.budget_seconds / 2
|
|
while time.time() < deadline:
|
|
tasks = fetch_tasks(config, swarm_id)
|
|
latest_events = fetch_logs(config, swarm_id, limit=100)
|
|
task_snapshot = tasks[0]
|
|
if time.time() >= halfway_at:
|
|
break
|
|
if task_snapshot.get("status") in {"completed", "failed"}:
|
|
break
|
|
time.sleep(args.poll_interval)
|
|
|
|
metrics = fetch_metrics(config, swarm_id)
|
|
halfway_observed_at = time.time()
|
|
run_id = swarm_id.replace("swarm-", "")
|
|
poster_path = OUTPUT_DIR / f"swarm-poster-{run_id}.html"
|
|
summary_path = OUTPUT_DIR / f"swarm-poster-{run_id}.json"
|
|
|
|
render_html(
|
|
poster_path=poster_path,
|
|
args=args,
|
|
swarm_data=swarm_data,
|
|
task=task_snapshot,
|
|
metrics=metrics,
|
|
started_at=started_at,
|
|
halfway_at=halfway_at,
|
|
halfway_observed_at=halfway_observed_at,
|
|
latest_events=latest_events,
|
|
)
|
|
|
|
summary = {
|
|
"swarm": swarm_data,
|
|
"task": task_snapshot,
|
|
"metrics": metrics,
|
|
"started_at": started_at,
|
|
"halfway_at": halfway_at,
|
|
"halfway_observed_at": halfway_observed_at,
|
|
"poster_html": str(poster_path),
|
|
}
|
|
summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except KeyboardInterrupt:
|
|
print("Interrupted.", file=sys.stderr)
|
|
raise SystemExit(130)
|