Files
agent_management/api/swarm/router.py
T

913 lines
36 KiB
Python

"""Sub-mode runtime compatibility router for legacy /api/swarms clients."""
import json
import mimetypes
import uuid
from datetime import datetime, timedelta
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from database import (
get_db, Swarm, SwarmAgent, SwarmMessage,
SwarmStatus, SwarmAgentStatus
)
from k8s_manager import sanitize_k8s_name
from .artifact_store import (
load_azblob_artifact,
load_runtime_project_artifact,
load_runtime_project_file,
load_runtime_artifact,
runtime_artifact_archive_path,
runtime_artifact_download_path,
runtime_artifact_file_path,
runtime_artifact_manifest_path,
runtime_artifact_uri,
store_text_artifact,
)
from .models import (
SwarmCreateRequest, SwarmCreateResponse, SwarmStatusResponse,
SwarmStopRequest, SwarmStopResponse, SwarmAgentInfo, SwarmMetrics,
ApprovalDecisionRequest, SwarmPhaseInfo, SwarmPhaseAgentInfo, ArtifactEditRequest, ArtifactEditResponse
)
from .orchestrator import SwarmOrchestrator
from api.status_projection import RuntimeDisplayStatus, project_runtime_run_status
from .callback_client import CallbackDeliveryClient
swarms_router = APIRouter(prefix="/api/swarms", tags=["sub-mode-runtime-compatibility"])
def generate_swarm_id() -> str:
"""Generate unique swarm ID"""
return f"swm_{uuid.uuid4().hex[:12]}"
def generate_agent_id(role: str) -> str:
"""Generate unique agent ID"""
return f"agi_{role}_{uuid.uuid4().hex[:8]}"
def _agent_infos_for_swarm(db: Session, swarm_id: str) -> list[SwarmAgentInfo]:
"""Build response agent summaries for a swarm."""
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).all()
messages = (
db.query(SwarmMessage)
.filter(SwarmMessage.swarm_id == swarm_id)
.order_by(SwarmMessage.created_at.asc())
.all()
)
artifacts_by_agent = {}
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
for artifact in (swarm.artifacts or []) if swarm else []:
agent_instance_id = artifact.get("agent_instance_id")
if agent_instance_id:
artifacts_by_agent.setdefault(agent_instance_id, []).append(artifact.get("artifact_id"))
def metrics_for_agent(agent_id: str):
agent_messages = [message for message in messages if agent_id in {message.from_agent_id, message.to_agent_id}]
tokens = 0
tools = 0
current_action = None
first_seen = None
last_seen = None
for message in agent_messages:
metadata = message.message_metadata or {}
usage = metadata.get("model_usage") or {}
tokens += int(usage.get("total_tokens") or 0)
if message.message_type in {"task", "task_retry", "response", "error"}:
tools += 1
if message.message_type in {"task", "task_retry"} and message.content:
current_action = message.content[:200]
if first_seen is None or message.created_at < first_seen:
first_seen = message.created_at
if last_seen is None or message.created_at > last_seen:
last_seen = message.created_at
elapsed = int((last_seen - first_seen).total_seconds()) if first_seen and last_seen else 0
return tokens, tools, elapsed, current_action
return [
SwarmAgentInfo(
agent_id=agent.agent_id,
role=agent.role,
status=agent.status.value,
namespace=agent.namespace,
service_url=agent.service_url,
current_task=agent.current_task,
output=agent.output,
tokens=metrics_for_agent(agent.agent_id)[0],
tools=metrics_for_agent(agent.agent_id)[1],
elapsed_seconds=metrics_for_agent(agent.agent_id)[2],
artifact_ids=artifacts_by_agent.get(agent.agent_id, []),
current_action=metrics_for_agent(agent.agent_id)[3] or agent.current_task,
)
for agent in agents
]
def _phases_for_swarm(db: Session, swarm: Swarm) -> list[SwarmPhaseInfo]:
"""Build workflow-style phase summaries from persisted runtime artifacts and messages."""
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm.swarm_id).all()
messages = (
db.query(SwarmMessage)
.filter(SwarmMessage.swarm_id == swarm.swarm_id)
.order_by(SwarmMessage.created_at.asc())
.all()
)
artifacts = swarm.artifacts or []
artifacts_by_agent = {}
for artifact in artifacts:
agent_instance_id = artifact.get("agent_instance_id")
if agent_instance_id:
artifacts_by_agent.setdefault(agent_instance_id, []).append(artifact.get("artifact_id"))
def metrics_for_agent(agent_id: str):
agent_messages = [message for message in messages if agent_id in {message.from_agent_id, message.to_agent_id}]
tokens = 0
tools = 0
current_action = None
first_seen = None
last_seen = None
for message in agent_messages:
metadata = message.message_metadata or {}
usage = metadata.get("model_usage") or {}
tokens += int(usage.get("total_tokens") or 0)
if message.message_type in {"task", "task_retry", "response", "error"}:
tools += 1
if message.message_type in {"task", "task_retry"} and message.content:
current_action = message.content[:200]
if first_seen is None or message.created_at < first_seen:
first_seen = message.created_at
if last_seen is None or message.created_at > last_seen:
last_seen = message.created_at
elapsed = int((last_seen - first_seen).total_seconds()) if first_seen and last_seen else 0
return tokens, tools, elapsed, current_action
agent_phase_infos = []
artifact_ids = []
for agent in agents:
tokens, tools, elapsed, current_action = metrics_for_agent(agent.agent_id)
agent_artifact_ids = artifacts_by_agent.get(agent.agent_id, [])
artifact_ids.extend(agent_artifact_ids)
agent_phase_infos.append(
SwarmPhaseAgentInfo(
agent_id=agent.agent_id,
role=agent.role,
status=agent.status.value,
tokens=tokens,
tools=tools,
elapsed_seconds=elapsed,
artifact_ids=agent_artifact_ids,
current_action=current_action or agent.current_task,
error=agent.output if agent.status == SwarmAgentStatus.FAILED else None,
summary=agent.output if agent.status != SwarmAgentStatus.FAILED else None,
)
)
phase_status = "completed"
if any(agent.status == SwarmAgentStatus.FAILED for agent in agents):
phase_status = "failed"
elif any(agent.status in {SwarmAgentStatus.PENDING, SwarmAgentStatus.RUNNING} for agent in agents):
phase_status = "running"
return [
SwarmPhaseInfo(
name=swarm.phase or "development",
status=phase_status,
agents=agent_phase_infos,
artifact_ids=artifact_ids,
summary=f"Runtime phase {swarm.phase or 'development'}",
)
]
def _synthesized_artifacts_for_swarm(db: Session, swarm: Swarm) -> list[Dict[str, Any]]:
"""Return stored artifacts or a compatibility summary for old empty terminal runs."""
if swarm.artifacts:
return swarm.artifacts
if swarm.status not in {SwarmStatus.COMPLETED, SwarmStatus.FAILED, SwarmStatus.STOPPED}:
return []
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm.swarm_id).all()
agent_summaries = []
for agent in agents:
detail = f"{agent.role}:{agent.status.value}"
if agent.output:
detail += f" - {agent.output[:300]}"
agent_summaries.append(detail)
failed = swarm.status == SwarmStatus.FAILED
summary = (
swarm.error_message
if failed and swarm.error_message
else "Runtime reached a terminal state without storing a concrete artifact. "
"This compatibility artifact preserves a visible delivery record for Heicode sub-mode clients."
)
if agent_summaries:
summary += "\nAgents: " + "; ".join(agent_summaries)
if swarm.task_description:
summary = f"Task: {swarm.task_description}\n{summary}"
artifact_id = f"art_{swarm.swarm_id}_{'failure' if failed else 'summary'}"
stored = None
try:
stored = store_text_artifact(swarm.swarm_id, artifact_id, summary[:2000])
except Exception:
stored = None
return [
{
"artifact_id": artifact_id,
"artifact_type": "other" if failed else "document",
"title": "Runtime execution failed" if failed else "Runtime execution summary",
"summary": summary[:2000],
"uri": stored.uri if stored else runtime_artifact_uri(swarm.swarm_id, artifact_id),
"agent_instance_id": None,
"mime_type": stored.mime_type if stored else "text/plain",
"size_bytes": stored.size_bytes if stored else len(summary[:2000].encode("utf-8")),
"stage": swarm.phase or "development",
"checkpoint": "failed" if failed else "artifact_ready",
"metadata": {
"redacted": True,
"source": "agent-manager-sub-mode-runtime",
"runtime_deployment_id": swarm.swarm_id,
"synthesized": True,
"summary_only": True,
"artifact_layout": "single_file_content",
"primary_read_path": "content",
"agent_count": len(agents),
"content_hash": stored.content_hash if stored else None,
"download_path": runtime_artifact_download_path(swarm.swarm_id, artifact_id),
},
}
]
def _build_swarm_status_response(db: Session, swarm: Swarm) -> SwarmStatusResponse:
"""Return a standard status payload for the sub-mode runtime compatibility API."""
elapsed_seconds = int((datetime.utcnow() - swarm.created_at).total_seconds())
display_status = project_runtime_run_status(swarm.status)
return SwarmStatusResponse(
deployment_id=swarm.swarm_id,
swarm_id=swarm.swarm_id,
mode="sub_agile",
status=display_status,
phase=swarm.phase,
progress=swarm.progress,
agents=_agent_infos_for_swarm(db, swarm.swarm_id),
phases=_phases_for_swarm(db, swarm),
metrics=SwarmMetrics(
total_messages=swarm.total_messages,
tokens_used=swarm.tokens_used,
elapsed_seconds=elapsed_seconds,
),
artifacts=_synthesized_artifacts_for_swarm(db, swarm),
error_message=swarm.error_message,
created_at=swarm.created_at,
updated_at=swarm.updated_at,
)
def _stop_swarm_record(db: Session, swarm_id: str, request: SwarmStopRequest) -> SwarmStopResponse:
"""Idempotently stop a compatibility runtime-run database record."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
stopped_at = datetime.utcnow()
if swarm.status != SwarmStatus.STOPPED:
swarm.status = SwarmStatus.STOPPED
swarm.error_message = request.reason
swarm.updated_at = stopped_at
db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).update(
{"status": SwarmAgentStatus.FAILED if request.reason else SwarmAgentStatus.COMPLETED}
)
db.commit()
return SwarmStopResponse(
deployment_id=swarm_id,
swarm_id=swarm_id,
status=RuntimeDisplayStatus.STOPPED.value,
stopped_at=stopped_at,
)
async def initialize_and_execute_swarm(swarm_id: str, db_url: str):
"""Background task to initialize and execute a sub-mode runtime run."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Create new database session for background task
engine = create_engine(db_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
orchestrator = SwarmOrchestrator(swarm_id, db)
# Initialize swarm
success = await orchestrator.initialize()
if not success:
return
# Execute swarm task
await orchestrator.execute()
except Exception as e:
print(f"Error in background sub-mode runtime execution: {e}")
finally:
await orchestrator.cleanup()
db.close()
async def create_swarm(
request: SwarmCreateRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
"""Create a new sub-mode runtime run."""
swarm_id = generate_swarm_id()
project_context = request.project_context.dict() if request.project_context else {}
if request.callback:
project_context["_callback"] = request.callback.dict(exclude_none=True)
# Create swarm record
swarm = Swarm(
swarm_id=swarm_id,
task_description=request.task_description,
project_context=project_context,
orchestration_strategy=request.orchestration.strategy,
max_iterations=request.orchestration.max_iterations,
timeout_minutes=request.orchestration.timeout_minutes,
callback_url=request.callback.url if request.callback else None,
callback_method=request.callback.method if request.callback else "POST",
owner_id=request.owner_id,
status=SwarmStatus.INITIALIZING
)
db.add(swarm)
db.commit()
db.refresh(swarm)
# Create agent records (pods will be created by background task)
agent_infos = []
for agent_config in request.agents:
for i in range(agent_config.replicas):
agent_id = generate_agent_id(agent_config.role)
replica_suffix = f"-{i+1}" if agent_config.replicas > 1 else ""
namespace = sanitize_k8s_name(f"swarm-{swarm_id[:8]}-{agent_config.role}{replica_suffix}")
pod_name = sanitize_k8s_name(f"agent-{agent_id}")
swarm_agent = SwarmAgent(
agent_id=agent_id,
swarm_id=swarm_id,
role=agent_config.role,
template=agent_config.template,
model=agent_config.model,
capabilities=agent_config.capabilities,
system_prompt=agent_config.system_prompt,
namespace=namespace,
pod_name=pod_name,
service_url=f"http://{pod_name}.{namespace}.svc.cluster.local:8000",
status=SwarmAgentStatus.PENDING
)
db.add(swarm_agent)
db.commit()
db.refresh(swarm_agent)
agent_infos.append(SwarmAgentInfo(
agent_id=swarm_agent.agent_id,
role=swarm_agent.role,
status=swarm_agent.status.value,
namespace=swarm_agent.namespace,
service_url=swarm_agent.service_url
))
# Schedule background task to initialize and execute swarm
# Note: In production, this should use a proper task queue like Celery
from database import DATABASE_URL
background_tasks.add_task(initialize_and_execute_swarm, swarm_id, DATABASE_URL)
return SwarmCreateResponse(
deployment_id=swarm_id,
swarm_id=swarm_id,
status=project_runtime_run_status(swarm.status),
agents=agent_infos,
created_at=swarm.created_at,
estimated_ready_at=swarm.created_at + timedelta(minutes=2)
)
@swarms_router.post("", response_model=SwarmCreateResponse)
async def create_swarm_compat(
payload: Dict[str, Any],
request: Request,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
"""Compatibility entrypoint for Heicode sub-mode Runtime adapters."""
if payload.get("dry_run") is True:
raise HTTPException(status_code=422, detail="dry_run is not supported by Runtime create; no swarm was created")
plan = payload.get("orchestration_plan")
if not isinstance(plan, dict):
raise HTTPException(status_code=422, detail="orchestration_plan must be an object")
if not plan.get("sub_mode"):
raise HTTPException(status_code=422, detail="orchestration_plan.sub_mode is required")
if plan.get("sub_mode") not in {"agile", "waterfall"}:
raise HTTPException(status_code=422, detail="orchestration_plan.sub_mode must be agile or waterfall")
if not isinstance(plan.get("user_context"), dict) or not plan["user_context"].get("user_id"):
raise HTTPException(status_code=422, detail="orchestration_plan.user_context.user_id is required")
callback = payload.get("callback") or plan.get("callback")
if not isinstance(callback, dict) or not callback.get("url"):
raise HTTPException(status_code=422, detail="callback.url is required")
idempotency_key = request.headers.get("X-Idempotency-Key") or request.headers.get("Idempotency-Key")
if idempotency_key:
existing = None
for candidate in db.query(Swarm).order_by(Swarm.created_at.asc()).all():
context = candidate.project_context or {}
if context.get("idempotency_key") == idempotency_key:
existing = candidate
break
if existing:
return _build_swarm_status_response(db, existing)
agents = plan.get("agents") or payload.get("agents") or [
{"role": "backend", "capabilities": ["code", "test"]},
{"role": "frontend", "capabilities": ["ui", "test"]},
{"role": "reviewer", "capabilities": ["review"]},
]
budget = plan.get("budget") or {}
agile_context = plan.get("agile_context") or payload.get("agile_context") or {}
user_context = plan.get("user_context") or {}
metadata = plan.get("metadata") or payload.get("metadata") or {}
project_context = plan.get("project_context") or {}
if not isinstance(project_context, dict):
project_context = {}
project_context = {
**project_context,
"intent_id": plan.get("intent_id"),
"template_hint": plan.get("template_hint"),
"binding_scope": user_context.get("binding_scope"),
"sub_mode": plan.get("sub_mode", "agile"),
"agile_context": agile_context,
"budget": budget,
"billing_context": plan.get("billing_context") or payload.get("billing_context") or {},
"resource_grants": plan.get("resource_grants") or payload.get("resource_grants") or [],
"idempotency_key": idempotency_key,
"correlation_id": metadata.get("correlation_id") or payload.get("correlation_id"),
"manager_deployment_id": payload.get("deployment_id")
or metadata.get("manager_deployment_id")
or metadata.get("heicode_deployment_id"),
"heicode_deployment_id": payload.get("deployment_id")
or metadata.get("heicode_deployment_id")
or metadata.get("manager_deployment_id"),
}
swarm_request = SwarmCreateRequest(
task_description=plan.get("objective") or plan.get("intent_id") or "Heicode sub-mode task",
project_context=project_context,
agents=agents,
orchestration={
"strategy": "sequential" if plan.get("sub_mode", "agile") == "waterfall" else "hybrid",
"max_iterations": agile_context.get("max_iterations", 3),
"timeout_minutes": max(1, int((budget.get("max_duration_sec") or 1800) / 60)),
},
callback=callback,
owner_id=str(user_context.get("user_id") or payload.get("owner_id") or "default"),
)
return await create_swarm(swarm_request, background_tasks, db)
@swarms_router.get("/{swarm_id}", response_model=SwarmStatusResponse)
async def get_swarm_detail_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Compatibility detail endpoint for legacy Manager runtime clients."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
return _build_swarm_status_response(db, swarm)
@swarms_router.get("/{swarm_id}/status", response_model=SwarmStatusResponse)
async def get_swarm_status_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Compatibility status endpoint for legacy Manager runtime clients."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
return _build_swarm_status_response(db, swarm)
@swarms_router.post("/{swarm_id}/stop", response_model=SwarmStopResponse)
async def stop_swarm_compat(
swarm_id: str,
request: SwarmStopRequest,
db: Session = Depends(get_db)
):
"""Compatibility stop endpoint used by Heicode Manager."""
return _stop_swarm_record(db, swarm_id, request)
async def get_swarm_logs(swarm_id: str, db: Session = Depends(get_db)):
"""Get aggregated logs from all agents in a sub-mode runtime run."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).all()
logs = {
"swarm_id": swarm_id,
"agents": []
}
messages_by_agent = {}
swarm_messages = (
db.query(SwarmMessage)
.filter(SwarmMessage.swarm_id == swarm_id)
.order_by(SwarmMessage.created_at.asc())
.all()
)
for message in swarm_messages:
related_agent_id = message.from_agent_id or message.to_agent_id
if not related_agent_id:
continue
messages_by_agent.setdefault(related_agent_id, []).append(message)
for agent in agents:
agent_messages = messages_by_agent.get(agent.agent_id, [])
log_lines = [
f"status={agent.status.value}",
f"role={agent.role}",
]
if agent.current_task:
log_lines.append(f"current_task={agent.current_task}")
if agent.output:
log_lines.append(f"last_output={agent.output[:500]}")
if agent_messages:
for message in agent_messages[-5:]:
metadata = message.message_metadata or {}
request_id = metadata.get("newapi_request_id")
usage = metadata.get("model_usage") or {}
suffix_parts = []
if request_id:
suffix_parts.append(f"newapi_request_id={request_id}")
if usage.get("total_tokens"):
suffix_parts.append(f"tokens={usage['total_tokens']}")
suffix = f" ({', '.join(suffix_parts)})" if suffix_parts else ""
log_lines.append(
f"{message.created_at.isoformat()} {message.message_type}: {message.content[:300]}{suffix}"
)
else:
log_lines.append("no_runtime_messages_recorded")
logs["agents"].append({
"agent_id": agent.agent_id,
"role": agent.role,
"namespace": agent.namespace,
"pod_name": agent.pod_name,
"logs": "\n".join(log_lines),
"messages": [
{
"message_id": message.message_id,
"message_type": message.message_type,
"created_at": message.created_at,
"newapi_request_id": (message.message_metadata or {}).get("newapi_request_id"),
"model_usage": (message.message_metadata or {}).get("model_usage"),
"metadata": message.message_metadata or {},
}
for message in agent_messages[-20:]
],
})
return logs
@swarms_router.get("/{swarm_id}/logs")
async def get_swarm_logs_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Compatibility logs endpoint under /api/swarms."""
return await get_swarm_logs(swarm_id, db)
@swarms_router.get("/{swarm_id}/events")
async def get_swarm_events_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Return runtime messages as compatibility events for polling fallback."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
messages = (
db.query(SwarmMessage)
.filter(SwarmMessage.swarm_id == swarm_id)
.order_by(SwarmMessage.created_at.asc())
.all()
)
events = [
{
"event_id": message.message_id,
"event_type": f"runtime.message.{message.message_type}",
"swarm_id": swarm_id,
"agent_instance_id": message.from_agent_id or message.to_agent_id,
"occurred_at": message.created_at,
"payload": {
"from_agent_id": message.from_agent_id,
"to_agent_id": message.to_agent_id,
"message_type": message.message_type,
"summary": message.content[:300] if message.content else None,
"metadata": message.message_metadata or {},
},
}
for message in messages
]
if not any(event["event_type"] == "artifact.created" for event in events):
for artifact in _synthesized_artifacts_for_swarm(db, swarm):
events.append(
{
"event_id": artifact.get("artifact_id"),
"event_type": "artifact.created",
"swarm_id": swarm_id,
"agent_instance_id": artifact.get("agent_instance_id"),
"occurred_at": swarm.completed_at or swarm.updated_at,
"payload": artifact,
}
)
return {"success": True, "swarm_id": swarm_id, "events": events}
@swarms_router.get("/{swarm_id}/metrics")
async def get_swarm_metrics_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Return basic runtime metrics for compatibility polling fallback."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
elapsed_seconds = int((datetime.utcnow() - swarm.created_at).total_seconds())
return {
"success": True,
"swarm_id": swarm_id,
"status": project_runtime_run_status(swarm.status),
"stage": swarm.phase,
"checkpoint": "completed" if swarm.status == SwarmStatus.COMPLETED else "agent_running",
"metrics": {
"tokens_used": swarm.tokens_used,
"duration_ms": elapsed_seconds * 1000,
"artifact_count": len(_synthesized_artifacts_for_swarm(db, swarm)),
"total_messages": swarm.total_messages,
},
}
@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/content")
async def get_swarm_artifact_content(
swarm_id: str,
artifact_id: str,
db: Session = Depends(get_db),
):
"""Return full persisted content for a sub-mode runtime artifact URI."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
artifacts_by_id = {
artifact.get("artifact_id"): artifact
for artifact in _synthesized_artifacts_for_swarm(db, swarm)
}
artifact = artifacts_by_id.get(artifact_id)
if not artifact:
raise HTTPException(status_code=404, detail="Artifact not found")
project_artifact = load_runtime_project_artifact(swarm_id, artifact_id)
if project_artifact:
return FileResponse(
path=project_artifact.archive_path,
media_type="application/zip",
filename=project_artifact.archive_path.name,
)
stored = load_runtime_artifact(swarm_id, artifact_id)
if stored:
return FileResponse(
path=stored.path,
media_type=stored.mime_type,
filename=stored.path.name,
)
blob_content = load_azblob_artifact(artifact.get("uri"))
if blob_content:
content, mime_type, filename = blob_content
return Response(
content=content,
media_type=mime_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
raise HTTPException(status_code=404, detail="Artifact content not found")
@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/manifest")
async def get_swarm_artifact_manifest(
swarm_id: str,
artifact_id: str,
db: Session = Depends(get_db),
):
"""Return manifest JSON for a project-folder artifact."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
project_artifact = load_runtime_project_artifact(swarm_id, artifact_id)
if not project_artifact:
raise HTTPException(status_code=404, detail="Project artifact manifest not found")
return FileResponse(
path=project_artifact.manifest_path,
media_type="application/json",
filename=project_artifact.manifest_path.name,
)
@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/archive.zip")
async def get_swarm_artifact_archive(
swarm_id: str,
artifact_id: str,
db: Session = Depends(get_db),
):
"""Return zip archive for a project-folder artifact."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
project_artifact = load_runtime_project_artifact(swarm_id, artifact_id)
if not project_artifact:
raise HTTPException(status_code=404, detail="Project artifact archive not found")
return FileResponse(
path=project_artifact.archive_path,
media_type="application/zip",
filename=project_artifact.archive_path.name,
)
@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/files/{file_path:path}")
async def get_swarm_artifact_file(
swarm_id: str,
artifact_id: str,
file_path: str,
db: Session = Depends(get_db),
):
"""Return a single file from a project-folder artifact."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
resolved_file = load_runtime_project_file(swarm_id, artifact_id, file_path)
if not resolved_file:
raise HTTPException(status_code=404, detail="Project artifact file not found")
return FileResponse(
path=resolved_file,
media_type=mimetypes.guess_type(str(resolved_file))[0] or "text/plain",
filename=resolved_file.name,
)
async def _emit_artifact_edit_event(swarm: Swarm, request: ArtifactEditRequest, event_type: str) -> None:
"""Emit a best-effort callback for project revision updates."""
project_context = swarm.project_context or {}
callback_config = project_context.get("_callback")
if not callback_config:
return
callback = CallbackDeliveryClient(callback_config)
if not callback.enabled:
return
payload = {
"artifact_id": request.artifact_id,
"project_revision": request.project_revision,
"base_project_revision": request.base_project_revision,
"base_content_hash": request.base_content_hash,
"manifest_uri": request.manifest_uri,
"archive_uri": request.archive_uri,
"source": request.source,
"runtime_deployment_id": swarm.swarm_id,
}
await callback.emit(
event_type,
project_context.get("manager_deployment_id")
or project_context.get("heicode_deployment_id")
or swarm.swarm_id,
swarm_id=swarm.swarm_id,
correlation_id=project_context.get("correlation_id"),
payload=payload,
)
def apply_runtime_artifact_edit(db: Session, swarm_id: str, request: ArtifactEditRequest) -> ArtifactEditResponse:
"""Persist an accepted project revision forwarded by Manager."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
artifacts = list(swarm.artifacts or [])
artifact = next((artifact for artifact in artifacts if artifact.get("artifact_id") == request.artifact_id), None)
if not artifact:
raise HTTPException(status_code=404, detail="ARTIFACT_REVISION_NOT_FOUND")
metadata = artifact.get("metadata") or {}
if metadata.get("artifact_layout") != "project_folder":
raise HTTPException(status_code=422, detail="ARTIFACT_EDIT_UNSUPPORTED_FOR_LAYOUT")
current_revision = int(metadata.get("project_revision") or 1)
if request.base_project_revision is not None and request.base_project_revision != current_revision:
raise HTTPException(status_code=409, detail="ARTIFACT_REVISION_CONFLICT")
metadata.update(
{
"project_revision": request.project_revision,
"base_project_revision": request.base_project_revision,
"base_content_hash": request.base_content_hash,
"manifest_uri": request.manifest_uri,
"archive_uri": request.archive_uri,
"content_hash": request.base_content_hash or metadata.get("content_hash"),
"revision_source": request.source,
"delivery_ref": {
"kind": "runtime_artifact",
"artifact_id": request.artifact_id,
"project_revision": request.project_revision,
},
}
)
artifact["metadata"] = metadata
swarm.artifacts = artifacts
swarm.updated_at = datetime.utcnow()
db.commit()
message = SwarmMessage(
message_id=f"rev_{uuid.uuid4().hex[:12]}",
swarm_id=swarm_id,
from_agent_id=None,
to_agent_id=None,
message_type="artifact_edit",
content=f"artifact {request.artifact_id} revision {request.project_revision}",
message_metadata=request.model_dump(),
)
db.add(message)
db.commit()
return ArtifactEditResponse(
deployment_id=swarm_id,
artifact_id=request.artifact_id,
project_revision=request.project_revision,
status="accepted",
)
@swarms_router.post("/{swarm_id}/artifact-edits", response_model=ArtifactEditResponse)
async def receive_swarm_artifact_edit(
swarm_id: str,
request: ArtifactEditRequest,
db: Session = Depends(get_db),
):
"""Accept an accepted project revision forwarded by Manager."""
response = apply_runtime_artifact_edit(db, swarm_id, request)
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if swarm:
await _emit_artifact_edit_event(swarm, request, "artifact.local_edit_applied")
return response
@swarms_router.post("/{swarm_id}/approvals/{approval_id}")
async def receive_swarm_approval_decision(
swarm_id: str,
approval_id: str,
request: ApprovalDecisionRequest,
db: Session = Depends(get_db)
):
"""Accept Manager approval decisions for paused high-risk runtime actions."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
if request.approval_id != approval_id:
raise HTTPException(status_code=422, detail="approval_id path/body mismatch")
if request.decision not in {"approved", "rejected"}:
raise HTTPException(status_code=422, detail="decision must be approved or rejected")
message = SwarmMessage(
message_id=f"appr_{uuid.uuid4().hex[:12]}",
swarm_id=swarm_id,
from_agent_id=None,
to_agent_id=None,
message_type="approval_decision",
content=request.decision,
message_metadata=request.model_dump(exclude_none=True),
)
db.add(message)
db.commit()
return {
"success": True,
"swarm_id": swarm_id,
"approval_id": approval_id,
"decision": request.decision,
"status": "accepted",
}