Expose richer sub-mode runtime workflow facts
This commit is contained in:
+18
-1
@@ -341,6 +341,22 @@ async def get_runtime_event_schema():
|
||||
"category": "artifact",
|
||||
"required_payload_fields": ["artifact_id", "artifact_type", "title"],
|
||||
},
|
||||
"artifact.local_edit_applied": {
|
||||
"category": "artifact",
|
||||
"required_payload_fields": ["artifact_id", "project_revision", "manifest_uri", "archive_uri"],
|
||||
},
|
||||
"artifact.local_edit_reviewed": {
|
||||
"category": "artifact",
|
||||
"required_payload_fields": ["artifact_id", "project_revision"],
|
||||
},
|
||||
"artifact.local_edit_rejected": {
|
||||
"category": "artifact",
|
||||
"required_payload_fields": ["artifact_id", "project_revision"],
|
||||
},
|
||||
"artifact.local_edit_conflict": {
|
||||
"category": "artifact",
|
||||
"required_payload_fields": ["artifact_id", "project_revision", "base_project_revision"],
|
||||
},
|
||||
"approval.requested": {
|
||||
"category": "approval",
|
||||
"required_payload_fields": ["approval_id", "operation", "risk_level", "reason"],
|
||||
@@ -399,7 +415,7 @@ async def get_runtime_event_schema():
|
||||
"body_required_fields": ["event_id", "event_type", "deployment_id", "occurred_at", "payload"],
|
||||
"event_types": event_types,
|
||||
"stages": ["planning", "design", "development", "testing", "fixing", "deployment", "review", "done", "failed"],
|
||||
"artifact_types": ["code_patch", "document", "test_report", "deployment_manifest", "log_bundle", "other"],
|
||||
"artifact_types": ["project_folder", "code_patch", "document", "test_report", "deployment_manifest", "log_bundle", "other"],
|
||||
}
|
||||
|
||||
|
||||
@@ -445,6 +461,7 @@ async def list_deployment_artifacts(
|
||||
"stage": artifact.get("stage"),
|
||||
"checkpoint": artifact.get("checkpoint"),
|
||||
"metadata": artifact.get("metadata") or {},
|
||||
"source_agent_role": (artifact.get("metadata") or {}).get("source_agent_role") or (artifact.get("metadata") or {}).get("agent_role"),
|
||||
"created_at": event.occurred_at,
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from api.agnet.models import (
|
||||
ListDeploymentsResponse, GetDeploymentResponse, StopDeploymentRequest, StopDeploymentResponse,
|
||||
DeploymentSummary, BudgetSummary, PaginationInfo,
|
||||
GetLogsResponse, LogEntry, GetEventsResponse, EventEntry, GetMetricsResponse,
|
||||
AgentMetrics, ResourceMetrics
|
||||
AgentMetrics, ResourceMetrics, ArtifactEditRequest, ArtifactEditResponse
|
||||
)
|
||||
from api.agnet.auth import verify_service_token, extract_headers
|
||||
from api.agnet.validators import validate_no_sensitive_fields, validate_vault_references
|
||||
@@ -22,6 +22,7 @@ from api.agnet.idempotency import idempotency_cache
|
||||
from api.agnet.k8s_manager import k8s_manager
|
||||
from api.agnet.vault_client import vault_client
|
||||
from api.swarm.callback_client import CallbackDeliveryClient
|
||||
from api.swarm.router import apply_runtime_artifact_edit, _phases_for_swarm, _emit_artifact_edit_event
|
||||
from api.status_projection import RuntimeDisplayStatus, project_deployment_status, project_runtime_run_status
|
||||
from config.error_codes import ErrorCode
|
||||
from config.settings import settings
|
||||
@@ -53,6 +54,26 @@ def _project_agent_instance_status(deployment_status: str, agent_status: str) ->
|
||||
return agent_status
|
||||
|
||||
|
||||
def _runtime_mode_from_orchestration_plan(orchestration_plan: str | dict | None) -> str:
|
||||
"""Best-effort projection of runtime mode for list/detail surfaces."""
|
||||
plan = orchestration_plan
|
||||
if isinstance(orchestration_plan, str):
|
||||
try:
|
||||
plan = json.loads(orchestration_plan)
|
||||
except Exception:
|
||||
plan = {}
|
||||
if not isinstance(plan, dict):
|
||||
plan = {}
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
|
||||
runtime_mode = metadata.get("runtime_mode") or plan.get("runtime_mode")
|
||||
if runtime_mode == "swarm":
|
||||
return "swarm"
|
||||
sub_mode = plan.get("sub_mode")
|
||||
if sub_mode in {"agile", "waterfall"}:
|
||||
return "sub_agile"
|
||||
return "sub_agile"
|
||||
|
||||
|
||||
def generate_deployment_id() -> str:
|
||||
"""Generate unique deployment ID."""
|
||||
return f"dep_{uuid.uuid4().hex[:12]}"
|
||||
@@ -294,11 +315,17 @@ def build_deployment_response_from_swarm(db: Session, swarm: Swarm) -> GetDeploy
|
||||
budget = context.get("budget") or {}
|
||||
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm.swarm_id).all()
|
||||
display_status = project_runtime_run_status(swarm.status)
|
||||
phases = [phase.model_dump() for phase in _phases_for_swarm(db, swarm)]
|
||||
agent_phase_map = {}
|
||||
for phase in phases:
|
||||
for phase_agent in phase.get("agents", []):
|
||||
agent_phase_map[phase_agent.get("agent_id")] = phase_agent
|
||||
return GetDeploymentResponse(
|
||||
deployment_id=swarm.swarm_id,
|
||||
user_id=swarm.owner_id,
|
||||
binding_scope=context.get("binding_scope") or context.get("intent_id") or swarm.swarm_id,
|
||||
status=display_status,
|
||||
mode="sub_agile",
|
||||
phase=swarm.phase,
|
||||
orchestration_plan=json.dumps(
|
||||
{
|
||||
@@ -327,9 +354,15 @@ def build_deployment_response_from_swarm(db: Session, swarm: Swarm) -> GetDeploy
|
||||
role=agent.role,
|
||||
status=_project_agent_instance_status(display_status, agent.status.value),
|
||||
phase=swarm.phase,
|
||||
tokens=(agent_phase_map.get(agent.agent_id) or {}).get("tokens", 0),
|
||||
tools=(agent_phase_map.get(agent.agent_id) or {}).get("tools", 0),
|
||||
elapsed_seconds=(agent_phase_map.get(agent.agent_id) or {}).get("elapsed_seconds", 0),
|
||||
artifact_ids=(agent_phase_map.get(agent.agent_id) or {}).get("artifact_ids", []),
|
||||
current_action=(agent_phase_map.get(agent.agent_id) or {}).get("current_action"),
|
||||
)
|
||||
for agent in agents
|
||||
],
|
||||
phases=phases,
|
||||
resource_grants=context.get("resource_grants") or [],
|
||||
created_at=swarm.created_at,
|
||||
updated_at=swarm.updated_at,
|
||||
@@ -574,6 +607,7 @@ async def create_deployment(
|
||||
deployment_id=deployment_id,
|
||||
swarm_id=deployment_id,
|
||||
status=RuntimeDisplayStatus.ACCEPTED.value,
|
||||
mode="sub_agile",
|
||||
agent_instances=[
|
||||
AgentInstanceResponse(
|
||||
agent_instance_id=inst.agent_instance_id,
|
||||
@@ -589,6 +623,7 @@ async def create_deployment(
|
||||
"deployment_id": deployment_id,
|
||||
"swarm_id": deployment_id,
|
||||
"status": RuntimeDisplayStatus.ACCEPTED.value,
|
||||
"mode": "sub_agile",
|
||||
"estimated_ready_at": (deployment.created_at + timedelta(minutes=2)).isoformat(),
|
||||
},
|
||||
)
|
||||
@@ -692,6 +727,7 @@ async def list_deployments(
|
||||
deployment_summaries.append(DeploymentSummary(
|
||||
deployment_id=dep.deployment_id,
|
||||
status=display_status,
|
||||
mode=_runtime_mode_from_orchestration_plan(dep.orchestration_plan),
|
||||
risk_level=dep.risk_level.value,
|
||||
budget=BudgetSummary(
|
||||
max_usd=dep.budget_max_usd or 0.0,
|
||||
@@ -748,6 +784,7 @@ async def get_deployment(
|
||||
user_id=deployment.user_id,
|
||||
binding_scope=deployment.binding_scope,
|
||||
status=display_status,
|
||||
mode=_runtime_mode_from_orchestration_plan(deployment.orchestration_plan),
|
||||
phase=deployment.phase,
|
||||
orchestration_plan=deployment.orchestration_plan,
|
||||
risk_level=deployment.risk_level.value,
|
||||
@@ -770,6 +807,7 @@ async def get_deployment(
|
||||
)
|
||||
for inst in instances
|
||||
],
|
||||
phases=[],
|
||||
resource_grants=deployment.resource_grants or [],
|
||||
created_at=deployment.created_at,
|
||||
updated_at=deployment.updated_at
|
||||
@@ -919,6 +957,47 @@ async def stop_deployment(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/deployments/{deployment_id}/artifact-edits", response_model=ArtifactEditResponse)
|
||||
async def receive_deployment_artifact_edit(
|
||||
deployment_id: str,
|
||||
request: ArtifactEditRequest,
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(verify_service_token),
|
||||
):
|
||||
"""Accept an accepted project revision for a runtime-backed deployment."""
|
||||
deployment = db.query(Deployment).filter(
|
||||
Deployment.deployment_id == deployment_id
|
||||
).first()
|
||||
if deployment:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": ErrorCode.INVALID_REQUEST,
|
||||
"message": "ARTIFACT_EDIT_UNSUPPORTED_FOR_LAYOUT",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
swarm = db.query(Swarm).filter(Swarm.swarm_id == deployment_id).first()
|
||||
if not swarm:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": ErrorCode.DEPLOYMENT_NOT_FOUND,
|
||||
"message": f"Deployment {deployment_id} not found",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response = apply_runtime_artifact_edit(db, deployment_id, request)
|
||||
await _emit_artifact_edit_event(swarm, request, "artifact.local_edit_applied")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/deployments/{deployment_id}/approvals/{approval_id}")
|
||||
async def receive_deployment_approval_decision(
|
||||
deployment_id: str,
|
||||
|
||||
@@ -298,6 +298,11 @@ class AgentInstanceResponse(BaseModel):
|
||||
role: str
|
||||
status: str
|
||||
phase: Optional[str] = None
|
||||
tokens: int = 0
|
||||
tools: int = 0
|
||||
elapsed_seconds: int = 0
|
||||
artifact_ids: List[str] = Field(default_factory=list)
|
||||
current_action: Optional[str] = None
|
||||
|
||||
|
||||
class CreateDeploymentResponse(BaseModel):
|
||||
@@ -306,6 +311,7 @@ class CreateDeploymentResponse(BaseModel):
|
||||
deployment_id: str
|
||||
swarm_id: Optional[str] = None
|
||||
status: str
|
||||
mode: Optional[str] = None
|
||||
agent_instances: List[AgentInstanceResponse]
|
||||
created_at: datetime
|
||||
estimated_ready_at: Optional[datetime] = None
|
||||
@@ -323,6 +329,7 @@ class DeploymentSummary(BaseModel):
|
||||
"""Deployment summary for list response."""
|
||||
deployment_id: str
|
||||
status: str
|
||||
mode: Optional[str] = None
|
||||
risk_level: str
|
||||
budget: BudgetSummary
|
||||
created_at: datetime
|
||||
@@ -366,12 +373,14 @@ class GetDeploymentResponse(BaseModel):
|
||||
user_id: str
|
||||
binding_scope: str
|
||||
status: str
|
||||
mode: Optional[str] = None
|
||||
phase: Optional[str]
|
||||
orchestration_plan: str
|
||||
risk_level: str
|
||||
budget: BudgetSummary
|
||||
billing_context: Dict[str, Any]
|
||||
agent_instances: List[AgentInstanceResponse]
|
||||
phases: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
resource_grants: List[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -463,3 +472,25 @@ class SubAgileDeploymentDetailResponse(GetDeploymentResponse):
|
||||
|
||||
class SubAgileDeploymentStopResponse(StopDeploymentResponse):
|
||||
"""Primary response model for /api/agent/sub-agile/deployments/{id}/stop."""
|
||||
|
||||
|
||||
class ArtifactEditRequest(BaseModel):
|
||||
"""Manager-forwarded accepted revision metadata for a project artifact."""
|
||||
|
||||
artifact_id: str
|
||||
project_revision: int
|
||||
base_project_revision: Optional[int] = None
|
||||
base_content_hash: Optional[str] = None
|
||||
manifest_uri: str
|
||||
archive_uri: str
|
||||
source: str
|
||||
|
||||
|
||||
class ArtifactEditResponse(BaseModel):
|
||||
"""Runtime acknowledgement for artifact revision updates."""
|
||||
|
||||
success: bool = True
|
||||
deployment_id: str
|
||||
artifact_id: str
|
||||
project_revision: int
|
||||
status: str
|
||||
|
||||
@@ -77,6 +77,34 @@ class SwarmAgentInfo(BaseModel):
|
||||
service_url: Optional[str] = None
|
||||
current_task: Optional[str] = None
|
||||
output: Optional[str] = None
|
||||
tokens: int = 0
|
||||
tools: int = 0
|
||||
elapsed_seconds: int = 0
|
||||
artifact_ids: List[str] = Field(default_factory=list)
|
||||
current_action: Optional[str] = None
|
||||
|
||||
|
||||
class SwarmPhaseAgentInfo(BaseModel):
|
||||
"""Per-phase agent information for workflow/work displays."""
|
||||
agent_id: str
|
||||
role: str
|
||||
status: str
|
||||
tokens: int = 0
|
||||
tools: int = 0
|
||||
elapsed_seconds: int = 0
|
||||
artifact_ids: List[str] = Field(default_factory=list)
|
||||
current_action: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
|
||||
|
||||
class SwarmPhaseInfo(BaseModel):
|
||||
"""Runtime phase summary with agent-level details."""
|
||||
name: str
|
||||
status: str
|
||||
agents: List[SwarmPhaseAgentInfo] = Field(default_factory=list)
|
||||
artifact_ids: List[str] = Field(default_factory=list)
|
||||
summary: Optional[str] = None
|
||||
|
||||
|
||||
class SwarmCreateResponse(BaseModel):
|
||||
@@ -100,10 +128,12 @@ class SwarmStatusResponse(BaseModel):
|
||||
"""Compatibility response model for sub-mode runtime status."""
|
||||
deployment_id: Optional[str] = None
|
||||
swarm_id: str
|
||||
mode: Optional[str] = None
|
||||
status: str
|
||||
phase: Optional[str] = None
|
||||
progress: int
|
||||
agents: List[SwarmAgentInfo]
|
||||
phases: List[SwarmPhaseInfo] = Field(default_factory=list)
|
||||
metrics: SwarmMetrics
|
||||
artifacts: List[Dict[str, Any]] = []
|
||||
error_message: Optional[str] = None
|
||||
@@ -139,3 +169,23 @@ class ApprovalDecisionRequest(BaseModel):
|
||||
credential_ref: Optional[str] = None
|
||||
lease_id: Optional[str] = None
|
||||
lease_expires_at: Optional[int] = None
|
||||
|
||||
|
||||
class ArtifactEditRequest(BaseModel):
|
||||
"""Accepted project revision notification from Manager."""
|
||||
artifact_id: str
|
||||
project_revision: int
|
||||
base_project_revision: Optional[int] = None
|
||||
base_content_hash: Optional[str] = None
|
||||
manifest_uri: str
|
||||
archive_uri: str
|
||||
source: str
|
||||
|
||||
|
||||
class ArtifactEditResponse(BaseModel):
|
||||
"""Runtime acknowledgement for an accepted artifact revision."""
|
||||
success: bool = True
|
||||
deployment_id: str
|
||||
artifact_id: str
|
||||
project_revision: int
|
||||
status: str
|
||||
|
||||
@@ -1096,6 +1096,7 @@ class SwarmOrchestrator:
|
||||
metadata = {
|
||||
"redacted": True,
|
||||
"agent_role": role,
|
||||
"source_agent_role": role,
|
||||
"runtime_deployment_id": self.swarm_id,
|
||||
"summary_only": False,
|
||||
"artifact_layout": "project_folder",
|
||||
@@ -1105,6 +1106,7 @@ class SwarmOrchestrator:
|
||||
"directory_count": stored_project.directory_count if stored_project else 1,
|
||||
"manifest_uri": runtime_artifact_manifest_path(self.swarm_id, artifact_id),
|
||||
"archive_uri": runtime_artifact_archive_path(self.swarm_id, artifact_id),
|
||||
"project_revision": 1,
|
||||
}
|
||||
if stored_project:
|
||||
metadata.update(
|
||||
@@ -1112,6 +1114,11 @@ class SwarmOrchestrator:
|
||||
"content_hash": stored_project.content_hash,
|
||||
"download_path": runtime_artifact_archive_path(self.swarm_id, artifact_id),
|
||||
"files_base_uri": runtime_artifact_file_path(self.swarm_id, artifact_id, ""),
|
||||
"delivery_ref": {
|
||||
"kind": "runtime_artifact",
|
||||
"artifact_id": artifact_id,
|
||||
"project_revision": 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -1132,6 +1139,7 @@ class SwarmOrchestrator:
|
||||
metadata = {
|
||||
"redacted": True,
|
||||
"agent_role": role,
|
||||
"source_agent_role": role,
|
||||
"runtime_deployment_id": self.swarm_id,
|
||||
"summary_only": False,
|
||||
"artifact_layout": "single_file_content",
|
||||
@@ -1224,6 +1232,7 @@ class SwarmOrchestrator:
|
||||
"summary_only": True,
|
||||
"artifact_layout": "single_file_content",
|
||||
"primary_read_path": "content",
|
||||
"project_revision": 0,
|
||||
}
|
||||
if stored:
|
||||
metadata.update(
|
||||
|
||||
+224
-1
@@ -29,10 +29,11 @@ from .artifact_store import (
|
||||
from .models import (
|
||||
SwarmCreateRequest, SwarmCreateResponse, SwarmStatusResponse,
|
||||
SwarmStopRequest, SwarmStopResponse, SwarmAgentInfo, SwarmMetrics,
|
||||
ApprovalDecisionRequest
|
||||
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"])
|
||||
|
||||
@@ -50,6 +51,41 @@ def generate_agent_id(role: str) -> str:
|
||||
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,
|
||||
@@ -59,11 +95,92 @@ def _agent_infos_for_swarm(db: Session, swarm_id: str) -> list[SwarmAgentInfo]:
|
||||
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:
|
||||
@@ -133,10 +250,12 @@ def _build_swarm_status_response(db: Session, swarm: Swarm) -> SwarmStatusRespon
|
||||
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,
|
||||
@@ -653,6 +772,110 @@ async def get_swarm_artifact_file(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -31,7 +31,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260602171443-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603104215-arm64
|
||||
imagePullPolicy: Always
|
||||
|
||||
ports:
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
- name: acr-secret
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260602171443-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603104215-arm64
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ spec:
|
||||
- name: acr-secret
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260602171443-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603104215-arm64
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
@@ -25,6 +25,7 @@ def test_primary_and_compat_runtime_routes_are_registered():
|
||||
assert 'prefix="/sub-agile"' in agent_router_source
|
||||
assert '@router.post("/runtime-events")' in callbacks_source
|
||||
assert '@compat_router.post("/swarm-events")' in callbacks_source
|
||||
assert '@router.post("/deployments/{deployment_id}/artifact-edits"' in _read("api/agnet/deployments.py")
|
||||
assert 'prefix="/api/swarms"' in swarm_router_source
|
||||
assert '@swarms_router.post("/{swarm_id}/approvals/{approval_id}")' in swarm_router_source
|
||||
assert '@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/manifest")' in swarm_router_source
|
||||
@@ -82,3 +83,24 @@ def test_project_folder_contract_is_documented_and_flagged():
|
||||
assert '"artifact_layout": "project_folder"' in orchestrator_source
|
||||
assert '"primary_read_path": "manifest"' in orchestrator_source
|
||||
assert '"summary_only": True' in swarm_router_source
|
||||
|
||||
|
||||
def test_runtime_rich_workflow_contract_is_present():
|
||||
"""Workflow/work outputs should expose richer runtime fields."""
|
||||
swarm_models_source = _read("api/swarm/models.py")
|
||||
swarm_router_source = _read("api/swarm/router.py")
|
||||
orchestrator_source = _read("api/swarm/orchestrator.py")
|
||||
|
||||
assert "tokens: int = 0" in swarm_models_source
|
||||
assert "tools: int = 0" in swarm_models_source
|
||||
assert "elapsed_seconds: int = 0" in swarm_models_source
|
||||
assert "artifact_ids: List[str] = Field(default_factory=list)" in swarm_models_source
|
||||
assert "class SwarmPhaseInfo" in swarm_models_source
|
||||
assert "phases: List[SwarmPhaseInfo]" in swarm_models_source
|
||||
assert "source_agent_role" in orchestrator_source
|
||||
assert "project_revision" in orchestrator_source
|
||||
assert "delivery_ref" in orchestrator_source
|
||||
assert "@swarms_router.post(\"/{swarm_id}/artifact-edits\"" in swarm_router_source
|
||||
callbacks_source = _read("api/agnet/callbacks.py")
|
||||
assert "artifact.local_edit_applied" in callbacks_source
|
||||
assert '"project_folder"' in callbacks_source
|
||||
|
||||
Reference in New Issue
Block a user