diff --git a/api/agnet/callbacks.py b/api/agnet/callbacks.py index 760c284..925085c 100644 --- a/api/agnet/callbacks.py +++ b/api/agnet/callbacks.py @@ -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, diff --git a/api/swarm/artifact_store.py b/api/swarm/artifact_store.py index b2f9739..c6d5078 100644 --- a/api/swarm/artifact_store.py +++ b/api/swarm/artifact_store.py @@ -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:///artifacts/ URIs.""" if not uri or not uri.startswith("runtime://"): diff --git a/api/swarm/orchestrator.py b/api/swarm/orchestrator.py index a5fc0d3..63188b4 100644 --- a/api/swarm/orchestrator.py +++ b/api/swarm/orchestrator.py @@ -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[a-zA-Z0-9_+-]*)\n(?P.*?)```", re.DOTALL) +_FILENAME_HINT_RE = re.compile( + r"^\s*(?:#|//|/\*+|\*|--)?\s*(?P[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" + ) diff --git a/api/swarm/router.py b/api/swarm/router.py index 941fb02..762253f 100644 --- a/api/swarm/router.py +++ b/api/swarm/router.py @@ -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, diff --git a/docs/HEICODE_SUB_MODE_RUNTIME_INTEGRATION.md b/docs/HEICODE_SUB_MODE_RUNTIME_INTEGRATION.md index 7e9790f..115449e 100644 --- a/docs/HEICODE_SUB_MODE_RUNTIME_INTEGRATION.md +++ b/docs/HEICODE_SUB_MODE_RUNTIME_INTEGRATION.md @@ -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 协议仍属于后续增强项 接入方应按以下原则理解: diff --git a/k8s/agent-manager-deployment.yaml b/k8s/agent-manager-deployment.yaml index d9b69a4..15fd8f8 100644 --- a/k8s/agent-manager-deployment.yaml +++ b/k8s/agent-manager-deployment.yaml @@ -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: diff --git a/k8s/deployment-with-kubeconfig.yaml b/k8s/deployment-with-kubeconfig.yaml index 0abaf75..2e0f0d6 100755 --- a/k8s/deployment-with-kubeconfig.yaml +++ b/k8s/deployment-with-kubeconfig.yaml @@ -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 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index e594249..e4ed0cd 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -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 diff --git a/tests/test_sub_mode_runtime_contract.py b/tests/test_sub_mode_runtime_contract.py index 7fd41b7..e4c2611 100644 --- a/tests/test_sub_mode_runtime_contract.py +++ b/tests/test_sub_mode_runtime_contract.py @@ -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