Add project-folder runtime artifacts
This commit is contained in:
@@ -3,6 +3,7 @@ from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import mimetypes
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
@@ -22,6 +23,7 @@ from api.agnet.auth import verify_service_token
|
||||
from api.agnet.validators import validate_no_sensitive_fields
|
||||
from api.agnet.vault_client import vault_client
|
||||
from api.swarm.artifact_store import load_azblob_artifact, load_runtime_artifact, runtime_uri_parts
|
||||
from api.swarm.artifact_store import load_runtime_project_artifact, load_runtime_project_file
|
||||
from api.status_projection import RuntimeDisplayStatus
|
||||
from config.error_codes import ErrorCode
|
||||
from config.settings import settings
|
||||
@@ -478,6 +480,13 @@ async def get_deployment_artifact_content(
|
||||
|
||||
parts = runtime_uri_parts(artifact_payload.get("uri"))
|
||||
if parts:
|
||||
project_artifact = load_runtime_project_artifact(*parts)
|
||||
if project_artifact:
|
||||
return FileResponse(
|
||||
path=project_artifact.archive_path,
|
||||
media_type="application/zip",
|
||||
filename=project_artifact.archive_path.name,
|
||||
)
|
||||
stored = load_runtime_artifact(*parts)
|
||||
if stored:
|
||||
return FileResponse(
|
||||
@@ -513,6 +522,127 @@ async def get_deployment_artifact_content(
|
||||
raise HTTPException(status_code=404, detail="Artifact content not found")
|
||||
|
||||
|
||||
@user_router.get("/{deployment_id}/artifacts/{artifact_id}/manifest")
|
||||
async def get_deployment_artifact_manifest(
|
||||
deployment_id: str,
|
||||
artifact_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(verify_service_token),
|
||||
):
|
||||
"""Return manifest JSON for a project-folder artifact."""
|
||||
_ensure_deployment(db, deployment_id)
|
||||
events = (
|
||||
db.query(Event)
|
||||
.filter(Event.deployment_id == deployment_id, Event.event_type == "artifact.created")
|
||||
.order_by(Event.occurred_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
artifact_payload = None
|
||||
for event in events:
|
||||
payload = event.payload or {}
|
||||
artifact = payload.get("artifact") if isinstance(payload.get("artifact"), dict) else payload
|
||||
if (artifact.get("artifact_id") or event.event_id) == artifact_id:
|
||||
artifact_payload = artifact
|
||||
break
|
||||
|
||||
if not artifact_payload:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
parts = runtime_uri_parts(artifact_payload.get("uri"))
|
||||
if parts:
|
||||
project_artifact = load_runtime_project_artifact(*parts)
|
||||
if project_artifact:
|
||||
return FileResponse(
|
||||
path=project_artifact.manifest_path,
|
||||
media_type="application/json",
|
||||
filename=project_artifact.manifest_path.name,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Project artifact manifest not found")
|
||||
|
||||
|
||||
@user_router.get("/{deployment_id}/artifacts/{artifact_id}/archive.zip")
|
||||
async def get_deployment_artifact_archive(
|
||||
deployment_id: str,
|
||||
artifact_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(verify_service_token),
|
||||
):
|
||||
"""Return zip archive for a project-folder artifact."""
|
||||
_ensure_deployment(db, deployment_id)
|
||||
events = (
|
||||
db.query(Event)
|
||||
.filter(Event.deployment_id == deployment_id, Event.event_type == "artifact.created")
|
||||
.order_by(Event.occurred_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
artifact_payload = None
|
||||
for event in events:
|
||||
payload = event.payload or {}
|
||||
artifact = payload.get("artifact") if isinstance(payload.get("artifact"), dict) else payload
|
||||
if (artifact.get("artifact_id") or event.event_id) == artifact_id:
|
||||
artifact_payload = artifact
|
||||
break
|
||||
|
||||
if not artifact_payload:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
parts = runtime_uri_parts(artifact_payload.get("uri"))
|
||||
if parts:
|
||||
project_artifact = load_runtime_project_artifact(*parts)
|
||||
if project_artifact:
|
||||
return FileResponse(
|
||||
path=project_artifact.archive_path,
|
||||
media_type="application/zip",
|
||||
filename=project_artifact.archive_path.name,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Project artifact archive not found")
|
||||
|
||||
|
||||
@user_router.get("/{deployment_id}/artifacts/{artifact_id}/files/{file_path:path}")
|
||||
async def get_deployment_artifact_file(
|
||||
deployment_id: str,
|
||||
artifact_id: str,
|
||||
file_path: str,
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(verify_service_token),
|
||||
):
|
||||
"""Return a single file from a project-folder artifact."""
|
||||
_ensure_deployment(db, deployment_id)
|
||||
events = (
|
||||
db.query(Event)
|
||||
.filter(Event.deployment_id == deployment_id, Event.event_type == "artifact.created")
|
||||
.order_by(Event.occurred_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
artifact_payload = None
|
||||
for event in events:
|
||||
payload = event.payload or {}
|
||||
artifact = payload.get("artifact") if isinstance(payload.get("artifact"), dict) else payload
|
||||
if (artifact.get("artifact_id") or event.event_id) == artifact_id:
|
||||
artifact_payload = artifact
|
||||
break
|
||||
|
||||
if not artifact_payload:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
parts = runtime_uri_parts(artifact_payload.get("uri"))
|
||||
if parts:
|
||||
resolved_file = load_runtime_project_file(parts[0], parts[1], file_path)
|
||||
if resolved_file:
|
||||
return FileResponse(
|
||||
path=resolved_file,
|
||||
media_type=mimetypes.guess_type(str(resolved_file))[0] or "text/plain",
|
||||
filename=resolved_file.name,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Project artifact file not found")
|
||||
|
||||
|
||||
@user_router.get("/{deployment_id}/timeline")
|
||||
async def list_deployment_timeline(
|
||||
deployment_id: str,
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -47,6 +49,21 @@ class StoredArtifact:
|
||||
content_hash: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredProjectArtifact:
|
||||
"""Metadata for a persisted project-folder artifact."""
|
||||
|
||||
artifact_id: str
|
||||
uri: str
|
||||
root_dir: str
|
||||
artifact_dir: Path
|
||||
manifest_path: Path
|
||||
archive_path: Path
|
||||
file_count: int
|
||||
directory_count: int
|
||||
content_hash: str
|
||||
|
||||
|
||||
def sanitize_artifact_id(value: str) -> str:
|
||||
"""Return a filesystem-safe identifier while preserving readable IDs."""
|
||||
cleaned = _SAFE_ID_RE.sub("_", value or "").strip("._-")
|
||||
@@ -65,11 +82,32 @@ def runtime_artifact_download_path(swarm_id: str, artifact_id: str) -> str:
|
||||
return f"/api/swarms/{sanitize_artifact_id(swarm_id)}/artifacts/{sanitize_artifact_id(artifact_id)}/content"
|
||||
|
||||
|
||||
def runtime_artifact_manifest_path(swarm_id: str, artifact_id: str) -> str:
|
||||
"""Build the HTTP path for a project-folder manifest."""
|
||||
return f"/api/swarms/{sanitize_artifact_id(swarm_id)}/artifacts/{sanitize_artifact_id(artifact_id)}/manifest"
|
||||
|
||||
|
||||
def runtime_artifact_archive_path(swarm_id: str, artifact_id: str) -> str:
|
||||
"""Build the HTTP path for a project-folder archive."""
|
||||
return f"/api/swarms/{sanitize_artifact_id(swarm_id)}/artifacts/{sanitize_artifact_id(artifact_id)}/archive.zip"
|
||||
|
||||
|
||||
def runtime_artifact_file_path(swarm_id: str, artifact_id: str, file_path: str) -> str:
|
||||
"""Build the HTTP path for a single file inside a project-folder artifact."""
|
||||
safe_swarm_id = sanitize_artifact_id(swarm_id)
|
||||
safe_artifact_id = sanitize_artifact_id(artifact_id)
|
||||
return f"/api/swarms/{safe_swarm_id}/artifacts/{safe_artifact_id}/files/{file_path.lstrip('/')}"
|
||||
|
||||
|
||||
def _artifact_dir(swarm_id: str) -> Path:
|
||||
base_dir = Path(settings.RUNTIME_ARTIFACT_DIR)
|
||||
return base_dir / sanitize_artifact_id(swarm_id)
|
||||
|
||||
|
||||
def _project_artifact_dir(swarm_id: str, artifact_id: str) -> Path:
|
||||
return _artifact_dir(swarm_id) / f"{sanitize_artifact_id(artifact_id)}.project"
|
||||
|
||||
|
||||
def _extension_for_mime_type(mime_type: str) -> str:
|
||||
if mime_type == "text/x-diff":
|
||||
return ".patch"
|
||||
@@ -275,6 +313,140 @@ def load_runtime_artifact(swarm_id: str, artifact_id: str) -> Optional[StoredArt
|
||||
)
|
||||
|
||||
|
||||
def _safe_project_file_path(value: str) -> str:
|
||||
normalized = (value or "").strip().replace("\\", "/").lstrip("/")
|
||||
normalized = re.sub(r"/+", "/", normalized)
|
||||
if not normalized or normalized in {".", ".."} or normalized.startswith("../") or "/../" in normalized:
|
||||
raise ValueError("invalid project file path")
|
||||
return normalized
|
||||
|
||||
|
||||
def _directory_count_for_files(root_dir: str, files: dict[str, str]) -> int:
|
||||
directories = {root_dir}
|
||||
for relative_path in files:
|
||||
parts = relative_path.split("/")[:-1]
|
||||
current = root_dir
|
||||
for part in parts:
|
||||
current = f"{current}/{part}" if current else part
|
||||
directories.add(current)
|
||||
return len(directories)
|
||||
|
||||
|
||||
def store_project_artifact(
|
||||
swarm_id: str,
|
||||
artifact_id: str,
|
||||
*,
|
||||
root_dir: str,
|
||||
files: dict[str, str],
|
||||
) -> StoredProjectArtifact:
|
||||
"""Persist a project-folder artifact with manifest and archive."""
|
||||
safe_artifact_id = sanitize_artifact_id(artifact_id)
|
||||
safe_root_dir = sanitize_artifact_id(root_dir)
|
||||
if not files:
|
||||
raise ValueError("project artifact requires at least one file")
|
||||
|
||||
artifact_dir = _project_artifact_dir(swarm_id, safe_artifact_id)
|
||||
if artifact_dir.exists():
|
||||
for child in sorted(artifact_dir.rglob("*"), reverse=True):
|
||||
if child.is_file():
|
||||
child.unlink()
|
||||
elif child.is_dir():
|
||||
child.rmdir()
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
root_path = artifact_dir / safe_root_dir
|
||||
root_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
normalized_files: dict[str, str] = {}
|
||||
for relative_path, content in files.items():
|
||||
safe_relative_path = _safe_project_file_path(relative_path)
|
||||
normalized_files[safe_relative_path] = content or ""
|
||||
file_path = root_path / safe_relative_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content or "", encoding="utf-8")
|
||||
|
||||
manifest = {
|
||||
"artifact_id": safe_artifact_id,
|
||||
"artifact_type": "project_folder",
|
||||
"root_dir": safe_root_dir,
|
||||
"file_count": len(normalized_files),
|
||||
"directory_count": _directory_count_for_files(safe_root_dir, normalized_files),
|
||||
"files": [
|
||||
{
|
||||
"path": relative_path,
|
||||
"size_bytes": len(content.encode("utf-8")),
|
||||
"mime_type": mimetypes.guess_type(relative_path)[0] or "text/plain",
|
||||
}
|
||||
for relative_path, content in sorted(normalized_files.items())
|
||||
],
|
||||
}
|
||||
|
||||
manifest_path = artifact_dir / "manifest.json"
|
||||
manifest_bytes = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
manifest_path.write_bytes(manifest_bytes)
|
||||
|
||||
archive_path = artifact_dir / f"{safe_artifact_id}.zip"
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for relative_path in sorted(normalized_files):
|
||||
zf.write(root_path / relative_path, arcname=f"{safe_root_dir}/{relative_path}")
|
||||
zf.write(manifest_path, arcname=f"{safe_root_dir}/heicode-artifact.json")
|
||||
|
||||
hash_source = hashlib.sha256()
|
||||
hash_source.update(manifest_bytes)
|
||||
for relative_path, content in sorted(normalized_files.items()):
|
||||
hash_source.update(relative_path.encode("utf-8"))
|
||||
hash_source.update((content or "").encode("utf-8"))
|
||||
content_hash = "sha256:" + hash_source.hexdigest()
|
||||
|
||||
return StoredProjectArtifact(
|
||||
artifact_id=safe_artifact_id,
|
||||
uri=runtime_artifact_uri(swarm_id, safe_artifact_id),
|
||||
root_dir=safe_root_dir,
|
||||
artifact_dir=artifact_dir,
|
||||
manifest_path=manifest_path,
|
||||
archive_path=archive_path,
|
||||
file_count=len(normalized_files),
|
||||
directory_count=manifest["directory_count"],
|
||||
content_hash=content_hash,
|
||||
)
|
||||
|
||||
|
||||
def load_runtime_project_artifact(swarm_id: str, artifact_id: str) -> Optional[StoredProjectArtifact]:
|
||||
"""Load project-folder artifact metadata if it exists locally."""
|
||||
safe_swarm_id = sanitize_artifact_id(swarm_id)
|
||||
safe_artifact_id = sanitize_artifact_id(artifact_id)
|
||||
artifact_dir = _project_artifact_dir(safe_swarm_id, safe_artifact_id)
|
||||
manifest_path = artifact_dir / "manifest.json"
|
||||
archive_path = artifact_dir / f"{safe_artifact_id}.zip"
|
||||
if not artifact_dir.exists() or not manifest_path.exists() or not archive_path.exists():
|
||||
return None
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
return StoredProjectArtifact(
|
||||
artifact_id=safe_artifact_id,
|
||||
uri=runtime_artifact_uri(safe_swarm_id, safe_artifact_id),
|
||||
root_dir=manifest.get("root_dir") or safe_artifact_id,
|
||||
artifact_dir=artifact_dir,
|
||||
manifest_path=manifest_path,
|
||||
archive_path=archive_path,
|
||||
file_count=int(manifest.get("file_count") or 0),
|
||||
directory_count=int(manifest.get("directory_count") or 0),
|
||||
content_hash="sha256:" + hashlib.sha256(manifest_path.read_bytes() + archive_path.read_bytes()).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def load_runtime_project_file(swarm_id: str, artifact_id: str, relative_path: str) -> Optional[Path]:
|
||||
"""Resolve a single file inside a stored project-folder artifact."""
|
||||
project = load_runtime_project_artifact(swarm_id, artifact_id)
|
||||
if not project:
|
||||
return None
|
||||
safe_relative_path = _safe_project_file_path(relative_path)
|
||||
file_path = project.artifact_dir / project.root_dir / safe_relative_path
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return None
|
||||
return file_path
|
||||
|
||||
|
||||
def runtime_uri_parts(uri: str) -> Optional[tuple[str, str]]:
|
||||
"""Parse runtime://<swarm_id>/artifacts/<artifact_id> URIs."""
|
||||
if not uri or not uri.startswith("runtime://"):
|
||||
|
||||
+116
-2
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
@@ -11,11 +12,24 @@ from sqlalchemy.orm import Session
|
||||
from database import Swarm, SwarmAgent, SwarmMessage, SwarmStatus, SwarmAgentStatus
|
||||
from k8s_manager import K8sManager
|
||||
from .agent_client import SwarmAgentClient
|
||||
from .artifact_store import store_text_artifact, runtime_artifact_uri, runtime_artifact_download_path
|
||||
from .artifact_store import (
|
||||
store_project_artifact,
|
||||
store_text_artifact,
|
||||
runtime_artifact_archive_path,
|
||||
runtime_artifact_download_path,
|
||||
runtime_artifact_file_path,
|
||||
runtime_artifact_manifest_path,
|
||||
runtime_artifact_uri,
|
||||
)
|
||||
from .callback_client import CallbackDeliveryClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CODE_BLOCK_RE = re.compile(r"```(?P<lang>[a-zA-Z0-9_+-]*)\n(?P<body>.*?)```", re.DOTALL)
|
||||
_FILENAME_HINT_RE = re.compile(
|
||||
r"^\s*(?:#|//|/\*+|\*|--)?\s*(?P<path>[A-Za-z0-9_.\-/]+\.[A-Za-z0-9_]+)\s*(?:\*/)?\s*$"
|
||||
)
|
||||
|
||||
|
||||
PHASE_MAP = {
|
||||
"planning": ("requirements", "agent_running"),
|
||||
@@ -1070,11 +1084,50 @@ class SwarmOrchestrator:
|
||||
def _build_artifact(self, agent: SwarmAgent, response: Any, index: int) -> Dict[str, Any]:
|
||||
"""Convert an agent response into a Heicode-visible artifact record."""
|
||||
role = agent.role or "worker"
|
||||
artifact_type = "code_patch" if role in {"backend", "frontend", "coder", "engineer"} else "document"
|
||||
is_structural_code_role = role in {"backend", "frontend", "coder", "engineer", "fullstack"}
|
||||
artifact_type = "project_folder" if is_structural_code_role else "document"
|
||||
artifact_id = f"art_{self.swarm_id}_{role}_{index + 1}"
|
||||
content = self._extract_deliverable_text(response)
|
||||
if not content:
|
||||
content = json.dumps(response, ensure_ascii=False, indent=2) if isinstance(response, (dict, list)) else str(response)
|
||||
if is_structural_code_role:
|
||||
files, root_dir = self._project_files_from_content(role, content)
|
||||
stored_project = self._store_project_artifact(artifact_id, root_dir, files)
|
||||
metadata = {
|
||||
"redacted": True,
|
||||
"agent_role": role,
|
||||
"runtime_deployment_id": self.swarm_id,
|
||||
"summary_only": False,
|
||||
"artifact_layout": "project_folder",
|
||||
"primary_read_path": "manifest",
|
||||
"root_dir": stored_project.root_dir if stored_project else root_dir,
|
||||
"file_count": stored_project.file_count if stored_project else len(files),
|
||||
"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),
|
||||
}
|
||||
if stored_project:
|
||||
metadata.update(
|
||||
{
|
||||
"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, ""),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"artifact_id": artifact_id,
|
||||
"artifact_type": artifact_type,
|
||||
"title": f"{role} task delivery",
|
||||
"summary": content[:1000],
|
||||
"uri": stored_project.uri if stored_project else runtime_artifact_uri(self.swarm_id, artifact_id),
|
||||
"agent_instance_id": agent.agent_id,
|
||||
"mime_type": "application/zip",
|
||||
"size_bytes": stored_project.archive_path.stat().st_size if stored_project else len(content.encode("utf-8")),
|
||||
"stage": "development",
|
||||
"checkpoint": "artifact_ready",
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
stored = self._store_artifact_content(artifact_id, content)
|
||||
metadata = {
|
||||
"redacted": True,
|
||||
@@ -1211,3 +1264,64 @@ class SwarmOrchestrator:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist runtime artifact %s: %s", artifact_id, e)
|
||||
return None
|
||||
|
||||
def _store_project_artifact(self, artifact_id: str, root_dir: str, files: Dict[str, str]):
|
||||
"""Persist a project-folder artifact without blocking runtime completion."""
|
||||
try:
|
||||
return store_project_artifact(self.swarm_id, artifact_id, root_dir=root_dir, files=files)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist runtime project artifact %s: %s", artifact_id, e)
|
||||
return None
|
||||
|
||||
def _project_files_from_content(self, role: str, content: str) -> tuple[Dict[str, str], str]:
|
||||
"""Convert a code-oriented response into a minimal project-folder file set."""
|
||||
root_dir = f"{role}-delivery"
|
||||
files: Dict[str, str] = {}
|
||||
for match in _CODE_BLOCK_RE.finditer(content):
|
||||
body = (match.group("body") or "").strip("\n")
|
||||
if not body:
|
||||
continue
|
||||
lines = body.splitlines()
|
||||
first_line = lines[0].strip() if lines else ""
|
||||
filename_match = _FILENAME_HINT_RE.match(first_line)
|
||||
if filename_match:
|
||||
file_path = filename_match.group("path")
|
||||
file_content = "\n".join(lines[1:]).lstrip("\n")
|
||||
else:
|
||||
file_path = self._default_file_path_for_role(role, match.group("lang") or "", len(files))
|
||||
file_content = body
|
||||
files[file_path] = file_content or ""
|
||||
|
||||
if not files:
|
||||
files[self._default_file_path_for_role(role, "", 0)] = content.strip() + "\n"
|
||||
|
||||
files.setdefault("README.md", self._project_readme(role, content))
|
||||
files.setdefault("heicode-artifact.json", json.dumps({"role": role, "artifact_layout": "project_folder"}, ensure_ascii=False, indent=2))
|
||||
return files, root_dir
|
||||
|
||||
def _default_file_path_for_role(self, role: str, language: str, index: int) -> str:
|
||||
"""Choose a stable fallback file path when the agent output omits file names."""
|
||||
normalized_role = (role or "worker").lower()
|
||||
language = (language or "").lower()
|
||||
if normalized_role == "backend":
|
||||
if language in {"python", "py"}:
|
||||
return "backend/app.py" if index == 0 else f"backend/module_{index + 1}.py"
|
||||
return "backend/implementation.txt"
|
||||
if normalized_role == "frontend":
|
||||
if language in {"jsx", "tsx", "javascript", "js", "typescript", "ts"}:
|
||||
return "frontend/OrderPage.jsx" if index == 0 else f"frontend/component_{index + 1}.jsx"
|
||||
if language == "css":
|
||||
return "frontend/styles.css"
|
||||
return "frontend/implementation.txt"
|
||||
if normalized_role == "reviewer":
|
||||
return "review/review.md"
|
||||
return f"{normalized_role}/artifact_{index + 1}.txt"
|
||||
|
||||
def _project_readme(self, role: str, content: str) -> str:
|
||||
"""Build a lightweight README for project-folder artifacts."""
|
||||
return (
|
||||
f"# {role} delivery\n\n"
|
||||
"This project-folder artifact was synthesized by the sub-mode runtime from the agent response.\n\n"
|
||||
"## Summary\n\n"
|
||||
f"{content[:1500].strip()}\n"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""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
|
||||
@@ -15,8 +16,13 @@ from database import (
|
||||
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,
|
||||
)
|
||||
@@ -552,6 +558,14 @@ async def get_swarm_artifact_content(
|
||||
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(
|
||||
@@ -572,6 +586,73 @@ async def get_swarm_artifact_content(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@swarms_router.post("/{swarm_id}/approvals/{approval_id}")
|
||||
async def receive_swarm_approval_decision(
|
||||
swarm_id: str,
|
||||
|
||||
@@ -359,9 +359,10 @@ artifact_type = project_folder
|
||||
|
||||
当前仓库现状说明:
|
||||
|
||||
- 当前实现仍以 `single_file_content` 作为主要真实产物形态
|
||||
- 结构性代码任务尚未默认落成 `project_folder`
|
||||
- 这属于当前实现缺口,不应被视为最终统一协议目标
|
||||
- 结构性代码角色(如 `backend` / `frontend` / `coder` / `engineer` / `fullstack`)现在会优先产出 `project_folder`
|
||||
- 普通摘要/兼容型产物仍可能是 `single_file_content`
|
||||
- `project_folder` 当前已经支持 `manifest`、`archive.zip`、`files/{path}` 读取
|
||||
- 更完整的 revision / conflict 协议仍属于后续增强项
|
||||
|
||||
接入方应按以下原则理解:
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260601235948-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260602171443-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-20260601235948-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260602171443-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-20260601235948-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260602171443-arm64
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
@@ -27,6 +27,9 @@ def test_primary_and_compat_runtime_routes_are_registered():
|
||||
assert '@compat_router.post("/swarm-events")' in callbacks_source
|
||||
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
|
||||
assert '@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/archive.zip")' in swarm_router_source
|
||||
assert '@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/files/{file_path:path}")' in swarm_router_source
|
||||
|
||||
|
||||
def test_projected_statuses_cover_manager_facing_contract():
|
||||
@@ -64,3 +67,18 @@ def test_runtime_run_projection_remains_sub_mode_compatible():
|
||||
assert project_runtime_run_status("completed") == RuntimeDisplayStatus.COMPLETED.value
|
||||
assert project_runtime_run_status("failed") == RuntimeDisplayStatus.FAILED.value
|
||||
assert project_runtime_run_status("stopped") == RuntimeDisplayStatus.STOPPED.value
|
||||
|
||||
|
||||
def test_project_folder_contract_is_documented_and_flagged():
|
||||
"""Structural code artifacts should be documented and tagged distinctly."""
|
||||
doc_source = _read("docs/HEICODE_SUB_MODE_RUNTIME_INTEGRATION.md")
|
||||
orchestrator_source = _read("api/swarm/orchestrator.py")
|
||||
swarm_router_source = _read("api/swarm/router.py")
|
||||
|
||||
assert "project_folder artifact(结构性代码强制要求)" in doc_source
|
||||
assert "manifest_uri" in doc_source
|
||||
assert "archive_uri" in doc_source
|
||||
assert "files/{path}" in doc_source
|
||||
assert '"artifact_layout": "project_folder"' in orchestrator_source
|
||||
assert '"primary_read_path": "manifest"' in orchestrator_source
|
||||
assert '"summary_only": True' in swarm_router_source
|
||||
|
||||
Reference in New Issue
Block a user