From fc903dded540e88a2ffeee43b70662ac739d84cc Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 14:38:16 +0800 Subject: [PATCH 01/69] feat: add Mission, AgentProfile, Execution data models and migration Co-Authored-By: Claude Opus 4.6 (1M context) --- ...e5f6_add_mission_agent_execution_tables.py | 152 ++++++++++++++++++ backend/app/models/__init__.py | 14 ++ backend/app/models/agent_profile.py | 51 ++++++ backend/app/models/execution.py | 138 ++++++++++++++++ backend/app/models/mission.py | 81 ++++++++++ backend/tests/test_models/__init__.py | 0 backend/tests/test_models/conftest.py | 3 + backend/tests/test_models/test_execution.py | 29 ++++ backend/tests/test_models/test_mission.py | 32 ++++ 9 files changed, 500 insertions(+) create mode 100644 backend/alembic/versions/20260415_000000_a1b2c3d4e5f6_add_mission_agent_execution_tables.py create mode 100644 backend/app/models/agent_profile.py create mode 100644 backend/app/models/execution.py create mode 100644 backend/app/models/mission.py create mode 100644 backend/tests/test_models/__init__.py create mode 100644 backend/tests/test_models/conftest.py create mode 100644 backend/tests/test_models/test_execution.py create mode 100644 backend/tests/test_models/test_mission.py diff --git a/backend/alembic/versions/20260415_000000_a1b2c3d4e5f6_add_mission_agent_execution_tables.py b/backend/alembic/versions/20260415_000000_a1b2c3d4e5f6_add_mission_agent_execution_tables.py new file mode 100644 index 000000000..ec2736f22 --- /dev/null +++ b/backend/alembic/versions/20260415_000000_a1b2c3d4e5f6_add_mission_agent_execution_tables.py @@ -0,0 +1,152 @@ +"""add_mission_agent_execution_tables + +Revision ID: a1b2c3d4e5f6 +Revises: 0f7082711f20 +Create Date: 2026-04-15 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "0f7082711f20" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- Enum types --- + op.execute("CREATE TYPE missionstatus AS ENUM ('backlog','todo','in_progress','in_review','done','blocked','cancelled')") + op.execute("CREATE TYPE missionpriority AS ENUM ('none','low','medium','high','urgent')") + op.execute("CREATE TYPE agentstatus AS ENUM ('idle','working','blocked','error','offline')") + op.execute("CREATE TYPE executionstatus_v2 AS ENUM ('queued','dispatched','running','interrupt_wait','approval_wait','completed','failed','cancelled')") + op.execute("CREATE TYPE executionsource AS ENUM ('mission','chat','graph','coordinator','api')") + + # --- missions --- + op.create_table( + "missions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("objective", sa.Text(), nullable=True), + sa.Column("status", sa.Enum("backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled", name="missionstatus", create_type=False), nullable=False, server_default="backlog"), + sa.Column("priority", sa.Enum("none", "low", "medium", "high", "urgent", name="missionpriority", create_type=False), nullable=False, server_default="none"), + sa.Column("assignee_type", sa.String(50), nullable=True), + sa.Column("assignee_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("creator_id", sa.String(255), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False), + sa.Column("parent_mission_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("missions.id", ondelete="SET NULL"), nullable=True), + sa.Column("current_execution_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("due_date", sa.DateTime(timezone=True), nullable=True), + sa.Column("position", sa.Float(), nullable=False, server_default="0.0"), + sa.Column("tags", postgresql.JSONB(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("missions_workspace_status_idx", "missions", ["workspace_id", "status"]) + op.create_index("missions_assignee_idx", "missions", ["assignee_type", "assignee_id"]) + op.create_index("missions_creator_idx", "missions", ["creator_id", "created_at"]) + + # --- agent_profiles --- + op.create_table( + "agent_profiles", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("avatar", sa.String(500), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("runtime_type", sa.String(50), nullable=False), + sa.Column("status", sa.Enum("idle", "working", "blocked", "error", "offline", name="agentstatus", create_type=False), nullable=False, server_default="offline"), + sa.Column("max_concurrent_tasks", sa.Integer(), nullable=False, server_default="1"), + sa.Column("skill_ids", postgresql.JSONB(), nullable=True), + sa.Column("instructions", sa.Text(), nullable=True), + sa.Column("custom_env", postgresql.JSONB(), nullable=True), + sa.Column("runtime_config", postgresql.JSONB(), nullable=True), + sa.Column("visibility", sa.String(50), nullable=False, server_default="workspace"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("agent_profiles_workspace_idx", "agent_profiles", ["workspace_id"]) + op.create_index("agent_profiles_workspace_status_idx", "agent_profiles", ["workspace_id", "status"]) + + # --- executions --- + op.create_table( + "executions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", sa.String(255), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False), + sa.Column("source", sa.Enum("mission", "chat", "graph", "coordinator", "api", name="executionsource", create_type=False), nullable=False), + sa.Column("source_id", sa.String(255), nullable=True), + sa.Column("status", sa.Enum("queued", "dispatched", "running", "interrupt_wait", "approval_wait", "completed", "failed", "cancelled", name="executionstatus_v2", create_type=False), nullable=False, server_default="queued"), + sa.Column("title", sa.String(500), nullable=True), + sa.Column("mission_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("missions.id", ondelete="SET NULL"), nullable=True), + sa.Column("agent_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("agent_profiles.id", ondelete="SET NULL"), nullable=True), + sa.Column("parent_execution_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("executions.id", ondelete="SET NULL"), nullable=True), + sa.Column("result_summary", postgresql.JSONB(), nullable=True), + sa.Column("error_code", sa.String(100), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("runtime_type", sa.String(50), nullable=False), + sa.Column("runtime_config", postgresql.JSONB(), nullable=True), + sa.Column("container_id", sa.String(255), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_seq", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("prior_session_id", sa.String(255), nullable=True), + sa.Column("session_id", sa.String(255), nullable=True), + sa.Column("work_dir", sa.String(500), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("executions_workspace_status_idx", "executions", ["workspace_id", "status"]) + op.create_index("executions_mission_idx", "executions", ["mission_id"]) + op.create_index("executions_agent_profile_idx", "executions", ["agent_profile_id"]) + op.create_index("executions_parent_idx", "executions", ["parent_execution_id"]) + op.create_index("executions_user_created_idx", "executions", ["user_id", "created_at"]) + + # --- execution_events --- + op.create_table( + "execution_events", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("execution_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("executions.id", ondelete="CASCADE"), nullable=False), + sa.Column("seq", sa.BigInteger(), nullable=False), + sa.Column("event_type", sa.String(100), nullable=False), + sa.Column("payload", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("trace_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("observation_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("parent_observation_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_unique_constraint("uq_execution_events_exec_seq", "execution_events", ["execution_id", "seq"]) + op.create_index("execution_events_exec_created_idx", "execution_events", ["execution_id", "created_at"]) + + # --- execution_snapshots --- + op.create_table( + "execution_snapshots", + sa.Column("execution_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("executions.id", ondelete="CASCADE"), primary_key=True), + sa.Column("last_seq", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("status", sa.String(100), nullable=False), + sa.Column("projection", postgresql.JSONB(), nullable=False, server_default="{}"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("execution_snapshots") + op.drop_table("execution_events") + op.drop_table("executions") + op.drop_table("agent_profiles") + op.drop_table("missions") + op.execute("DROP TYPE IF EXISTS executionsource") + op.execute("DROP TYPE IF EXISTS executionstatus_v2") + op.execute("DROP TYPE IF EXISTS agentstatus") + op.execute("DROP TYPE IF EXISTS missionpriority") + op.execute("DROP TYPE IF EXISTS missionstatus") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 761a6150f..d14456ccc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -44,6 +44,10 @@ from .skill_version import SkillVersion, SkillVersionFile from .user_sandbox import UserSandbox from .workspace import Workspace, WorkspaceFolder, WorkspaceMember, WorkspaceMemberRole, WorkspaceStatus +from .agent_profile import AgentProfile, AgentStatus +from .execution import Execution, ExecutionEvent, ExecutionSnapshot, ExecutionSource +from .execution import ExecutionStatus as AgentExecutionStatus +from .mission import Mission, MissionPriority, MissionStatus from .workspace_files import WorkspaceFile, WorkspaceStoredFile __all__ = [ @@ -106,4 +110,14 @@ "SkillVersion", "SkillVersionFile", "PlatformToken", + "Mission", + "MissionStatus", + "MissionPriority", + "AgentProfile", + "AgentStatus", + "Execution", + "ExecutionEvent", + "ExecutionSnapshot", + "ExecutionSource", + "AgentExecutionStatus", ] diff --git a/backend/app/models/agent_profile.py b/backend/app/models/agent_profile.py new file mode 100644 index 000000000..50322418b --- /dev/null +++ b/backend/app/models/agent_profile.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import enum +import uuid +from typing import Optional + +from sqlalchemy import Enum, ForeignKey, Index, Integer, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import BaseModel + + +class AgentStatus(str, enum.Enum): + IDLE = "idle" + WORKING = "working" + BLOCKED = "blocked" + ERROR = "error" + OFFLINE = "offline" + + +class AgentProfile(BaseModel): + __tablename__ = "agent_profiles" + + workspace_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + avatar: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + runtime_type: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[AgentStatus] = mapped_column( + Enum(AgentStatus, values_callable=lambda e: [m.value for m in e], name="agentstatus"), + nullable=False, + default=AgentStatus.OFFLINE, + ) + max_concurrent_tasks: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + skill_ids: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True) + instructions: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + custom_env: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + runtime_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + visibility: Mapped[str] = mapped_column(String(50), nullable=False, default="workspace") + + __table_args__ = ( + Index("agent_profiles_workspace_idx", "workspace_id"), + Index("agent_profiles_workspace_status_idx", "workspace_id", "status"), + ) diff --git a/backend/app/models/execution.py b/backend/app/models/execution.py new file mode 100644 index 000000000..97d284243 --- /dev/null +++ b/backend/app/models/execution.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import enum +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, Index, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.utils.datetime import utc_now + +from .base import BaseModel, TimestampMixin + + +class ExecutionStatus(str, enum.Enum): + QUEUED = "queued" + DISPATCHED = "dispatched" + RUNNING = "running" + INTERRUPT_WAIT = "interrupt_wait" + APPROVAL_WAIT = "approval_wait" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ExecutionSource(str, enum.Enum): + MISSION = "mission" + CHAT = "chat" + GRAPH = "graph" + COORDINATOR = "coordinator" + API = "api" + + +class Execution(BaseModel): + __tablename__ = "executions" + + workspace_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[str] = mapped_column( + String(255), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + ) + + source: Mapped[ExecutionSource] = mapped_column( + Enum(ExecutionSource, values_callable=lambda e: [m.value for m in e], name="executionsource"), + nullable=False, + ) + source_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + + status: Mapped[ExecutionStatus] = mapped_column( + Enum(ExecutionStatus, values_callable=lambda e: [m.value for m in e], name="executionstatus_v2"), + nullable=False, + default=ExecutionStatus.QUEUED, + ) + title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + mission_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("missions.id", ondelete="SET NULL"), + nullable=True, + ) + agent_profile_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("agent_profiles.id", ondelete="SET NULL"), + nullable=True, + ) + parent_execution_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("executions.id", ondelete="SET NULL"), + nullable=True, + ) + + result_summary: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + error_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + runtime_type: Mapped[str] = mapped_column(String(50), nullable=False) + runtime_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + container_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + + started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + last_heartbeat_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + + last_seq: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + + prior_session_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + session_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + work_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + __table_args__ = ( + Index("executions_workspace_status_idx", "workspace_id", "status"), + Index("executions_mission_idx", "mission_id"), + Index("executions_agent_profile_idx", "agent_profile_id"), + Index("executions_parent_idx", "parent_execution_id"), + Index("executions_user_created_idx", "user_id", "created_at"), + ) + + +class ExecutionEvent(BaseModel): + __tablename__ = "execution_events" + + execution_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("executions.id", ondelete="CASCADE"), + nullable=False, + ) + seq: Mapped[int] = mapped_column(BigInteger, nullable=False) + event_type: Mapped[str] = mapped_column(String(100), nullable=False) + payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + trace_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True) + observation_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True) + parent_observation_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True) + + __table_args__ = ( + UniqueConstraint("execution_id", "seq", name="uq_execution_events_exec_seq"), + Index("execution_events_exec_created_idx", "execution_id", "created_at"), + ) + + +class ExecutionSnapshot(Base, TimestampMixin): + __tablename__ = "execution_snapshots" + + execution_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("executions.id", ondelete="CASCADE"), + primary_key=True, + ) + last_seq: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(100), nullable=False) + projection: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) diff --git a/backend/app/models/mission.py b/backend/app/models/mission.py new file mode 100644 index 000000000..700ff30b0 --- /dev/null +++ b/backend/app/models/mission.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import enum +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, Enum, Float, ForeignKey, Index, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from .base import BaseModel + + +class MissionStatus(str, enum.Enum): + BACKLOG = "backlog" + TODO = "todo" + IN_PROGRESS = "in_progress" + IN_REVIEW = "in_review" + DONE = "done" + BLOCKED = "blocked" + CANCELLED = "cancelled" + + +class MissionPriority(str, enum.Enum): + NONE = "none" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + URGENT = "urgent" + + +class Mission(BaseModel): + __tablename__ = "missions" + + workspace_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + objective: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + status: Mapped[MissionStatus] = mapped_column( + Enum(MissionStatus, values_callable=lambda e: [m.value for m in e], name="missionstatus"), + nullable=False, + default=MissionStatus.BACKLOG, + ) + priority: Mapped[MissionPriority] = mapped_column( + Enum(MissionPriority, values_callable=lambda e: [m.value for m in e], name="missionpriority"), + nullable=False, + default=MissionPriority.NONE, + ) + + assignee_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + assignee_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True) + + creator_id: Mapped[str] = mapped_column( + String(255), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + ) + parent_mission_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("missions.id", ondelete="SET NULL"), + nullable=True, + ) + current_execution_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), nullable=True + ) + + due_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + position: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + tags: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True) + + __table_args__ = ( + Index("missions_workspace_status_idx", "workspace_id", "status"), + Index("missions_assignee_idx", "assignee_type", "assignee_id"), + Index("missions_creator_idx", "creator_id", "created_at"), + ) diff --git a/backend/tests/test_models/__init__.py b/backend/tests/test_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_models/conftest.py b/backend/tests/test_models/conftest.py new file mode 100644 index 000000000..3f9c2c8fe --- /dev/null +++ b/backend/tests/test_models/conftest.py @@ -0,0 +1,3 @@ +import os + +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests") diff --git a/backend/tests/test_models/test_execution.py b/backend/tests/test_models/test_execution.py new file mode 100644 index 000000000..fcd41c5c1 --- /dev/null +++ b/backend/tests/test_models/test_execution.py @@ -0,0 +1,29 @@ +import os +import uuid + +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests") + +from app.models.execution import Execution, ExecutionStatus, ExecutionSource + + +def test_execution_column_defaults(): + """Verify column default callables are wired to the expected enum values.""" + status_col = Execution.__table__.c.status + last_seq_col = Execution.__table__.c.last_seq + + assert status_col.default.arg == ExecutionStatus.QUEUED + assert last_seq_col.default.arg == 0 + + +def test_execution_explicit_values(): + e = Execution( + workspace_id=uuid.uuid4(), + user_id="user-1", + source=ExecutionSource.MISSION, + runtime_type="claude_code", + status=ExecutionStatus.RUNNING, + last_seq=42, + ) + assert e.status == ExecutionStatus.RUNNING + assert e.last_seq == 42 + assert e.source == ExecutionSource.MISSION diff --git a/backend/tests/test_models/test_mission.py b/backend/tests/test_models/test_mission.py new file mode 100644 index 000000000..008f416f7 --- /dev/null +++ b/backend/tests/test_models/test_mission.py @@ -0,0 +1,32 @@ +import os +import uuid + +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests") + +from app.models.mission import Mission, MissionStatus, MissionPriority + + +def test_mission_column_defaults(): + """Verify column default callables are wired to the expected enum values.""" + status_col = Mission.__table__.c.status + priority_col = Mission.__table__.c.priority + position_col = Mission.__table__.c.position + + assert status_col.default.arg == MissionStatus.BACKLOG + assert priority_col.default.arg == MissionPriority.NONE + assert position_col.default.arg == 0.0 + + +def test_mission_explicit_values(): + m = Mission( + workspace_id=uuid.uuid4(), + title="Test APK audit", + creator_id="user-1", + status=MissionStatus.IN_PROGRESS, + priority=MissionPriority.HIGH, + position=1.5, + ) + assert m.status == MissionStatus.IN_PROGRESS + assert m.priority == MissionPriority.HIGH + assert m.position == 1.5 + assert m.title == "Test APK audit" From e5273524ea0c4eeea550d6ef60aa9b186b4442f1 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 14:48:58 +0800 Subject: [PATCH 02/69] feat: add RuntimeProvider abstraction and Claude Code provider Introduces the cli_backends package with unified RuntimeProvider protocol, ContainerProcessBridge for docker exec streaming, ClaudeCodeProvider for parsing Claude Code NDJSON stream-json output, and a RuntimeProviderRegistry singleton. Includes 13 unit tests covering data types, event parsing, registry lookup, and async session operations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/core/agent/cli_backends/__init__.py | 10 + backend/app/core/agent/cli_backends/base.py | 67 +++++++ .../core/agent/cli_backends/claude_code.py | 158 +++++++++++++++ .../agent/cli_backends/container_bridge.py | 32 +++ .../app/core/agent/cli_backends/registry.py | 30 +++ backend/tests/test_core/__init__.py | 0 backend/tests/test_core/conftest.py | 3 + backend/tests/test_core/test_cli_backends.py | 187 ++++++++++++++++++ 8 files changed, 487 insertions(+) create mode 100644 backend/app/core/agent/cli_backends/__init__.py create mode 100644 backend/app/core/agent/cli_backends/base.py create mode 100644 backend/app/core/agent/cli_backends/claude_code.py create mode 100644 backend/app/core/agent/cli_backends/container_bridge.py create mode 100644 backend/app/core/agent/cli_backends/registry.py create mode 100644 backend/tests/test_core/__init__.py create mode 100644 backend/tests/test_core/conftest.py create mode 100644 backend/tests/test_core/test_cli_backends.py diff --git a/backend/app/core/agent/cli_backends/__init__.py b/backend/app/core/agent/cli_backends/__init__.py new file mode 100644 index 000000000..df513fe02 --- /dev/null +++ b/backend/app/core/agent/cli_backends/__init__.py @@ -0,0 +1,10 @@ +from .base import CLIMessage, CLIResult, RuntimeProvider, RuntimeSession +from .registry import RuntimeProviderRegistry + +__all__ = [ + "CLIMessage", + "CLIResult", + "RuntimeProvider", + "RuntimeSession", + "RuntimeProviderRegistry", +] diff --git a/backend/app/core/agent/cli_backends/base.py b/backend/app/core/agent/cli_backends/base.py new file mode 100644 index 000000000..002ece820 --- /dev/null +++ b/backend/app/core/agent/cli_backends/base.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import AsyncIterator, Awaitable, Callable, Protocol + + +@dataclass +class CLIMessage: + type: str # "text" | "thinking" | "tool_use" | "tool_result" | "error" | "artifact" + content: str = "" + tool: str = "" + call_id: str = "" + input: dict | None = None + output: str = "" + + +@dataclass +class CLIResult: + status: str # "completed" | "failed" | "timeout" | "blocked" + output: str = "" + error: str = "" + session_id: str = "" + branch_name: str = "" + usage: dict | None = None + + +@dataclass +class RuntimeSession: + messages: asyncio.Queue[CLIMessage | None] + result: asyncio.Future[CLIResult] + _inject_fn: Callable[[str], Awaitable[None]] | None = None + _cancel_fn: Callable[[], Awaitable[None]] | None = None + _drain_task: asyncio.Task | None = None + + async def inject_message(self, message: str) -> None: + if self._inject_fn: + await self._inject_fn(message) + + async def cancel(self) -> None: + if self._cancel_fn: + await self._cancel_fn() + if self._drain_task: + self._drain_task.cancel() + + async def iter_messages(self) -> AsyncIterator[CLIMessage]: + while True: + msg = await self.messages.get() + if msg is None: + break + yield msg + + +class RuntimeProvider(Protocol): + provider_type: str + + async def execute( + self, + prompt: str, + *, + container_id: str, + cwd: str | None = None, + model: str | None = None, + timeout: int = 7200, + resume_session_id: str | None = None, + env: dict[str, str] | None = None, + ) -> RuntimeSession: ... diff --git a/backend/app/core/agent/cli_backends/claude_code.py b/backend/app/core/agent/cli_backends/claude_code.py new file mode 100644 index 000000000..2eb23d9ff --- /dev/null +++ b/backend/app/core/agent/cli_backends/claude_code.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import asyncio +import json + +from loguru import logger + +from .base import CLIMessage, CLIResult, RuntimeSession +from .container_bridge import ContainerProcessBridge + + +class ClaudeCodeProvider: + provider_type = "claude_code" + + def __init__(self, executable_path: str = "claude"): + self.executable_path = executable_path + self.bridge = ContainerProcessBridge() + + async def execute( + self, + prompt: str, + *, + container_id: str, + cwd: str | None = None, + model: str | None = None, + timeout: int = 7200, + resume_session_id: str | None = None, + env: dict[str, str] | None = None, + ) -> RuntimeSession: + cmd = [ + self.executable_path, + "--output-format", "stream-json", + "--verbose", + "--max-turns", "200", + ] + if model: + cmd.extend(["--model", model]) + if resume_session_id: + cmd.extend(["--resume", resume_session_id]) + else: + cmd.extend(["--print", prompt]) + + process = await self.bridge.exec_streaming( + container_id, cmd, env=env, workdir=cwd, + ) + + queue: asyncio.Queue[CLIMessage | None] = asyncio.Queue(maxsize=512) + loop = asyncio.get_event_loop() + result_future: asyncio.Future[CLIResult] = loop.create_future() + + drain_task = asyncio.create_task( + self._drain(process, queue, result_future, timeout), + name=f"claude-drain-{container_id[:12]}", + ) + + async def inject(message: str) -> None: + if process.stdin and not process.stdin.is_closing(): + process.stdin.write(f"{message}\n".encode()) + await process.stdin.drain() + + async def cancel() -> None: + process.terminate() + + return RuntimeSession( + messages=queue, + result=result_future, + _inject_fn=inject, + _cancel_fn=cancel, + _drain_task=drain_task, + ) + + async def _drain( + self, + process: asyncio.subprocess.Process, + queue: asyncio.Queue[CLIMessage | None], + result_future: asyncio.Future[CLIResult], + timeout: int, + ) -> None: + accumulated_text: list[str] = [] + session_id = "" + usage: dict = {} + + try: + async with asyncio.timeout(timeout): + async for raw_line in process.stdout: + line = raw_line.decode().strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + for msg in self._parse_event(event): + if msg.type == "text": + accumulated_text.append(msg.content) + await queue.put(msg) + + if event.get("type") == "result": + result_data = event.get("result", {}) + session_id = result_data.get("session_id", "") + if "usage" in event: + usage = event["usage"] + + except TimeoutError: + if not result_future.done(): + result_future.set_result(CLIResult(status="timeout", error="Agent timed out")) + except Exception as e: + logger.error(f"Claude drain error: {e}") + if not result_future.done(): + result_future.set_result(CLIResult(status="failed", error=str(e))) + finally: + if not result_future.done(): + exit_code = await process.wait() + if exit_code == 0 or accumulated_text: + result_future.set_result(CLIResult( + status="completed", + output="\n".join(accumulated_text), + session_id=session_id, + usage=usage, + )) + else: + stderr_bytes = await process.stderr.read() if process.stderr else b"" + result_future.set_result(CLIResult( + status="failed", + error=f"Exit code {exit_code}: {stderr_bytes.decode()[:2000]}", + )) + await queue.put(None) + + def _parse_event(self, event: dict) -> list[CLIMessage]: + messages: list[CLIMessage] = [] + event_type = event.get("type", "") + + if event_type == "assistant" and "message" in event: + msg = event["message"] + for block in msg.get("content", []): + block_type = block.get("type", "") + if block_type == "text": + messages.append(CLIMessage(type="text", content=block.get("text", ""))) + elif block_type == "tool_use": + messages.append(CLIMessage( + type="tool_use", + tool=block.get("name", ""), + call_id=block.get("id", ""), + input=block.get("input"), + )) + elif block_type == "thinking": + messages.append(CLIMessage(type="thinking", content=block.get("thinking", ""))) + + elif event_type == "tool_result": + messages.append(CLIMessage( + type="tool_result", + tool=event.get("tool", ""), + call_id=event.get("call_id", ""), + output=str(event.get("output", ""))[:8192], + )) + + return messages diff --git a/backend/app/core/agent/cli_backends/container_bridge.py b/backend/app/core/agent/cli_backends/container_bridge.py new file mode 100644 index 000000000..dfc252e22 --- /dev/null +++ b/backend/app/core/agent/cli_backends/container_bridge.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import asyncio + +from loguru import logger + + +class ContainerProcessBridge: + async def exec_streaming( + self, + container_id: str, + cmd: list[str], + env: dict[str, str] | None = None, + workdir: str | None = None, + ) -> asyncio.subprocess.Process: + docker_cmd = ["docker", "exec", "-i"] + if workdir: + docker_cmd.extend(["-w", workdir]) + if env: + for k, v in env.items(): + docker_cmd.extend(["-e", f"{k}={v}"]) + docker_cmd.append(container_id) + docker_cmd.extend(cmd) + + logger.debug(f"container exec: {' '.join(docker_cmd[:6])}...") + + return await asyncio.create_subprocess_exec( + *docker_cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) diff --git a/backend/app/core/agent/cli_backends/registry.py b/backend/app/core/agent/cli_backends/registry.py new file mode 100644 index 000000000..f67dc2c82 --- /dev/null +++ b/backend/app/core/agent/cli_backends/registry.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from loguru import logger + +from .base import RuntimeProvider + + +class RuntimeProviderRegistry: + def __init__(self) -> None: + self._providers: dict[str, RuntimeProvider] = {} + + def register(self, provider: RuntimeProvider) -> None: + self._providers[provider.provider_type] = provider + logger.info(f"Registered runtime provider: {provider.provider_type}") + + def get(self, provider_type: str) -> RuntimeProvider: + if provider_type not in self._providers: + raise ValueError(f"Unknown runtime provider: {provider_type}") + return self._providers[provider_type] + + def list_providers(self) -> list[str]: + return list(self._providers.keys()) + + +runtime_registry = RuntimeProviderRegistry() + + +def init_providers() -> None: + from .claude_code import ClaudeCodeProvider + runtime_registry.register(ClaudeCodeProvider()) diff --git a/backend/tests/test_core/__init__.py b/backend/tests/test_core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/test_core/conftest.py b/backend/tests/test_core/conftest.py new file mode 100644 index 000000000..3f9c2c8fe --- /dev/null +++ b/backend/tests/test_core/conftest.py @@ -0,0 +1,3 @@ +import os + +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests") diff --git a/backend/tests/test_core/test_cli_backends.py b/backend/tests/test_core/test_cli_backends.py new file mode 100644 index 000000000..9fd780e44 --- /dev/null +++ b/backend/tests/test_core/test_cli_backends.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from app.core.agent.cli_backends.base import CLIMessage, CLIResult, RuntimeSession +from app.core.agent.cli_backends.claude_code import ClaudeCodeProvider +from app.core.agent.cli_backends.registry import RuntimeProviderRegistry + + +def test_cli_message_defaults(): + msg = CLIMessage(type="text", content="hello") + assert msg.type == "text" + assert msg.content == "hello" + assert msg.tool == "" + assert msg.input is None + + +def test_cli_result_defaults(): + result = CLIResult(status="completed", output="done") + assert result.status == "completed" + assert result.session_id == "" + + +def test_registry_register_and_get(): + reg = RuntimeProviderRegistry() + provider = ClaudeCodeProvider() + reg.register(provider) + assert reg.get("claude_code") is provider + assert "claude_code" in reg.list_providers() + + +def test_registry_unknown_provider(): + reg = RuntimeProviderRegistry() + with pytest.raises(ValueError, match="Unknown runtime provider"): + reg.get("nonexistent") + + +def test_claude_parse_text_event(): + provider = ClaudeCodeProvider() + event = { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Hello world"}, + ] + }, + } + messages = provider._parse_event(event) + assert len(messages) == 1 + assert messages[0].type == "text" + assert messages[0].content == "Hello world" + + +def test_claude_parse_tool_use_event(): + provider = ClaudeCodeProvider() + event = { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "name": "Bash", + "id": "call-123", + "input": {"command": "ls"}, + }, + ] + }, + } + messages = provider._parse_event(event) + assert len(messages) == 1 + assert messages[0].type == "tool_use" + assert messages[0].tool == "Bash" + assert messages[0].call_id == "call-123" + assert messages[0].input == {"command": "ls"} + + +def test_claude_parse_thinking_event(): + provider = ClaudeCodeProvider() + event = { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Let me analyze..."}, + ] + }, + } + messages = provider._parse_event(event) + assert len(messages) == 1 + assert messages[0].type == "thinking" + assert messages[0].content == "Let me analyze..." + + +def test_claude_parse_mixed_content(): + provider = ClaudeCodeProvider() + event = { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "hmm"}, + {"type": "text", "text": "I'll run a scan"}, + {"type": "tool_use", "name": "Bash", "id": "c1", "input": {"command": "nmap"}}, + ] + }, + } + messages = provider._parse_event(event) + assert len(messages) == 3 + assert messages[0].type == "thinking" + assert messages[1].type == "text" + assert messages[2].type == "tool_use" + + +def test_claude_parse_tool_result_event(): + provider = ClaudeCodeProvider() + event = { + "type": "tool_result", + "tool": "Bash", + "call_id": "c1", + "output": "scan complete", + } + messages = provider._parse_event(event) + assert len(messages) == 1 + assert messages[0].type == "tool_result" + assert messages[0].tool == "Bash" + assert messages[0].output == "scan complete" + + +def test_claude_parse_unknown_event(): + provider = ClaudeCodeProvider() + event = {"type": "unknown_event"} + messages = provider._parse_event(event) + assert len(messages) == 0 + + +@pytest.mark.asyncio +async def test_runtime_session_iter_messages(): + queue: asyncio.Queue[CLIMessage | None] = asyncio.Queue() + loop = asyncio.get_event_loop() + future: asyncio.Future[CLIResult] = loop.create_future() + + session = RuntimeSession(messages=queue, result=future) + + await queue.put(CLIMessage(type="text", content="hello")) + await queue.put(CLIMessage(type="text", content="world")) + await queue.put(None) + + collected = [] + async for msg in session.iter_messages(): + collected.append(msg.content) + + assert collected == ["hello", "world"] + + +@pytest.mark.asyncio +async def test_runtime_session_cancel(): + queue: asyncio.Queue[CLIMessage | None] = asyncio.Queue() + loop = asyncio.get_event_loop() + future: asyncio.Future[CLIResult] = loop.create_future() + + cancelled = False + async def mock_cancel(): + nonlocal cancelled + cancelled = True + + session = RuntimeSession( + messages=queue, result=future, _cancel_fn=mock_cancel, + ) + await session.cancel() + assert cancelled + + +@pytest.mark.asyncio +async def test_runtime_session_inject(): + queue: asyncio.Queue[CLIMessage | None] = asyncio.Queue() + loop = asyncio.get_event_loop() + future: asyncio.Future[CLIResult] = loop.create_future() + + injected = [] + async def mock_inject(msg: str): + injected.append(msg) + + session = RuntimeSession( + messages=queue, result=future, _inject_fn=mock_inject, + ) + await session.inject_message("hello agent") + assert injected == ["hello agent"] From f19cdf051e9fc01ad10bffd3f148238a34e61eb5 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:08:17 +0800 Subject: [PATCH 03/69] feat: add ExecutionService, ExecutionRepository, and snapshot reducer Event-sourced execution lifecycle with row-lock seq increment, snapshot projection via apply_execution_event reducer, and 20 unit tests covering all event types plus immutability and full lifecycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/repositories/execution.py | 99 +++++++ backend/app/services/execution_reducer.py | 141 +++++++++ backend/app/services/execution_service.py | 231 +++++++++++++++ .../tests/test_core/test_execution_reducer.py | 279 ++++++++++++++++++ 4 files changed, 750 insertions(+) create mode 100644 backend/app/repositories/execution.py create mode 100644 backend/app/services/execution_reducer.py create mode 100644 backend/app/services/execution_service.py create mode 100644 backend/tests/test_core/test_execution_reducer.py diff --git a/backend/app/repositories/execution.py b/backend/app/repositories/execution.py new file mode 100644 index 000000000..efd6cc5cc --- /dev/null +++ b/backend/app/repositories/execution.py @@ -0,0 +1,99 @@ +""" +Execution repository helpers. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional, Sequence + +from sqlalchemy import and_, desc, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.execution import Execution, ExecutionEvent, ExecutionSnapshot, ExecutionStatus + +from .base import BaseRepository + + +class ExecutionRepository(BaseRepository[Execution]): + def __init__(self, db: AsyncSession): + super().__init__(Execution, db) + + async def get_by_id_and_user(self, execution_id: uuid.UUID, user_id: str) -> Optional[Execution]: + result = await self.db.execute( + select(Execution).where( + Execution.id == execution_id, + Execution.user_id == user_id, + ) + ) + return result.scalar_one_or_none() + + async def get_for_update(self, execution_id: uuid.UUID, user_id: Optional[str] = None) -> Optional[Execution]: + query = select(Execution).where(Execution.id == execution_id) + if user_id is not None: + query = query.where(Execution.user_id == user_id) + result = await self.db.execute(query.with_for_update()) + return result.scalar_one_or_none() + + async def get_snapshot(self, execution_id: uuid.UUID) -> Optional[ExecutionSnapshot]: + result = await self.db.execute( + select(ExecutionSnapshot).where(ExecutionSnapshot.execution_id == execution_id) + ) + return result.scalar_one_or_none() + + async def list_events_after( + self, execution_id: uuid.UUID, after_seq: int = 0, limit: int = 500 + ) -> Sequence[ExecutionEvent]: + result = await self.db.execute( + select(ExecutionEvent) + .where( + ExecutionEvent.execution_id == execution_id, + ExecutionEvent.seq > after_seq, + ) + .order_by(ExecutionEvent.seq.asc()) + .limit(limit) + ) + return result.scalars().all() + + async def list_by_workspace( + self, + *, + workspace_id: uuid.UUID, + user_id: Optional[str] = None, + status: Optional[str] = None, + source: Optional[str] = None, + mission_id: Optional[uuid.UUID] = None, + limit: int = 50, + ) -> Sequence[Execution]: + query = select(Execution).where(Execution.workspace_id == workspace_id) + if user_id: + query = query.where(Execution.user_id == user_id) + if status: + query = query.where(Execution.status == status) + if source: + query = query.where(Execution.source == source) + if mission_id: + query = query.where(Execution.mission_id == mission_id) + result = await self.db.execute(query.order_by(desc(Execution.created_at)).limit(limit)) + return result.scalars().all() + + async def list_recoverable_stale( + self, *, stale_before: datetime + ) -> Sequence[Execution]: + recoverable = (ExecutionStatus.QUEUED, ExecutionStatus.DISPATCHED, ExecutionStatus.RUNNING) + result = await self.db.execute( + select(Execution) + .where( + Execution.status.in_(recoverable), + or_( + and_( + Execution.last_heartbeat_at.is_(None), + Execution.updated_at < stale_before, + ), + Execution.last_heartbeat_at < stale_before, + ), + ) + .order_by(desc(Execution.updated_at)) + ) + return result.scalars().all() diff --git a/backend/app/services/execution_reducer.py b/backend/app/services/execution_reducer.py new file mode 100644 index 000000000..55da86794 --- /dev/null +++ b/backend/app/services/execution_reducer.py @@ -0,0 +1,141 @@ +""" +Execution snapshot reducer. + +Applies execution events to build a projection of the current execution state. +Each event type updates the projection dict immutably (via deepcopy). +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +def _deepcopy_projection(projection: dict[str, Any] | None) -> dict[str, Any]: + if projection is not None: + return deepcopy(projection) + return { + "version": 1, + "status": "queued", + "source": None, + "mission_id": None, + "agent_profile_id": None, + "container_id": None, + "session_id": None, + "messages": [], + "tool_calls": [], + "artifacts": [], + "meta": {}, + } + + +def make_initial_projection(payload: dict[str, Any], status: str) -> dict[str, Any]: + projection = _deepcopy_projection(None) + projection["status"] = status + projection["source"] = payload.get("source") + projection["mission_id"] = payload.get("mission_id") + projection["agent_profile_id"] = payload.get("agent_profile_id") + return projection + + +def apply_execution_event( + projection: dict[str, Any] | None, + *, + event_type: str, + payload: dict[str, Any], + status: str, +) -> dict[str, Any]: + next_proj = _deepcopy_projection(projection) + next_proj["status"] = status + + if event_type == "execution_started": + next_proj["container_id"] = payload.get("container_id") + next_proj["session_id"] = payload.get("session_id") + return next_proj + + if event_type == "prompt_sent": + msg = payload.get("message") + if isinstance(msg, dict): + next_proj["messages"].append(msg) + return next_proj + + if event_type == "assistant_text": + msg = payload.get("message") + if isinstance(msg, dict): + next_proj["messages"].append(msg) + elif isinstance(payload.get("content"), str): + next_proj["messages"].append({ + "role": "assistant", + "content": payload["content"], + }) + return next_proj + + if event_type == "content_delta": + delta = payload.get("delta", "") + message_id = payload.get("message_id") + if delta and next_proj["messages"]: + last = next_proj["messages"][-1] + if last.get("role") == "assistant" and ( + not message_id or last.get("id") == message_id + ): + last["content"] = f"{last.get('content', '')}{delta}" + return next_proj + + if event_type == "tool_use_start": + tool = payload.get("tool") + if isinstance(tool, dict): + next_proj["tool_calls"].append(tool) + else: + next_proj["tool_calls"].append({ + "name": payload.get("tool_name", ""), + "call_id": payload.get("call_id", ""), + "input": payload.get("input"), + "status": "running", + }) + return next_proj + + if event_type == "tool_use_end": + call_id = payload.get("call_id") + for tc in reversed(next_proj["tool_calls"]): + if call_id and tc.get("call_id") != call_id: + continue + if not call_id and tc.get("status") != "running": + continue + tc["status"] = "completed" + tc["output"] = payload.get("output", "") + break + return next_proj + + if event_type == "thinking": + meta = next_proj["meta"] + meta["last_thinking"] = payload.get("content", "") + return next_proj + + if event_type == "artifact_created": + artifact = payload.get("artifact") + if isinstance(artifact, dict): + next_proj["artifacts"].append(artifact) + return next_proj + + if event_type == "approval_requested": + next_proj["meta"]["pending_approval"] = payload + return next_proj + + if event_type == "approval_resolved": + next_proj["meta"].pop("pending_approval", None) + return next_proj + + if event_type == "error": + next_proj["meta"]["error"] = payload.get("message", "") + return next_proj + + if event_type == "execution_completed": + next_proj["meta"]["completed"] = True + if "result_summary" in payload: + next_proj["meta"]["result_summary"] = payload["result_summary"] + return next_proj + + if event_type == "heartbeat": + return next_proj + + return next_proj diff --git a/backend/app/services/execution_service.py b/backend/app/services/execution_service.py new file mode 100644 index 000000000..17743d5e0 --- /dev/null +++ b/backend/app/services/execution_service.py @@ -0,0 +1,231 @@ +""" +Service layer for CLI agent executions. +""" + +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.execution import ( + Execution, + ExecutionEvent, + ExecutionSnapshot, + ExecutionSource, + ExecutionStatus, +) +from app.repositories.execution import ExecutionRepository +from app.services.execution_reducer import apply_execution_event, make_initial_projection +from app.utils.datetime import utc_now + + +class ExecutionService: + """Orchestrates execution lifecycle, event sourcing, and snapshot management.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.repo = ExecutionRepository(db) + + async def create_execution( + self, + *, + workspace_id: uuid.UUID, + user_id: str, + source: ExecutionSource, + runtime_type: str, + source_id: Optional[str] = None, + title: Optional[str] = None, + mission_id: Optional[uuid.UUID] = None, + agent_profile_id: Optional[uuid.UUID] = None, + parent_execution_id: Optional[uuid.UUID] = None, + runtime_config: Optional[dict[str, Any]] = None, + ) -> Execution: + execution = Execution( + workspace_id=workspace_id, + user_id=user_id, + source=source, + source_id=source_id, + status=ExecutionStatus.QUEUED, + title=title, + mission_id=mission_id, + agent_profile_id=agent_profile_id, + parent_execution_id=parent_execution_id, + runtime_type=runtime_type, + runtime_config=runtime_config, + last_heartbeat_at=utc_now(), + ) + self.db.add(execution) + await self.db.flush() + + snapshot = ExecutionSnapshot( + execution_id=execution.id, + last_seq=0, + status=execution.status.value, + projection=make_initial_projection( + { + "source": source.value, + "mission_id": str(mission_id) if mission_id else None, + "agent_profile_id": str(agent_profile_id) if agent_profile_id else None, + }, + execution.status.value, + ), + ) + self.db.add(snapshot) + await self.db.commit() + await self.db.refresh(execution) + return execution + + async def get_execution(self, execution_id: uuid.UUID, user_id: str) -> Optional[Execution]: + return await self.repo.get_by_id_and_user(execution_id, user_id) + + async def get_snapshot(self, execution_id: uuid.UUID, user_id: str) -> Optional[ExecutionSnapshot]: + execution = await self.get_execution(execution_id, user_id) + if not execution: + return None + return await self.repo.get_snapshot(execution_id) + + async def list_events_after( + self, execution_id: uuid.UUID, user_id: str, after_seq: int = 0, limit: int = 500 + ) -> list[ExecutionEvent]: + execution = await self.get_execution(execution_id, user_id) + if not execution: + return [] + return list(await self.repo.list_events_after(execution_id, after_seq=after_seq, limit=limit)) + + async def list_executions( + self, + *, + workspace_id: uuid.UUID, + user_id: Optional[str] = None, + status: Optional[str] = None, + source: Optional[str] = None, + mission_id: Optional[uuid.UUID] = None, + limit: int = 50, + ) -> list[Execution]: + return list( + await self.repo.list_by_workspace( + workspace_id=workspace_id, + user_id=user_id, + status=status, + source=source, + mission_id=mission_id, + limit=limit, + ) + ) + + async def mark_status( + self, + *, + execution_id: uuid.UUID, + user_id: Optional[str] = None, + status: ExecutionStatus, + container_id: Optional[str] = None, + session_id: Optional[str] = None, + error_code: Optional[str] = None, + error_message: Optional[str] = None, + result_summary: Optional[dict[str, Any]] = None, + ) -> Optional[Execution]: + execution = await self.repo.get_for_update(execution_id, user_id=user_id) + if not execution: + return None + + now = utc_now() + execution.status = status + execution.error_code = error_code + execution.error_message = error_message + execution.last_heartbeat_at = now + + if container_id is not None: + execution.container_id = container_id + if session_id is not None: + execution.session_id = session_id + if result_summary is not None: + execution.result_summary = result_summary + + if status == ExecutionStatus.RUNNING and not execution.started_at: + execution.started_at = now + if status in {ExecutionStatus.COMPLETED, ExecutionStatus.FAILED, ExecutionStatus.CANCELLED}: + execution.finished_at = now + + snapshot = await self.repo.get_snapshot(execution_id) + if snapshot: + snapshot.status = status.value + projection = dict(snapshot.projection or {}) + projection["status"] = status.value + if error_message: + meta = dict(projection.get("meta") or {}) + meta["error"] = error_message + projection["meta"] = meta + snapshot.projection = projection + + await self.db.commit() + return execution + + async def append_event( + self, + *, + execution_id: uuid.UUID, + event_type: str, + payload: dict[str, Any], + trace_id: Optional[uuid.UUID] = None, + observation_id: Optional[uuid.UUID] = None, + parent_observation_id: Optional[uuid.UUID] = None, + commit: bool = True, + ) -> ExecutionEvent: + execution = await self.repo.get_for_update(execution_id) + if not execution: + raise ValueError(f"Execution not found: {execution_id}") + + next_seq = int(execution.last_seq) + 1 + event = ExecutionEvent( + execution_id=execution.id, + seq=next_seq, + event_type=event_type, + payload=payload, + trace_id=trace_id, + observation_id=observation_id, + parent_observation_id=parent_observation_id, + ) + self.db.add(event) + execution.last_seq = next_seq + execution.last_heartbeat_at = utc_now() + + snapshot = await self.repo.get_snapshot(execution.id) + if snapshot is None: + snapshot = ExecutionSnapshot( + execution_id=execution.id, + last_seq=0, + status=execution.status.value, + projection={}, + ) + self.db.add(snapshot) + + snapshot.projection = apply_execution_event( + snapshot.projection, + event_type=event_type, + payload=payload, + status=execution.status.value, + ) + snapshot.last_seq = next_seq + snapshot.status = execution.status.value + + await self.db.flush() + if commit: + await self.db.commit() + return event + + async def touch_heartbeat( + self, *, execution_id: uuid.UUID + ) -> Optional[Execution]: + execution = await self.repo.get_for_update(execution_id) + if not execution: + return None + active = {ExecutionStatus.QUEUED, ExecutionStatus.DISPATCHED, ExecutionStatus.RUNNING} + if execution.status not in active: + return execution + execution.last_heartbeat_at = utc_now() + await self.db.commit() + return execution diff --git a/backend/tests/test_core/test_execution_reducer.py b/backend/tests/test_core/test_execution_reducer.py new file mode 100644 index 000000000..7082273a0 --- /dev/null +++ b/backend/tests/test_core/test_execution_reducer.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import pytest + +from app.services.execution_reducer import apply_execution_event, make_initial_projection + + +def test_make_initial_projection(): + proj = make_initial_projection( + {"source": "mission", "mission_id": "m1", "agent_profile_id": "a1"}, + "queued", + ) + assert proj["status"] == "queued" + assert proj["source"] == "mission" + assert proj["mission_id"] == "m1" + assert proj["agent_profile_id"] == "a1" + assert proj["messages"] == [] + assert proj["tool_calls"] == [] + assert proj["artifacts"] == [] + + +def test_execution_started(): + proj = make_initial_projection({"source": "chat"}, "running") + proj = apply_execution_event( + proj, + event_type="execution_started", + payload={"container_id": "ctr-1", "session_id": "sess-1"}, + status="running", + ) + assert proj["container_id"] == "ctr-1" + assert proj["session_id"] == "sess-1" + assert proj["status"] == "running" + + +def test_prompt_sent(): + proj = make_initial_projection({}, "running") + msg = {"role": "user", "content": "Fix the bug"} + proj = apply_execution_event( + proj, event_type="prompt_sent", payload={"message": msg}, status="running", + ) + assert len(proj["messages"]) == 1 + assert proj["messages"][0]["content"] == "Fix the bug" + + +def test_assistant_text_with_message_dict(): + proj = make_initial_projection({}, "running") + msg = {"role": "assistant", "content": "On it"} + proj = apply_execution_event( + proj, event_type="assistant_text", payload={"message": msg}, status="running", + ) + assert len(proj["messages"]) == 1 + assert proj["messages"][0]["content"] == "On it" + + +def test_assistant_text_with_content_string(): + proj = make_initial_projection({}, "running") + proj = apply_execution_event( + proj, event_type="assistant_text", payload={"content": "Hello"}, status="running", + ) + assert len(proj["messages"]) == 1 + assert proj["messages"][0]["role"] == "assistant" + assert proj["messages"][0]["content"] == "Hello" + + +def test_content_delta_appends(): + proj = make_initial_projection({}, "running") + proj["messages"].append({"role": "assistant", "content": "Hel", "id": "m1"}) + proj = apply_execution_event( + proj, + event_type="content_delta", + payload={"delta": "lo", "message_id": "m1"}, + status="running", + ) + assert proj["messages"][-1]["content"] == "Hello" + + +def test_content_delta_no_messages_is_noop(): + proj = make_initial_projection({}, "running") + proj = apply_execution_event( + proj, event_type="content_delta", payload={"delta": "x"}, status="running", + ) + assert proj["messages"] == [] + + +def test_tool_use_start_with_dict(): + proj = make_initial_projection({}, "running") + tool = {"name": "Bash", "call_id": "c1", "input": {"command": "ls"}, "status": "running"} + proj = apply_execution_event( + proj, event_type="tool_use_start", payload={"tool": tool}, status="running", + ) + assert len(proj["tool_calls"]) == 1 + assert proj["tool_calls"][0]["name"] == "Bash" + + +def test_tool_use_start_with_flat_fields(): + proj = make_initial_projection({}, "running") + proj = apply_execution_event( + proj, + event_type="tool_use_start", + payload={"tool_name": "Read", "call_id": "c2", "input": {"path": "/tmp"}}, + status="running", + ) + assert len(proj["tool_calls"]) == 1 + assert proj["tool_calls"][0]["name"] == "Read" + assert proj["tool_calls"][0]["status"] == "running" + + +def test_tool_use_end(): + proj = make_initial_projection({}, "running") + proj["tool_calls"].append({"name": "Bash", "call_id": "c1", "status": "running"}) + proj = apply_execution_event( + proj, + event_type="tool_use_end", + payload={"call_id": "c1", "output": "file.txt"}, + status="running", + ) + assert proj["tool_calls"][0]["status"] == "completed" + assert proj["tool_calls"][0]["output"] == "file.txt" + + +def test_tool_use_end_no_match(): + proj = make_initial_projection({}, "running") + proj["tool_calls"].append({"name": "Bash", "call_id": "c1", "status": "completed"}) + proj = apply_execution_event( + proj, + event_type="tool_use_end", + payload={"call_id": "c99", "output": "nope"}, + status="running", + ) + # No match — original stays unchanged + assert proj["tool_calls"][0]["status"] == "completed" + assert "output" not in proj["tool_calls"][0] or proj["tool_calls"][0].get("output") != "nope" + + +def test_thinking(): + proj = make_initial_projection({}, "running") + proj = apply_execution_event( + proj, event_type="thinking", payload={"content": "analyzing..."}, status="running", + ) + assert proj["meta"]["last_thinking"] == "analyzing..." + + +def test_artifact_created(): + proj = make_initial_projection({}, "running") + artifact = {"type": "file", "path": "/app/main.py"} + proj = apply_execution_event( + proj, event_type="artifact_created", payload={"artifact": artifact}, status="running", + ) + assert len(proj["artifacts"]) == 1 + assert proj["artifacts"][0]["path"] == "/app/main.py" + + +def test_approval_requested_and_resolved(): + proj = make_initial_projection({}, "approval_wait") + proj = apply_execution_event( + proj, + event_type="approval_requested", + payload={"tool": "Bash", "command": "rm -rf /"}, + status="approval_wait", + ) + assert proj["meta"]["pending_approval"]["tool"] == "Bash" + + proj = apply_execution_event( + proj, event_type="approval_resolved", payload={"approved": True}, status="running", + ) + assert "pending_approval" not in proj["meta"] + + +def test_error_event(): + proj = make_initial_projection({}, "failed") + proj = apply_execution_event( + proj, event_type="error", payload={"message": "OOM"}, status="failed", + ) + assert proj["meta"]["error"] == "OOM" + assert proj["status"] == "failed" + + +def test_execution_completed(): + proj = make_initial_projection({}, "completed") + proj = apply_execution_event( + proj, + event_type="execution_completed", + payload={"result_summary": {"files_changed": 3}}, + status="completed", + ) + assert proj["meta"]["completed"] is True + assert proj["meta"]["result_summary"]["files_changed"] == 3 + + +def test_heartbeat_is_noop(): + proj = make_initial_projection({}, "running") + original_messages = list(proj["messages"]) + proj = apply_execution_event( + proj, event_type="heartbeat", payload={}, status="running", + ) + assert proj["messages"] == original_messages + + +def test_unknown_event_preserves_projection(): + proj = make_initial_projection({}, "running") + proj["messages"].append({"role": "user", "content": "hi"}) + proj = apply_execution_event( + proj, event_type="some_future_event", payload={"data": 1}, status="running", + ) + assert len(proj["messages"]) == 1 + assert proj["status"] == "running" + + +def test_immutability(): + """Verify that apply_execution_event does not mutate the input projection.""" + proj = make_initial_projection({}, "running") + proj["messages"].append({"role": "assistant", "content": "hi", "id": "m1"}) + original_content = proj["messages"][0]["content"] + + _ = apply_execution_event( + proj, + event_type="content_delta", + payload={"delta": " world", "message_id": "m1"}, + status="running", + ) + # Original should be untouched + assert proj["messages"][0]["content"] == original_content + + +def test_full_lifecycle(): + """Walk through a realistic sequence of events.""" + proj = make_initial_projection( + {"source": "mission", "mission_id": "m1"}, "queued", + ) + assert proj["status"] == "queued" + + proj = apply_execution_event( + proj, + event_type="execution_started", + payload={"container_id": "ctr-abc", "session_id": "s1"}, + status="running", + ) + assert proj["container_id"] == "ctr-abc" + + proj = apply_execution_event( + proj, + event_type="prompt_sent", + payload={"message": {"role": "user", "content": "Fix login bug"}}, + status="running", + ) + + proj = apply_execution_event( + proj, + event_type="assistant_text", + payload={"message": {"role": "assistant", "content": "Looking into it", "id": "a1"}}, + status="running", + ) + + proj = apply_execution_event( + proj, + event_type="tool_use_start", + payload={"tool": {"name": "Bash", "call_id": "t1", "input": {"command": "grep"}, "status": "running"}}, + status="running", + ) + + proj = apply_execution_event( + proj, + event_type="tool_use_end", + payload={"call_id": "t1", "output": "found match"}, + status="running", + ) + + proj = apply_execution_event( + proj, + event_type="execution_completed", + payload={"result_summary": {"fixed": True}}, + status="completed", + ) + + assert proj["status"] == "completed" + assert len(proj["messages"]) == 2 + assert len(proj["tool_calls"]) == 1 + assert proj["tool_calls"][0]["status"] == "completed" + assert proj["meta"]["completed"] is True From 59d511164fea4875523dfda5329bb1711dc36fa8 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:15:50 +0800 Subject: [PATCH 04/69] feat: add MissionService, AgentProfileService, and dispatch logic Repositories for Mission and AgentProfile with workspace-scoped queries, MissionService with assign_to_agent and dispatch_mission lifecycle, prompt builder for CLI agent execution, and 7 unit tests for prompt generation. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/repositories/agent_profile.py | 66 +++++ backend/app/repositories/mission.py | 81 ++++++ backend/app/services/agent_profile_service.py | 117 ++++++++ backend/app/services/mission_service.py | 264 ++++++++++++++++++ .../tests/test_core/test_mission_service.py | 103 +++++++ 5 files changed, 631 insertions(+) create mode 100644 backend/app/repositories/agent_profile.py create mode 100644 backend/app/repositories/mission.py create mode 100644 backend/app/services/agent_profile_service.py create mode 100644 backend/app/services/mission_service.py create mode 100644 backend/tests/test_core/test_mission_service.py diff --git a/backend/app/repositories/agent_profile.py b/backend/app/repositories/agent_profile.py new file mode 100644 index 000000000..9752657f8 --- /dev/null +++ b/backend/app/repositories/agent_profile.py @@ -0,0 +1,66 @@ +""" +AgentProfile repository helpers. +""" + +from __future__ import annotations + +import uuid +from typing import Optional, Sequence + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.agent_profile import AgentProfile, AgentStatus + +from .base import BaseRepository + + +class AgentProfileRepository(BaseRepository[AgentProfile]): + def __init__(self, db: AsyncSession): + super().__init__(AgentProfile, db) + + async def get_by_id_and_workspace( + self, profile_id: uuid.UUID, workspace_id: uuid.UUID + ) -> Optional[AgentProfile]: + result = await self.db.execute( + select(AgentProfile).where( + AgentProfile.id == profile_id, + AgentProfile.workspace_id == workspace_id, + ) + ) + return result.scalar_one_or_none() + + async def list_by_workspace( + self, + *, + workspace_id: uuid.UUID, + status: Optional[str] = None, + runtime_type: Optional[str] = None, + limit: int = 100, + ) -> Sequence[AgentProfile]: + query = select(AgentProfile).where(AgentProfile.workspace_id == workspace_id) + if status: + query = query.where(AgentProfile.status == status) + if runtime_type: + query = query.where(AgentProfile.runtime_type == runtime_type) + result = await self.db.execute(query.order_by(desc(AgentProfile.created_at)).limit(limit)) + return result.scalars().all() + + async def find_available( + self, *, workspace_id: uuid.UUID, runtime_type: Optional[str] = None + ) -> Sequence[AgentProfile]: + """Find agents in IDLE status that can accept new tasks.""" + query = select(AgentProfile).where( + AgentProfile.workspace_id == workspace_id, + AgentProfile.status == AgentStatus.IDLE, + ) + if runtime_type: + query = query.where(AgentProfile.runtime_type == runtime_type) + result = await self.db.execute(query.order_by(AgentProfile.created_at.asc())) + return result.scalars().all() + + async def get_for_update(self, profile_id: uuid.UUID) -> Optional[AgentProfile]: + result = await self.db.execute( + select(AgentProfile).where(AgentProfile.id == profile_id).with_for_update() + ) + return result.scalar_one_or_none() diff --git a/backend/app/repositories/mission.py b/backend/app/repositories/mission.py new file mode 100644 index 000000000..097ef8c0b --- /dev/null +++ b/backend/app/repositories/mission.py @@ -0,0 +1,81 @@ +""" +Mission repository helpers. +""" + +from __future__ import annotations + +import uuid +from typing import Optional, Sequence + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.mission import Mission, MissionStatus + +from .base import BaseRepository + + +class MissionRepository(BaseRepository[Mission]): + def __init__(self, db: AsyncSession): + super().__init__(Mission, db) + + async def get_by_id_and_workspace( + self, mission_id: uuid.UUID, workspace_id: uuid.UUID + ) -> Optional[Mission]: + result = await self.db.execute( + select(Mission).where( + Mission.id == mission_id, + Mission.workspace_id == workspace_id, + ) + ) + return result.scalar_one_or_none() + + async def get_for_update( + self, mission_id: uuid.UUID, workspace_id: Optional[uuid.UUID] = None + ) -> Optional[Mission]: + query = select(Mission).where(Mission.id == mission_id) + if workspace_id is not None: + query = query.where(Mission.workspace_id == workspace_id) + result = await self.db.execute(query.with_for_update()) + return result.scalar_one_or_none() + + async def list_by_workspace( + self, + *, + workspace_id: uuid.UUID, + status: Optional[str] = None, + creator_id: Optional[str] = None, + assignee_id: Optional[uuid.UUID] = None, + parent_mission_id: Optional[uuid.UUID] = None, + limit: int = 50, + ) -> Sequence[Mission]: + query = select(Mission).where(Mission.workspace_id == workspace_id) + if status: + query = query.where(Mission.status == status) + if creator_id: + query = query.where(Mission.creator_id == creator_id) + if assignee_id: + query = query.where(Mission.assignee_id == assignee_id) + if parent_mission_id: + query = query.where(Mission.parent_mission_id == parent_mission_id) + result = await self.db.execute( + query.order_by(Mission.position.asc(), desc(Mission.created_at)).limit(limit) + ) + return result.scalars().all() + + async def list_dispatchable( + self, *, workspace_id: uuid.UUID, limit: int = 10 + ) -> Sequence[Mission]: + """Find missions in TODO status with an agent assignee, ready for dispatch.""" + result = await self.db.execute( + select(Mission) + .where( + Mission.workspace_id == workspace_id, + Mission.status == MissionStatus.TODO, + Mission.assignee_type == "agent", + Mission.assignee_id.isnot(None), + ) + .order_by(Mission.position.asc(), Mission.created_at.asc()) + .limit(limit) + ) + return result.scalars().all() diff --git a/backend/app/services/agent_profile_service.py b/backend/app/services/agent_profile_service.py new file mode 100644 index 000000000..f8166a3d6 --- /dev/null +++ b/backend/app/services/agent_profile_service.py @@ -0,0 +1,117 @@ +""" +AgentProfile service layer. +""" + +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.agent_profile import AgentProfile, AgentStatus +from app.repositories.agent_profile import AgentProfileRepository + + +class AgentProfileService: + """Manages agent profile lifecycle.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.repo = AgentProfileRepository(db) + + async def create_profile( + self, + *, + workspace_id: uuid.UUID, + name: str, + runtime_type: str, + description: Optional[str] = None, + avatar: Optional[str] = None, + instructions: Optional[str] = None, + skill_ids: Optional[list] = None, + custom_env: Optional[dict[str, Any]] = None, + runtime_config: Optional[dict[str, Any]] = None, + max_concurrent_tasks: int = 1, + ) -> AgentProfile: + profile = AgentProfile( + workspace_id=workspace_id, + name=name, + runtime_type=runtime_type, + description=description, + avatar=avatar, + instructions=instructions, + skill_ids=skill_ids, + custom_env=custom_env, + runtime_config=runtime_config, + max_concurrent_tasks=max_concurrent_tasks, + status=AgentStatus.IDLE, + ) + self.db.add(profile) + await self.db.commit() + await self.db.refresh(profile) + logger.info(f"Created agent profile: {profile.id} ({name})") + return profile + + async def get_profile( + self, profile_id: uuid.UUID, workspace_id: uuid.UUID + ) -> Optional[AgentProfile]: + return await self.repo.get_by_id_and_workspace(profile_id, workspace_id) + + async def list_profiles( + self, + *, + workspace_id: uuid.UUID, + status: Optional[str] = None, + runtime_type: Optional[str] = None, + limit: int = 100, + ) -> list[AgentProfile]: + return list( + await self.repo.list_by_workspace( + workspace_id=workspace_id, + status=status, + runtime_type=runtime_type, + limit=limit, + ) + ) + + async def update_status( + self, profile_id: uuid.UUID, status: AgentStatus + ) -> Optional[AgentProfile]: + profile = await self.repo.get_for_update(profile_id) + if not profile: + return None + profile.status = status + await self.db.commit() + return profile + + async def find_available_agents( + self, *, workspace_id: uuid.UUID, runtime_type: Optional[str] = None + ) -> list[AgentProfile]: + return list( + await self.repo.find_available( + workspace_id=workspace_id, runtime_type=runtime_type + ) + ) + + async def update_profile( + self, + profile_id: uuid.UUID, + workspace_id: uuid.UUID, + **kwargs: Any, + ) -> Optional[AgentProfile]: + profile = await self.repo.get_by_id_and_workspace(profile_id, workspace_id) + if not profile: + return None + allowed = { + "name", "description", "avatar", "instructions", + "skill_ids", "custom_env", "runtime_config", + "max_concurrent_tasks", "runtime_type", "visibility", + } + for key, value in kwargs.items(): + if key in allowed: + setattr(profile, key, value) + await self.db.commit() + await self.db.refresh(profile) + return profile diff --git a/backend/app/services/mission_service.py b/backend/app/services/mission_service.py new file mode 100644 index 000000000..b93996c83 --- /dev/null +++ b/backend/app/services/mission_service.py @@ -0,0 +1,264 @@ +""" +Mission service layer with dispatch logic. +""" + +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.agent_profile import AgentStatus +from app.models.execution import ExecutionSource, ExecutionStatus +from app.models.mission import Mission, MissionPriority, MissionStatus +from app.repositories.agent_profile import AgentProfileRepository +from app.repositories.mission import MissionRepository +from app.services.execution_service import ExecutionService +from app.utils.datetime import utc_now + + +def build_execution_prompt(mission: Mission) -> str: + """Build the prompt sent to the CLI agent for a mission.""" + parts: list[str] = [] + parts.append(f"# Mission: {mission.title}") + if mission.description: + parts.append(f"\n## Description\n{mission.description}") + if mission.objective: + parts.append(f"\n## Objective\n{mission.objective}") + if mission.tags: + parts.append(f"\n## Tags\n{', '.join(str(t) for t in mission.tags)}") + return "\n".join(parts) + + +class MissionService: + """Manages mission lifecycle and agent dispatch.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.repo = MissionRepository(db) + self.agent_repo = AgentProfileRepository(db) + self.execution_service = ExecutionService(db) + + async def create_mission( + self, + *, + workspace_id: uuid.UUID, + creator_id: str, + title: str, + description: Optional[str] = None, + objective: Optional[str] = None, + priority: MissionPriority = MissionPriority.NONE, + parent_mission_id: Optional[uuid.UUID] = None, + tags: Optional[list] = None, + position: float = 0.0, + ) -> Mission: + mission = Mission( + workspace_id=workspace_id, + creator_id=creator_id, + title=title, + description=description, + objective=objective, + priority=priority, + status=MissionStatus.BACKLOG, + parent_mission_id=parent_mission_id, + tags=tags, + position=position, + ) + self.db.add(mission) + await self.db.commit() + await self.db.refresh(mission) + logger.info(f"Created mission: {mission.id} ({title})") + return mission + + async def get_mission( + self, mission_id: uuid.UUID, workspace_id: uuid.UUID + ) -> Optional[Mission]: + return await self.repo.get_by_id_and_workspace(mission_id, workspace_id) + + async def list_missions( + self, + *, + workspace_id: uuid.UUID, + status: Optional[str] = None, + creator_id: Optional[str] = None, + assignee_id: Optional[uuid.UUID] = None, + parent_mission_id: Optional[uuid.UUID] = None, + limit: int = 50, + ) -> list[Mission]: + return list( + await self.repo.list_by_workspace( + workspace_id=workspace_id, + status=status, + creator_id=creator_id, + assignee_id=assignee_id, + parent_mission_id=parent_mission_id, + limit=limit, + ) + ) + + async def update_mission( + self, + mission_id: uuid.UUID, + workspace_id: uuid.UUID, + **kwargs: Any, + ) -> Optional[Mission]: + mission = await self.repo.get_by_id_and_workspace(mission_id, workspace_id) + if not mission: + return None + allowed = { + "title", "description", "objective", "priority", + "status", "assignee_type", "assignee_id", + "parent_mission_id", "due_date", "position", "tags", + } + for key, value in kwargs.items(): + if key in allowed: + setattr(mission, key, value) + await self.db.commit() + await self.db.refresh(mission) + return mission + + async def assign_to_agent( + self, + *, + mission_id: uuid.UUID, + workspace_id: uuid.UUID, + agent_profile_id: uuid.UUID, + ) -> Mission: + """Assign a mission to an agent profile and move it to TODO status.""" + mission = await self.repo.get_for_update(mission_id, workspace_id) + if not mission: + raise ValueError(f"Mission not found: {mission_id}") + + agent = await self.agent_repo.get_by_id_and_workspace(agent_profile_id, workspace_id) + if not agent: + raise ValueError(f"Agent profile not found: {agent_profile_id}") + + mission.assignee_type = "agent" + mission.assignee_id = agent_profile_id + if mission.status == MissionStatus.BACKLOG: + mission.status = MissionStatus.TODO + await self.db.commit() + await self.db.refresh(mission) + logger.info(f"Assigned mission {mission_id} to agent {agent_profile_id}") + return mission + + async def dispatch_mission( + self, + *, + mission_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: str, + runtime_config: Optional[dict[str, Any]] = None, + ) -> tuple[Mission, Any]: + """Dispatch a mission: create an execution and transition to IN_PROGRESS. + + Returns the updated mission and the created execution. + """ + mission = await self.repo.get_for_update(mission_id, workspace_id) + if not mission: + raise ValueError(f"Mission not found: {mission_id}") + + if mission.status not in {MissionStatus.TODO, MissionStatus.BACKLOG}: + raise ValueError( + f"Mission {mission_id} cannot be dispatched from status {mission.status.value}" + ) + + if not mission.assignee_id or mission.assignee_type != "agent": + raise ValueError(f"Mission {mission_id} has no agent assignee") + + agent = await self.agent_repo.get_by_id_and_workspace( + mission.assignee_id, workspace_id + ) + if not agent: + raise ValueError(f"Agent profile not found: {mission.assignee_id}") + + execution = await self.execution_service.create_execution( + workspace_id=workspace_id, + user_id=user_id, + source=ExecutionSource.MISSION, + source_id=str(mission_id), + runtime_type=agent.runtime_type, + title=mission.title, + mission_id=mission_id, + agent_profile_id=mission.assignee_id, + runtime_config=runtime_config or agent.runtime_config, + ) + + mission.status = MissionStatus.IN_PROGRESS + mission.current_execution_id = execution.id + await self.db.commit() + await self.db.refresh(mission) + + logger.info( + f"Dispatched mission {mission_id} -> execution {execution.id} " + f"(agent={agent.name}, runtime={agent.runtime_type})" + ) + return mission, execution + + async def complete_mission( + self, + *, + mission_id: uuid.UUID, + workspace_id: uuid.UUID, + ) -> Optional[Mission]: + """Mark a mission as DONE.""" + mission = await self.repo.get_for_update(mission_id, workspace_id) + if not mission: + return None + mission.status = MissionStatus.DONE + await self.db.commit() + await self.db.refresh(mission) + return mission + + async def cancel_mission( + self, + *, + mission_id: uuid.UUID, + workspace_id: uuid.UUID, + ) -> Optional[Mission]: + """Cancel a mission and its active execution if any.""" + mission = await self.repo.get_for_update(mission_id, workspace_id) + if not mission: + return None + + if mission.current_execution_id: + await self.execution_service.mark_status( + execution_id=mission.current_execution_id, + status=ExecutionStatus.CANCELLED, + ) + + mission.status = MissionStatus.CANCELLED + await self.db.commit() + await self.db.refresh(mission) + return mission + + async def dispatch_ready_missions( + self, + *, + workspace_id: uuid.UUID, + user_id: str, + limit: int = 10, + ) -> list[tuple[Mission, Any]]: + """Background task: find TODO missions with agent assignees and dispatch them. + + Returns list of (mission, execution) tuples for successfully dispatched missions. + """ + dispatchable = await self.repo.list_dispatchable( + workspace_id=workspace_id, limit=limit + ) + results: list[tuple[Mission, Any]] = [] + for mission in dispatchable: + try: + result = await self.dispatch_mission( + mission_id=mission.id, + workspace_id=workspace_id, + user_id=user_id, + ) + results.append(result) + except Exception as exc: + logger.warning( + f"Failed to dispatch mission {mission.id}: {exc}" + ) + return results diff --git a/backend/tests/test_core/test_mission_service.py b/backend/tests/test_core/test_mission_service.py new file mode 100644 index 000000000..de8966560 --- /dev/null +++ b/backend/tests/test_core/test_mission_service.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import uuid +from unittest.mock import MagicMock + +from app.models.mission import Mission, MissionPriority, MissionStatus +from app.services.mission_service import build_execution_prompt + + +def _make_mission(**overrides) -> Mission: + """Create a Mission-like object for testing prompt building.""" + defaults = { + "id": uuid.uuid4(), + "workspace_id": uuid.uuid4(), + "creator_id": "user-1", + "title": "Fix login bug", + "description": None, + "objective": None, + "status": MissionStatus.TODO, + "priority": MissionPriority.MEDIUM, + "assignee_type": "agent", + "assignee_id": uuid.uuid4(), + "parent_mission_id": None, + "current_execution_id": None, + "due_date": None, + "position": 0.0, + "tags": None, + } + defaults.update(overrides) + mission = MagicMock(spec=Mission) + for k, v in defaults.items(): + setattr(mission, k, v) + return mission + + +def test_build_prompt_title_only(): + mission = _make_mission(title="Fix login bug") + prompt = build_execution_prompt(mission) + assert "# Mission: Fix login bug" in prompt + assert "## Description" not in prompt + assert "## Objective" not in prompt + + +def test_build_prompt_with_description(): + mission = _make_mission( + title="Add caching", + description="Implement Redis caching for the API layer.", + ) + prompt = build_execution_prompt(mission) + assert "# Mission: Add caching" in prompt + assert "## Description" in prompt + assert "Implement Redis caching" in prompt + + +def test_build_prompt_with_objective(): + mission = _make_mission( + title="Refactor auth", + objective="Reduce auth latency by 50%.", + ) + prompt = build_execution_prompt(mission) + assert "## Objective" in prompt + assert "Reduce auth latency" in prompt + + +def test_build_prompt_with_tags(): + mission = _make_mission( + title="Deploy v2", + tags=["backend", "infra", "urgent"], + ) + prompt = build_execution_prompt(mission) + assert "## Tags" in prompt + assert "backend" in prompt + assert "infra" in prompt + assert "urgent" in prompt + + +def test_build_prompt_full(): + mission = _make_mission( + title="Full mission", + description="Do everything.", + objective="Ship it.", + tags=["release"], + ) + prompt = build_execution_prompt(mission) + assert "# Mission: Full mission" in prompt + assert "## Description" in prompt + assert "Do everything." in prompt + assert "## Objective" in prompt + assert "Ship it." in prompt + assert "## Tags" in prompt + assert "release" in prompt + + +def test_build_prompt_empty_tags_not_shown(): + mission = _make_mission(title="No tags", tags=None) + prompt = build_execution_prompt(mission) + assert "## Tags" not in prompt + + +def test_build_prompt_empty_tags_list_not_shown(): + mission = _make_mission(title="Empty tags", tags=[]) + prompt = build_execution_prompt(mission) + assert "## Tags" not in prompt From 30e6d3f79bd32da5cc18d7835564ee041730cedd Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:21:03 +0800 Subject: [PATCH 05/69] feat: add CLIContainerService and credential/skill/config injectors Docker container lifecycle management (create, stop, remove, exec), CredentialInjector for API key injection, CLISkillInjector for writing skill definitions, RuntimeConfigInjector for CLAUDE.md generation. 13 unit tests covering data classes, CLAUDE.md building, and async injection calls. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent/cli_backends/container_service.py | 133 ++++++++++++++ .../app/core/agent/cli_backends/injectors.py | 126 +++++++++++++ .../test_core/test_container_injectors.py | 168 ++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 backend/app/core/agent/cli_backends/container_service.py create mode 100644 backend/app/core/agent/cli_backends/injectors.py create mode 100644 backend/tests/test_core/test_container_injectors.py diff --git a/backend/app/core/agent/cli_backends/container_service.py b/backend/app/core/agent/cli_backends/container_service.py new file mode 100644 index 000000000..a39a55ee1 --- /dev/null +++ b/backend/app/core/agent/cli_backends/container_service.py @@ -0,0 +1,133 @@ +""" +CLI container lifecycle management via Docker. +""" + +from __future__ import annotations + +import asyncio +import uuid +from dataclasses import dataclass, field +from typing import Optional + +from loguru import logger + + +@dataclass +class ContainerConfig: + image: str = "joysafeter/cli-agent:latest" + memory_limit: str = "2g" + cpu_quota: int = 200000 + network_mode: str = "bridge" + working_dir: str = "/workspace" + labels: dict[str, str] = field(default_factory=dict) + + +@dataclass +class ContainerInfo: + container_id: str + name: str + status: str + working_dir: str + + +class CLIContainerService: + """Manages Docker container lifecycle for CLI agent executions.""" + + def __init__(self, default_config: Optional[ContainerConfig] = None): + self.default_config = default_config or ContainerConfig() + + async def create_container( + self, + *, + execution_id: uuid.UUID, + config: Optional[ContainerConfig] = None, + env: Optional[dict[str, str]] = None, + ) -> ContainerInfo: + cfg = config or self.default_config + name = f"cli-agent-{execution_id!s:.12}" + + docker_cmd = [ + "docker", "create", + "--name", name, + "-w", cfg.working_dir, + f"--memory={cfg.memory_limit}", + f"--cpu-quota={cfg.cpu_quota}", + f"--network={cfg.network_mode}", + ] + for k, v in cfg.labels.items(): + docker_cmd.extend(["--label", f"{k}={v}"]) + docker_cmd.extend(["--label", f"execution_id={execution_id}"]) + if env: + for k, v in env.items(): + docker_cmd.extend(["-e", f"{k}={v}"]) + docker_cmd.append(cfg.image) + docker_cmd.append("sleep") + docker_cmd.append("infinity") + + container_id = await self._run_docker(docker_cmd) + container_id = container_id.strip() + + await self._run_docker(["docker", "start", container_id]) + + logger.info(f"Created container {container_id[:12]} for execution {execution_id}") + return ContainerInfo( + container_id=container_id, + name=name, + status="running", + working_dir=cfg.working_dir, + ) + + async def stop_container(self, container_id: str, timeout: int = 10) -> None: + try: + await self._run_docker( + ["docker", "stop", "-t", str(timeout), container_id] + ) + logger.info(f"Stopped container {container_id[:12]}") + except RuntimeError as exc: + logger.warning(f"Failed to stop container {container_id[:12]}: {exc}") + + async def remove_container(self, container_id: str, force: bool = True) -> None: + cmd = ["docker", "rm"] + if force: + cmd.append("-f") + cmd.append(container_id) + try: + await self._run_docker(cmd) + logger.info(f"Removed container {container_id[:12]}") + except RuntimeError as exc: + logger.warning(f"Failed to remove container {container_id[:12]}: {exc}") + + async def copy_to_container( + self, container_id: str, src_path: str, dest_path: str + ) -> None: + await self._run_docker( + ["docker", "cp", src_path, f"{container_id}:{dest_path}"] + ) + + async def exec_in_container( + self, container_id: str, cmd: list[str], workdir: Optional[str] = None + ) -> str: + docker_cmd = ["docker", "exec"] + if workdir: + docker_cmd.extend(["-w", workdir]) + docker_cmd.append(container_id) + docker_cmd.extend(cmd) + return await self._run_docker(docker_cmd) + + async def inspect_container(self, container_id: str) -> str: + return await self._run_docker( + ["docker", "inspect", "--format", "{{.State.Status}}", container_id] + ) + + async def _run_docker(self, cmd: list[str]) -> str: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError( + f"Docker command failed (exit {proc.returncode}): {stderr.decode()[:1000]}" + ) + return stdout.decode() diff --git a/backend/app/core/agent/cli_backends/injectors.py b/backend/app/core/agent/cli_backends/injectors.py new file mode 100644 index 000000000..be99d7d80 --- /dev/null +++ b/backend/app/core/agent/cli_backends/injectors.py @@ -0,0 +1,126 @@ +""" +Credential, skill, and runtime config injectors for CLI agent containers. +""" + +from __future__ import annotations + +import json +import textwrap +from typing import Any, Optional + +from loguru import logger + +from .container_service import CLIContainerService + + +class CredentialInjector: + """Injects API keys into a container as environment variables.""" + + def __init__(self, container_service: CLIContainerService): + self.container_service = container_service + + async def inject( + self, + container_id: str, + credentials: dict[str, str], + ) -> None: + """Write credentials as env vars in the container's /etc/environment.""" + if not credentials: + return + lines = [f"{k}={v}" for k, v in credentials.items()] + content = "\n".join(lines) + "\n" + await self.container_service.exec_in_container( + container_id, + ["sh", "-c", f"cat >> /etc/environment << 'ENVEOF'\n{content}ENVEOF"], + ) + logger.debug(f"Injected {len(credentials)} credentials into {container_id[:12]}") + + +class CLISkillInjector: + """Writes skill definitions into the container filesystem.""" + + def __init__(self, container_service: CLIContainerService): + self.container_service = container_service + + async def inject( + self, + container_id: str, + skills: list[dict[str, Any]], + target_dir: str = "/workspace/.skills", + ) -> None: + if not skills: + return + await self.container_service.exec_in_container( + container_id, ["mkdir", "-p", target_dir] + ) + for skill in skills: + name = skill.get("name", "unnamed") + filename = f"{target_dir}/{name}.json" + content = json.dumps(skill, indent=2) + await self.container_service.exec_in_container( + container_id, + ["sh", "-c", f"cat > {filename} << 'SKILLEOF'\n{content}\nSKILLEOF"], + ) + logger.debug(f"Injected {len(skills)} skills into {container_id[:12]}") + + +class RuntimeConfigInjector: + """Generates and writes CLAUDE.md configuration into the container.""" + + def __init__(self, container_service: CLIContainerService): + self.container_service = container_service + + async def inject( + self, + container_id: str, + *, + instructions: Optional[str] = None, + skill_names: Optional[list[str]] = None, + project_context: Optional[str] = None, + working_dir: str = "/workspace", + ) -> None: + claude_md = self._build_claude_md( + instructions=instructions, + skill_names=skill_names, + project_context=project_context, + ) + target = f"{working_dir}/CLAUDE.md" + await self.container_service.exec_in_container( + container_id, + ["sh", "-c", f"cat > {target} << 'CLAUDEEOF'\n{claude_md}\nCLAUDEEOF"], + ) + logger.debug(f"Injected CLAUDE.md into {container_id[:12]}") + + def _build_claude_md( + self, + *, + instructions: Optional[str] = None, + skill_names: Optional[list[str]] = None, + project_context: Optional[str] = None, + ) -> str: + sections: list[str] = [] + sections.append("# Agent Configuration") + sections.append("") + sections.append("You are an autonomous coding agent executing a mission.") + sections.append("Complete the task thoroughly and commit your work when done.") + + if instructions: + sections.append("") + sections.append("## Instructions") + sections.append("") + sections.append(instructions) + + if skill_names: + sections.append("") + sections.append("## Available Skills") + sections.append("") + for name in skill_names: + sections.append(f"- {name}") + + if project_context: + sections.append("") + sections.append("## Project Context") + sections.append("") + sections.append(project_context) + + return "\n".join(sections) diff --git a/backend/tests/test_core/test_container_injectors.py b/backend/tests/test_core/test_container_injectors.py new file mode 100644 index 000000000..3e0dc01f2 --- /dev/null +++ b/backend/tests/test_core/test_container_injectors.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import uuid + +import pytest + +from app.core.agent.cli_backends.container_service import ContainerConfig, ContainerInfo +from app.core.agent.cli_backends.injectors import ( + CLISkillInjector, + CredentialInjector, + RuntimeConfigInjector, +) + + +# --------------------------------------------------------------------------- +# ContainerConfig / ContainerInfo data classes +# --------------------------------------------------------------------------- + +def test_container_config_defaults(): + cfg = ContainerConfig() + assert cfg.image == "joysafeter/cli-agent:latest" + assert cfg.memory_limit == "2g" + assert cfg.network_mode == "bridge" + assert cfg.labels == {} + + +def test_container_config_custom(): + cfg = ContainerConfig(image="my-image:v1", memory_limit="4g", labels={"env": "test"}) + assert cfg.image == "my-image:v1" + assert cfg.memory_limit == "4g" + assert cfg.labels == {"env": "test"} + + +def test_container_info(): + info = ContainerInfo( + container_id="abc123", + name="cli-agent-test", + status="running", + working_dir="/workspace", + ) + assert info.container_id == "abc123" + assert info.name == "cli-agent-test" + + +# --------------------------------------------------------------------------- +# RuntimeConfigInjector._build_claude_md (pure logic, no Docker needed) +# --------------------------------------------------------------------------- + +class _FakeContainerService: + """Stub that records exec calls without touching Docker.""" + def __init__(self): + self.calls: list[tuple[str, list[str]]] = [] + + async def exec_in_container(self, container_id, cmd, workdir=None): + self.calls.append((container_id, cmd)) + return "" + + +def test_build_claude_md_minimal(): + injector = RuntimeConfigInjector(_FakeContainerService()) + md = injector._build_claude_md() + assert "# Agent Configuration" in md + assert "autonomous coding agent" in md + assert "## Instructions" not in md + assert "## Available Skills" not in md + assert "## Project Context" not in md + + +def test_build_claude_md_with_instructions(): + injector = RuntimeConfigInjector(_FakeContainerService()) + md = injector._build_claude_md(instructions="Always write tests first.") + assert "## Instructions" in md + assert "Always write tests first." in md + + +def test_build_claude_md_with_skills(): + injector = RuntimeConfigInjector(_FakeContainerService()) + md = injector._build_claude_md(skill_names=["code_review", "deploy"]) + assert "## Available Skills" in md + assert "- code_review" in md + assert "- deploy" in md + + +def test_build_claude_md_with_project_context(): + injector = RuntimeConfigInjector(_FakeContainerService()) + md = injector._build_claude_md(project_context="Python 3.12, FastAPI backend") + assert "## Project Context" in md + assert "Python 3.12, FastAPI backend" in md + + +def test_build_claude_md_full(): + injector = RuntimeConfigInjector(_FakeContainerService()) + md = injector._build_claude_md( + instructions="Be thorough.", + skill_names=["lint", "test"], + project_context="Monorepo with backend/ and frontend/", + ) + assert "## Instructions" in md + assert "Be thorough." in md + assert "## Available Skills" in md + assert "- lint" in md + assert "- test" in md + assert "## Project Context" in md + assert "Monorepo" in md + + +# --------------------------------------------------------------------------- +# Async injector tests (using fake container service) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_credential_injector_inject(): + svc = _FakeContainerService() + injector = CredentialInjector(svc) + await injector.inject("ctr-1", {"ANTHROPIC_API_KEY": "sk-test", "GITHUB_TOKEN": "ghp-abc"}) + assert len(svc.calls) == 1 + container_id, cmd = svc.calls[0] + assert container_id == "ctr-1" + # The command should write to /etc/environment + cmd_str = " ".join(cmd) + assert "/etc/environment" in cmd_str + + +@pytest.mark.asyncio +async def test_credential_injector_empty(): + svc = _FakeContainerService() + injector = CredentialInjector(svc) + await injector.inject("ctr-1", {}) + assert len(svc.calls) == 0 + + +@pytest.mark.asyncio +async def test_skill_injector_inject(): + svc = _FakeContainerService() + injector = CLISkillInjector(svc) + skills = [ + {"name": "lint", "command": "ruff check ."}, + {"name": "test", "command": "pytest"}, + ] + await injector.inject("ctr-2", skills) + # 1 mkdir + 2 skill writes + assert len(svc.calls) == 3 + assert svc.calls[0][1] == ["mkdir", "-p", "/workspace/.skills"] + + +@pytest.mark.asyncio +async def test_skill_injector_empty(): + svc = _FakeContainerService() + injector = CLISkillInjector(svc) + await injector.inject("ctr-2", []) + assert len(svc.calls) == 0 + + +@pytest.mark.asyncio +async def test_runtime_config_injector_inject(): + svc = _FakeContainerService() + injector = RuntimeConfigInjector(svc) + await injector.inject( + "ctr-3", + instructions="Write clean code.", + skill_names=["lint"], + working_dir="/project", + ) + assert len(svc.calls) == 1 + container_id, cmd = svc.calls[0] + assert container_id == "ctr-3" + cmd_str = " ".join(cmd) + assert "/project/CLAUDE.md" in cmd_str From 49f03840768bc2e24c78a5bdde3b1bb218422f13 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:25:32 +0800 Subject: [PATCH 06/69] feat: add ExecutionRunner E2E orchestrator Ties together container lifecycle, credential/skill/config injection, RuntimeProvider execution, message draining to ExecutionEvents, final status marking, and container cleanup. 14 unit tests for message-to-event type mapping and payload conversion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agent/cli_backends/execution_runner.py | 320 ++++++++++++++++++ .../tests/test_core/test_execution_runner.py | 86 +++++ 2 files changed, 406 insertions(+) create mode 100644 backend/app/core/agent/cli_backends/execution_runner.py create mode 100644 backend/tests/test_core/test_execution_runner.py diff --git a/backend/app/core/agent/cli_backends/execution_runner.py b/backend/app/core/agent/cli_backends/execution_runner.py new file mode 100644 index 000000000..a1f1ecde4 --- /dev/null +++ b/backend/app/core/agent/cli_backends/execution_runner.py @@ -0,0 +1,320 @@ +""" +ExecutionRunner — end-to-end orchestrator for CLI agent executions. + +Lifecycle: + 1. Create container + 2. Inject credentials, skills, and CLAUDE.md config + 3. Execute via RuntimeProvider + 4. Drain messages → append as ExecutionEvents + 5. Mark final status + 6. Destroy container +""" + +from __future__ import annotations + +import uuid +from typing import Any, Optional + +from loguru import logger +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.agent.cli_backends.base import CLIMessage, CLIResult, RuntimeSession +from app.core.agent.cli_backends.container_service import ( + CLIContainerService, + ContainerConfig, + ContainerInfo, +) +from app.core.agent.cli_backends.injectors import ( + CLISkillInjector, + CredentialInjector, + RuntimeConfigInjector, +) +from app.core.agent.cli_backends.registry import runtime_registry +from app.models.agent_profile import AgentProfile, AgentStatus +from app.models.execution import Execution, ExecutionStatus +from app.repositories.agent_profile import AgentProfileRepository +from app.services.execution_service import ExecutionService +from app.utils.datetime import utc_now + + +class ExecutionRunner: + """Orchestrates the full lifecycle of a CLI agent execution.""" + + def __init__( + self, + db: AsyncSession, + container_service: Optional[CLIContainerService] = None, + ): + self.db = db + self.execution_service = ExecutionService(db) + self.agent_repo = AgentProfileRepository(db) + self.container_service = container_service or CLIContainerService() + + async def run( + self, + *, + execution_id: uuid.UUID, + prompt: str, + credentials: Optional[dict[str, str]] = None, + skills: Optional[list[dict[str, Any]]] = None, + container_config: Optional[ContainerConfig] = None, + model: Optional[str] = None, + timeout: int = 7200, + ) -> CLIResult: + """Run a full execution lifecycle. + + Returns the final CLIResult after the agent completes or fails. + """ + container: Optional[ContainerInfo] = None + execution = await self._get_execution(execution_id) + agent_profile = await self._get_agent_profile(execution) + + try: + # 1. Mark as dispatched + await self.execution_service.mark_status( + execution_id=execution_id, + status=ExecutionStatus.DISPATCHED, + ) + + # 2. Create container + container = await self.container_service.create_container( + execution_id=execution_id, + config=container_config, + env=credentials, + ) + await self.execution_service.mark_status( + execution_id=execution_id, + status=ExecutionStatus.RUNNING, + container_id=container.container_id, + ) + + # 3. Inject credentials, skills, config + await self._inject( + container_id=container.container_id, + credentials=credentials, + skills=skills, + agent_profile=agent_profile, + working_dir=container.working_dir, + ) + + # 4. Record execution_started event + await self.execution_service.append_event( + execution_id=execution_id, + event_type="execution_started", + payload={ + "container_id": container.container_id, + "runtime_type": execution.runtime_type, + }, + ) + + # 5. Execute via provider + provider = runtime_registry.get(execution.runtime_type) + session = await provider.execute( + prompt, + container_id=container.container_id, + cwd=container.working_dir, + model=model, + timeout=timeout, + resume_session_id=execution.prior_session_id, + env=credentials, + ) + + # 6. Drain messages → events + await self._drain_to_events(execution_id, session) + + # 7. Await final result + result = await session.result + + # 8. Mark final status + await self._finalize(execution_id, result, agent_profile) + + return result + + except Exception as exc: + logger.error(f"ExecutionRunner error for {execution_id}: {exc}") + await self._mark_failed(execution_id, str(exc), agent_profile) + return CLIResult(status="failed", error=str(exc)) + + finally: + # 9. Destroy container + if container: + await self._cleanup_container(container.container_id) + + async def _get_execution(self, execution_id: uuid.UUID) -> Execution: + from sqlalchemy import select + from app.models.execution import Execution as ExecModel + + result = await self.db.execute( + select(ExecModel).where(ExecModel.id == execution_id) + ) + execution = result.scalar_one_or_none() + if not execution: + raise ValueError(f"Execution not found: {execution_id}") + return execution + + async def _get_agent_profile( + self, execution: Execution + ) -> Optional[AgentProfile]: + if not execution.agent_profile_id: + return None + return await self.agent_repo.get(execution.agent_profile_id) + + async def _inject( + self, + *, + container_id: str, + credentials: Optional[dict[str, str]], + skills: Optional[list[dict[str, Any]]], + agent_profile: Optional[AgentProfile], + working_dir: str, + ) -> None: + cred_injector = CredentialInjector(self.container_service) + skill_injector = CLISkillInjector(self.container_service) + config_injector = RuntimeConfigInjector(self.container_service) + + if credentials: + await cred_injector.inject(container_id, credentials) + + if skills: + await skill_injector.inject(container_id, skills) + + instructions = agent_profile.instructions if agent_profile else None + skill_names = None + if skills: + skill_names = [s.get("name", "") for s in skills if s.get("name")] + + await config_injector.inject( + container_id, + instructions=instructions, + skill_names=skill_names, + working_dir=working_dir, + ) + + async def _drain_to_events( + self, execution_id: uuid.UUID, session: RuntimeSession + ) -> None: + async for msg in session.iter_messages(): + event_type = self._msg_to_event_type(msg) + payload = self._msg_to_payload(msg) + try: + await self.execution_service.append_event( + execution_id=execution_id, + event_type=event_type, + payload=payload, + ) + except Exception as exc: + logger.warning( + f"Failed to append event for {execution_id}: {exc}" + ) + + async def _finalize( + self, + execution_id: uuid.UUID, + result: CLIResult, + agent_profile: Optional[AgentProfile], + ) -> None: + if result.status == "completed": + status = ExecutionStatus.COMPLETED + elif result.status == "timeout": + status = ExecutionStatus.FAILED + else: + status = ExecutionStatus.FAILED + + await self.execution_service.append_event( + execution_id=execution_id, + event_type="execution_completed" if status == ExecutionStatus.COMPLETED else "error", + payload={ + "result_summary": {"output_length": len(result.output)}, + "message": result.error or "", + }, + ) + + await self.execution_service.mark_status( + execution_id=execution_id, + status=status, + session_id=result.session_id, + error_message=result.error if result.error else None, + result_summary=result.usage, + ) + + if agent_profile: + await self._update_agent_status(agent_profile, AgentStatus.IDLE) + + async def _mark_failed( + self, + execution_id: uuid.UUID, + error: str, + agent_profile: Optional[AgentProfile], + ) -> None: + try: + await self.execution_service.append_event( + execution_id=execution_id, + event_type="error", + payload={"message": error}, + ) + await self.execution_service.mark_status( + execution_id=execution_id, + status=ExecutionStatus.FAILED, + error_message=error[:2000], + ) + except Exception as exc: + logger.error(f"Failed to mark execution {execution_id} as failed: {exc}") + + if agent_profile: + await self._update_agent_status(agent_profile, AgentStatus.ERROR) + + async def _update_agent_status( + self, agent_profile: AgentProfile, status: AgentStatus + ) -> None: + try: + profile = await self.agent_repo.get_for_update(agent_profile.id) + if profile: + profile.status = status + await self.db.commit() + except Exception as exc: + logger.warning(f"Failed to update agent status: {exc}") + + async def _cleanup_container(self, container_id: str) -> None: + try: + await self.container_service.remove_container(container_id, force=True) + except Exception as exc: + logger.warning(f"Failed to cleanup container {container_id[:12]}: {exc}") + + @staticmethod + def _msg_to_event_type(msg: CLIMessage) -> str: + mapping = { + "text": "assistant_text", + "thinking": "thinking", + "tool_use": "tool_use_start", + "tool_result": "tool_use_end", + "error": "error", + "artifact": "artifact_created", + } + return mapping.get(msg.type, msg.type) + + @staticmethod + def _msg_to_payload(msg: CLIMessage) -> dict[str, Any]: + if msg.type == "text": + return {"content": msg.content} + if msg.type == "thinking": + return {"content": msg.content} + if msg.type == "tool_use": + return { + "tool": { + "name": msg.tool, + "call_id": msg.call_id, + "input": msg.input, + "status": "running", + }, + } + if msg.type == "tool_result": + return { + "call_id": msg.call_id, + "tool_name": msg.tool, + "output": msg.output, + } + if msg.type == "error": + return {"message": msg.content} + if msg.type == "artifact": + return {"artifact": {"content": msg.content}} + return {"content": msg.content} diff --git a/backend/tests/test_core/test_execution_runner.py b/backend/tests/test_core/test_execution_runner.py new file mode 100644 index 000000000..f75550a54 --- /dev/null +++ b/backend/tests/test_core/test_execution_runner.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from app.core.agent.cli_backends.base import CLIMessage +from app.core.agent.cli_backends.execution_runner import ExecutionRunner + + +def test_msg_to_event_type_text(): + msg = CLIMessage(type="text", content="hello") + assert ExecutionRunner._msg_to_event_type(msg) == "assistant_text" + + +def test_msg_to_event_type_thinking(): + msg = CLIMessage(type="thinking", content="hmm") + assert ExecutionRunner._msg_to_event_type(msg) == "thinking" + + +def test_msg_to_event_type_tool_use(): + msg = CLIMessage(type="tool_use", tool="Bash", call_id="c1") + assert ExecutionRunner._msg_to_event_type(msg) == "tool_use_start" + + +def test_msg_to_event_type_tool_result(): + msg = CLIMessage(type="tool_result", tool="Bash", call_id="c1", output="ok") + assert ExecutionRunner._msg_to_event_type(msg) == "tool_use_end" + + +def test_msg_to_event_type_error(): + msg = CLIMessage(type="error", content="boom") + assert ExecutionRunner._msg_to_event_type(msg) == "error" + + +def test_msg_to_event_type_artifact(): + msg = CLIMessage(type="artifact", content="file data") + assert ExecutionRunner._msg_to_event_type(msg) == "artifact_created" + + +def test_msg_to_event_type_unknown(): + msg = CLIMessage(type="custom_type", content="data") + assert ExecutionRunner._msg_to_event_type(msg) == "custom_type" + + +def test_msg_to_payload_text(): + msg = CLIMessage(type="text", content="hello world") + payload = ExecutionRunner._msg_to_payload(msg) + assert payload == {"content": "hello world"} + + +def test_msg_to_payload_thinking(): + msg = CLIMessage(type="thinking", content="analyzing") + payload = ExecutionRunner._msg_to_payload(msg) + assert payload == {"content": "analyzing"} + + +def test_msg_to_payload_tool_use(): + msg = CLIMessage(type="tool_use", tool="Bash", call_id="c1", input={"command": "ls"}) + payload = ExecutionRunner._msg_to_payload(msg) + assert payload["tool"]["name"] == "Bash" + assert payload["tool"]["call_id"] == "c1" + assert payload["tool"]["input"] == {"command": "ls"} + assert payload["tool"]["status"] == "running" + + +def test_msg_to_payload_tool_result(): + msg = CLIMessage(type="tool_result", tool="Bash", call_id="c1", output="file.txt") + payload = ExecutionRunner._msg_to_payload(msg) + assert payload["call_id"] == "c1" + assert payload["tool_name"] == "Bash" + assert payload["output"] == "file.txt" + + +def test_msg_to_payload_error(): + msg = CLIMessage(type="error", content="OOM killed") + payload = ExecutionRunner._msg_to_payload(msg) + assert payload == {"message": "OOM killed"} + + +def test_msg_to_payload_artifact(): + msg = CLIMessage(type="artifact", content="binary data") + payload = ExecutionRunner._msg_to_payload(msg) + assert payload == {"artifact": {"content": "binary data"}} + + +def test_msg_to_payload_unknown(): + msg = CLIMessage(type="custom", content="stuff") + payload = ExecutionRunner._msg_to_payload(msg) + assert payload == {"content": "stuff"} From 3a63009d5c2728faa837bdaa1d29899f947983bf Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:28:27 +0800 Subject: [PATCH 07/69] feat: add WebSocket execution streaming with subscribe/unsubscribe ExecutionSubscriptionManager for in-memory connection tracking, ExecutionSubscriptionHandler for subscribe/unsubscribe/ping frames with snapshot replay and event catch-up. 8 unit tests covering subscription lifecycle, broadcast fan-out, and error disconnection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../execution_subscription_handler.py | 121 ++++++++++++++++++ .../execution_subscription_manager.py | 61 +++++++++ .../test_core/test_execution_subscription.py | 113 ++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 backend/app/websocket/execution_subscription_handler.py create mode 100644 backend/app/websocket/execution_subscription_manager.py create mode 100644 backend/tests/test_core/test_execution_subscription.py diff --git a/backend/app/websocket/execution_subscription_handler.py b/backend/app/websocket/execution_subscription_handler.py new file mode 100644 index 000000000..fb27ae41c --- /dev/null +++ b/backend/app/websocket/execution_subscription_handler.py @@ -0,0 +1,121 @@ +"""WebSocket handler for execution event subscriptions.""" + +from __future__ import annotations + +import json +import uuid + +from fastapi import WebSocket, WebSocketDisconnect + +from app.core.database import AsyncSessionLocal +from app.services.execution_service import ExecutionService +from app.websocket.execution_subscription_manager import execution_subscription_manager + + +class ExecutionSubscriptionHandler: + """Handles subscribe/unsubscribe frames for execution event streams.""" + + async def handle_connection(self, websocket: WebSocket, user_id: str) -> None: + await websocket.accept() + try: + while True: + raw = await websocket.receive_text() + await self._handle_frame(websocket, user_id, raw) + except WebSocketDisconnect: + pass + finally: + execution_subscription_manager.disconnect(websocket) + + async def _handle_frame(self, websocket: WebSocket, user_id: str, raw: str) -> None: + try: + frame = json.loads(raw) + except json.JSONDecodeError: + await websocket.send_text(json.dumps({"type": "ws_error", "message": "invalid json frame"})) + return + + frame_type = frame.get("type") + if frame_type == "ping": + await websocket.send_text(json.dumps({"type": "pong"})) + return + + if frame_type == "unsubscribe": + execution_id = frame.get("execution_id") + if execution_id: + execution_subscription_manager.remove_subscription(websocket, str(execution_id)) + return + + if frame_type != "subscribe": + await websocket.send_text( + json.dumps({"type": "ws_error", "message": f"unknown frame type: {frame_type}"}) + ) + return + + execution_id_raw = frame.get("execution_id") + if not execution_id_raw: + await websocket.send_text(json.dumps({"type": "ws_error", "message": "execution_id is required"})) + return + + try: + execution_id = uuid.UUID(str(execution_id_raw)) + except ValueError: + await websocket.send_text(json.dumps({"type": "ws_error", "message": "invalid execution_id"})) + return + + try: + after_seq = int(frame.get("after_seq") or 0) + except (ValueError, TypeError): + await websocket.send_text(json.dumps({"type": "ws_error", "message": "invalid after_seq"})) + return + + async with AsyncSessionLocal() as db: + service = ExecutionService(db) + execution = await service.get_execution(execution_id, user_id) + if execution is None: + await websocket.send_text(json.dumps({"type": "ws_error", "message": "execution not found"})) + return + + snapshot = await service.get_snapshot(execution_id, user_id) + if snapshot is None: + await websocket.send_text(json.dumps({"type": "ws_error", "message": "snapshot not found"})) + return + + snapshot_last_seq = int(snapshot.last_seq or 0) + await websocket.send_text( + json.dumps({ + "type": "snapshot", + "execution_id": str(execution_id), + "last_seq": snapshot_last_seq, + "data": snapshot.projection, + }) + ) + + await execution_subscription_manager.add_subscription(websocket, str(execution_id)) + + catchup_after_seq = max(after_seq, snapshot_last_seq) + events = await service.list_events_after( + execution_id, user_id, after_seq=catchup_after_seq, limit=1000 + ) + replay_last_seq = snapshot_last_seq + for event in events: + replay_last_seq = max(replay_last_seq, int(event.seq)) + await websocket.send_text( + json.dumps({ + "type": "event", + "execution_id": str(execution_id), + "seq": event.seq, + "event_type": event.event_type, + "data": event.payload, + "created_at": event.created_at.isoformat() if event.created_at else None, + }) + ) + + await websocket.send_text( + json.dumps({ + "type": "replay_done", + "execution_id": str(execution_id), + "last_seq": replay_last_seq, + }) + ) + + +execution_subscription_handler = ExecutionSubscriptionHandler() diff --git a/backend/app/websocket/execution_subscription_manager.py b/backend/app/websocket/execution_subscription_manager.py new file mode 100644 index 000000000..8a34e0cb5 --- /dev/null +++ b/backend/app/websocket/execution_subscription_manager.py @@ -0,0 +1,61 @@ +"""In-memory execution subscription manager.""" + +from __future__ import annotations + +import json +from collections import defaultdict +from typing import Any + +from fastapi import WebSocket + + +class ExecutionSubscriptionManager: + """Tracks which WebSocket connections are subscribed to which execution IDs. + + Mirrors RunSubscriptionManager but scoped to CLI agent executions. + """ + + def __init__(self) -> None: + self._exec_connections: dict[str, set[WebSocket]] = defaultdict(set) + self._connection_execs: dict[WebSocket, set[str]] = defaultdict(set) + + async def add_subscription(self, websocket: WebSocket, execution_id: str) -> None: + self._exec_connections[execution_id].add(websocket) + self._connection_execs[websocket].add(execution_id) + + def remove_subscription(self, websocket: WebSocket, execution_id: str) -> None: + execs = self._connection_execs.get(websocket) + if execs: + execs.discard(execution_id) + if not execs: + self._connection_execs.pop(websocket, None) + + connections = self._exec_connections.get(execution_id) + if connections: + connections.discard(websocket) + if not connections: + self._exec_connections.pop(execution_id, None) + + def disconnect(self, websocket: WebSocket) -> None: + exec_ids = list(self._connection_execs.get(websocket, set())) + for exec_id in exec_ids: + self.remove_subscription(websocket, exec_id) + + async def broadcast_event(self, execution_id: str, message: dict[str, Any]) -> int: + connections = list(self._exec_connections.get(execution_id, set())) + success_count = 0 + disconnected: list[WebSocket] = [] + for connection in connections: + try: + await connection.send_text(json.dumps(message, default=str)) + success_count += 1 + except Exception: + disconnected.append(connection) + + for connection in disconnected: + self.disconnect(connection) + + return success_count + + +execution_subscription_manager = ExecutionSubscriptionManager() diff --git a/backend/tests/test_core/test_execution_subscription.py b/backend/tests/test_core/test_execution_subscription.py new file mode 100644 index 000000000..6bd2847ce --- /dev/null +++ b/backend/tests/test_core/test_execution_subscription.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.websocket.execution_subscription_manager import ExecutionSubscriptionManager + + +def _make_ws() -> MagicMock: + ws = MagicMock() + ws.send_text = AsyncMock() + return ws + + +def test_add_and_remove_subscription(): + mgr = ExecutionSubscriptionManager() + ws = _make_ws() + + asyncio.get_event_loop().run_until_complete(mgr.add_subscription(ws, "exec-1")) + assert ws in mgr._exec_connections["exec-1"] + assert "exec-1" in mgr._connection_execs[ws] + + mgr.remove_subscription(ws, "exec-1") + assert ws not in mgr._exec_connections.get("exec-1", set()) + assert ws not in mgr._connection_execs + + +def test_disconnect_removes_all(): + mgr = ExecutionSubscriptionManager() + ws = _make_ws() + + asyncio.get_event_loop().run_until_complete(mgr.add_subscription(ws, "exec-1")) + asyncio.get_event_loop().run_until_complete(mgr.add_subscription(ws, "exec-2")) + + mgr.disconnect(ws) + assert ws not in mgr._connection_execs + assert ws not in mgr._exec_connections.get("exec-1", set()) + assert ws not in mgr._exec_connections.get("exec-2", set()) + + +@pytest.mark.asyncio +async def test_broadcast_event(): + mgr = ExecutionSubscriptionManager() + ws1 = _make_ws() + ws2 = _make_ws() + + await mgr.add_subscription(ws1, "exec-1") + await mgr.add_subscription(ws2, "exec-1") + + count = await mgr.broadcast_event("exec-1", {"type": "event", "seq": 1}) + assert count == 2 + assert ws1.send_text.call_count == 1 + assert ws2.send_text.call_count == 1 + + sent = json.loads(ws1.send_text.call_args[0][0]) + assert sent["type"] == "event" + assert sent["seq"] == 1 + + +@pytest.mark.asyncio +async def test_broadcast_to_empty(): + mgr = ExecutionSubscriptionManager() + count = await mgr.broadcast_event("nonexistent", {"type": "event"}) + assert count == 0 + + +@pytest.mark.asyncio +async def test_broadcast_disconnects_failed(): + mgr = ExecutionSubscriptionManager() + ws_good = _make_ws() + ws_bad = _make_ws() + ws_bad.send_text.side_effect = Exception("connection closed") + + await mgr.add_subscription(ws_good, "exec-1") + await mgr.add_subscription(ws_bad, "exec-1") + + count = await mgr.broadcast_event("exec-1", {"type": "event"}) + assert count == 1 + # ws_bad should have been disconnected + assert ws_bad not in mgr._exec_connections.get("exec-1", set()) + + +@pytest.mark.asyncio +async def test_multiple_executions_per_connection(): + mgr = ExecutionSubscriptionManager() + ws = _make_ws() + + await mgr.add_subscription(ws, "exec-1") + await mgr.add_subscription(ws, "exec-2") + + assert "exec-1" in mgr._connection_execs[ws] + assert "exec-2" in mgr._connection_execs[ws] + + mgr.remove_subscription(ws, "exec-1") + assert "exec-1" not in mgr._connection_execs[ws] + assert "exec-2" in mgr._connection_execs[ws] + + +def test_remove_nonexistent_subscription(): + mgr = ExecutionSubscriptionManager() + ws = _make_ws() + # Should not raise + mgr.remove_subscription(ws, "nonexistent") + + +def test_disconnect_unsubscribed(): + mgr = ExecutionSubscriptionManager() + ws = _make_ws() + # Should not raise + mgr.disconnect(ws) From 4019194b865d8a102e2029082f7f0943eea84b45 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:37:43 +0800 Subject: [PATCH 08/69] feat: add REST API endpoints for Mission, AgentProfile, Execution Pydantic schemas for all three resources, API routers with CRUD + dispatch + cancel operations, registered in v1 router. WS /ws/executions endpoint for execution streaming. Runtime provider init on startup. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/v1/__init__.py | 6 + backend/app/api/v1/agent_profiles.py | 111 +++++++++++++++++ backend/app/api/v1/executions.py | 154 ++++++++++++++++++++++++ backend/app/api/v1/missions.py | 174 +++++++++++++++++++++++++++ backend/app/main.py | 22 ++++ backend/app/schemas/execution.py | 165 +++++++++++++++++++++++++ 6 files changed, 632 insertions(+) create mode 100644 backend/app/api/v1/agent_profiles.py create mode 100644 backend/app/api/v1/executions.py create mode 100644 backend/app/api/v1/missions.py create mode 100644 backend/app/schemas/execution.py diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 8bd3e1831..3beb53b77 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -38,6 +38,9 @@ from .traces import router as traces_router from .users import router as users_router from .version import router as version_router +from .missions import router as missions_router +from .executions import router as executions_router +from .agent_profiles import router as agent_profiles_router from .workspace_files import router as workspace_files_router from .workspace_folders import router as workspace_folders_router from .workspaces import router as workspaces_router @@ -78,6 +81,9 @@ openclaw_proxy_router, openapi_graph_router, version_router, + missions_router, + executions_router, + agent_profiles_router, ] diff --git a/backend/app/api/v1/agent_profiles.py b/backend/app/api/v1/agent_profiles.py new file mode 100644 index 000000000..16af48cbc --- /dev/null +++ b/backend/app/api/v1/agent_profiles.py @@ -0,0 +1,111 @@ +"""Agent Profiles API.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.common.dependencies import CurrentUser +from app.core.database import get_db +from app.models.agent_profile import AgentProfile +from app.schemas import BaseResponse +from app.schemas.execution import ( + AgentProfileListResponse, + AgentProfileSummary, + CreateAgentProfileRequest, + UpdateAgentProfileRequest, +) +from app.services.agent_profile_service import AgentProfileService + +router = APIRouter(prefix="/v1/agent-profiles", tags=["Agent Profiles"]) + + +def _to_summary(p: AgentProfile) -> AgentProfileSummary: + return AgentProfileSummary( + id=p.id, + workspace_id=p.workspace_id, + name=p.name, + runtime_type=p.runtime_type, + status=p.status.value if hasattr(p.status, "value") else str(p.status), + description=p.description, + avatar=p.avatar, + max_concurrent_tasks=p.max_concurrent_tasks, + created_at=p.created_at, + updated_at=p.updated_at, + ) + + +@router.get("", response_model=BaseResponse[AgentProfileListResponse]) +async def list_agent_profiles( + current_user: CurrentUser, + workspace_id: uuid.UUID = Query(...), + status: str | None = Query(None), + runtime_type: str | None = Query(None), + limit: int = Query(100, ge=1, le=500), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[AgentProfileListResponse]: + service = AgentProfileService(db) + profiles = await service.list_profiles( + workspace_id=workspace_id, + status=status, + runtime_type=runtime_type, + limit=limit, + ) + return BaseResponse( + success=True, code=200, msg="ok", + data=AgentProfileListResponse(items=[_to_summary(p) for p in profiles]), + ) + + +@router.post("", response_model=BaseResponse[AgentProfileSummary]) +async def create_agent_profile( + request: CreateAgentProfileRequest, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +) -> BaseResponse[AgentProfileSummary]: + service = AgentProfileService(db) + profile = await service.create_profile( + workspace_id=request.workspace_id, + name=request.name, + runtime_type=request.runtime_type, + description=request.description, + avatar=request.avatar, + instructions=request.instructions, + skill_ids=request.skill_ids, + custom_env=request.custom_env, + runtime_config=request.runtime_config, + max_concurrent_tasks=request.max_concurrent_tasks, + ) + return BaseResponse(success=True, code=200, msg="Agent profile created", data=_to_summary(profile)) + + +@router.get("/{profile_id}", response_model=BaseResponse[AgentProfileSummary]) +async def get_agent_profile( + profile_id: uuid.UUID, + workspace_id: uuid.UUID = Query(...), + current_user: CurrentUser = Depends(), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[AgentProfileSummary]: + service = AgentProfileService(db) + profile = await service.get_profile(profile_id, workspace_id) + if not profile: + return BaseResponse(success=False, code=404, msg="Agent profile not found", data=None) + return BaseResponse(success=True, code=200, msg="ok", data=_to_summary(profile)) + + +@router.patch("/{profile_id}", response_model=BaseResponse[AgentProfileSummary]) +async def update_agent_profile( + profile_id: uuid.UUID, + request: UpdateAgentProfileRequest, + workspace_id: uuid.UUID = Query(...), + current_user: CurrentUser = Depends(), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[AgentProfileSummary]: + service = AgentProfileService(db) + updates = request.model_dump(exclude_unset=True) + profile = await service.update_profile(profile_id, workspace_id, **updates) + if not profile: + return BaseResponse(success=False, code=404, msg="Agent profile not found", data=None) + return BaseResponse(success=True, code=200, msg="Agent profile updated", data=_to_summary(profile)) diff --git a/backend/app/api/v1/executions.py b/backend/app/api/v1/executions.py new file mode 100644 index 000000000..5ef68cf3f --- /dev/null +++ b/backend/app/api/v1/executions.py @@ -0,0 +1,154 @@ +"""Executions API.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.common.dependencies import CurrentUser +from app.core.database import get_db +from app.models.execution import Execution, ExecutionStatus +from app.schemas import BaseResponse +from app.schemas.execution import ( + ExecutionEventsPageResponse, + ExecutionEventResponse, + ExecutionListResponse, + ExecutionSnapshotResponse, + ExecutionSummary, +) +from app.services.execution_service import ExecutionService + +router = APIRouter(prefix="/v1/executions", tags=["Executions"]) + + +def _to_summary(e: Execution) -> ExecutionSummary: + return ExecutionSummary( + id=e.id, + workspace_id=e.workspace_id, + user_id=e.user_id, + source=e.source.value if hasattr(e.source, "value") else str(e.source), + status=e.status.value if hasattr(e.status, "value") else str(e.status), + title=e.title, + mission_id=e.mission_id, + agent_profile_id=e.agent_profile_id, + runtime_type=e.runtime_type, + container_id=e.container_id, + session_id=e.session_id, + started_at=e.started_at, + finished_at=e.finished_at, + last_seq=e.last_seq, + error_code=e.error_code, + error_message=e.error_message, + created_at=e.created_at, + updated_at=e.updated_at, + ) + + +@router.get("", response_model=BaseResponse[ExecutionListResponse]) +async def list_executions( + current_user: CurrentUser, + workspace_id: uuid.UUID = Query(...), + status: str | None = Query(None), + source: str | None = Query(None), + mission_id: uuid.UUID | None = Query(None), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[ExecutionListResponse]: + service = ExecutionService(db) + executions = await service.list_executions( + workspace_id=workspace_id, + user_id=str(current_user.id), + status=status, + source=source, + mission_id=mission_id, + limit=limit, + ) + return BaseResponse( + success=True, code=200, msg="ok", + data=ExecutionListResponse(items=[_to_summary(e) for e in executions]), + ) + + +@router.get("/{execution_id}", response_model=BaseResponse[ExecutionSummary]) +async def get_execution( + execution_id: uuid.UUID, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +) -> BaseResponse[ExecutionSummary]: + service = ExecutionService(db) + execution = await service.get_execution(execution_id, str(current_user.id)) + if not execution: + return BaseResponse(success=False, code=404, msg="Execution not found", data=None) + return BaseResponse(success=True, code=200, msg="ok", data=_to_summary(execution)) + + +@router.get("/{execution_id}/snapshot", response_model=BaseResponse[ExecutionSnapshotResponse]) +async def get_execution_snapshot( + execution_id: uuid.UUID, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +) -> BaseResponse[ExecutionSnapshotResponse]: + service = ExecutionService(db) + snapshot = await service.get_snapshot(execution_id, str(current_user.id)) + if not snapshot: + return BaseResponse(success=False, code=404, msg="Snapshot not found", data=None) + return BaseResponse( + success=True, code=200, msg="ok", + data=ExecutionSnapshotResponse( + execution_id=execution_id, + status=snapshot.status, + last_seq=snapshot.last_seq, + projection=snapshot.projection or {}, + ), + ) + + +@router.get("/{execution_id}/events", response_model=BaseResponse[ExecutionEventsPageResponse]) +async def get_execution_events( + execution_id: uuid.UUID, + current_user: CurrentUser, + after_seq: int = Query(0, ge=0), + limit: int = Query(500, ge=1, le=1000), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[ExecutionEventsPageResponse]: + service = ExecutionService(db) + events = await service.list_events_after( + execution_id, str(current_user.id), after_seq=after_seq, limit=limit, + ) + return BaseResponse( + success=True, code=200, msg="ok", + data=ExecutionEventsPageResponse( + execution_id=execution_id, + events=[ + ExecutionEventResponse( + seq=event.seq, + event_type=event.event_type, + payload=event.payload or {}, + created_at=event.created_at, + ) + for event in events + ], + next_after_seq=events[-1].seq if events else after_seq, + ), + ) + + +@router.post("/{execution_id}/cancel", response_model=BaseResponse[ExecutionSummary]) +async def cancel_execution( + execution_id: uuid.UUID, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +) -> BaseResponse[ExecutionSummary]: + service = ExecutionService(db) + execution = await service.mark_status( + execution_id=execution_id, + user_id=str(current_user.id), + status=ExecutionStatus.CANCELLED, + error_code="cancelled", + error_message="Cancelled by user", + ) + if not execution: + return BaseResponse(success=False, code=404, msg="Execution not found", data=None) + return BaseResponse(success=True, code=200, msg="Execution cancelled", data=_to_summary(execution)) diff --git a/backend/app/api/v1/missions.py b/backend/app/api/v1/missions.py new file mode 100644 index 000000000..de735d550 --- /dev/null +++ b/backend/app/api/v1/missions.py @@ -0,0 +1,174 @@ +"""Missions API.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.common.dependencies import CurrentUser +from app.common.exceptions import BadRequestException +from app.core.database import get_db +from app.models.mission import Mission, MissionPriority +from app.schemas import BaseResponse +from app.schemas.execution import ( + AssignMissionRequest, + CreateMissionRequest, + DispatchMissionRequest, + MissionListResponse, + MissionSummary, + UpdateMissionRequest, +) +from app.services.mission_service import MissionService + +router = APIRouter(prefix="/v1/missions", tags=["Missions"]) + + +def _to_summary(m: Mission) -> MissionSummary: + return MissionSummary( + id=m.id, + workspace_id=m.workspace_id, + title=m.title, + status=m.status.value if hasattr(m.status, "value") else str(m.status), + priority=m.priority.value if hasattr(m.priority, "value") else str(m.priority), + assignee_type=m.assignee_type, + assignee_id=m.assignee_id, + creator_id=m.creator_id, + current_execution_id=m.current_execution_id, + parent_mission_id=m.parent_mission_id, + tags=m.tags, + position=m.position, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +@router.get("", response_model=BaseResponse[MissionListResponse]) +async def list_missions( + current_user: CurrentUser, + workspace_id: uuid.UUID = Query(...), + status: str | None = Query(None), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[MissionListResponse]: + service = MissionService(db) + missions = await service.list_missions( + workspace_id=workspace_id, + status=status, + limit=limit, + ) + return BaseResponse( + success=True, code=200, msg="ok", + data=MissionListResponse(items=[_to_summary(m) for m in missions]), + ) + + +@router.post("", response_model=BaseResponse[MissionSummary]) +async def create_mission( + request: CreateMissionRequest, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +) -> BaseResponse[MissionSummary]: + service = MissionService(db) + try: + priority = MissionPriority(request.priority) + except ValueError: + raise BadRequestException(f"Invalid priority: {request.priority}") + mission = await service.create_mission( + workspace_id=request.workspace_id, + creator_id=str(current_user.id), + title=request.title, + description=request.description, + objective=request.objective, + priority=priority, + parent_mission_id=request.parent_mission_id, + tags=request.tags, + position=request.position, + ) + return BaseResponse(success=True, code=200, msg="Mission created", data=_to_summary(mission)) + + +@router.get("/{mission_id}", response_model=BaseResponse[MissionSummary]) +async def get_mission( + mission_id: uuid.UUID, + workspace_id: uuid.UUID = Query(...), + current_user: CurrentUser = Depends(), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[MissionSummary]: + service = MissionService(db) + mission = await service.get_mission(mission_id, workspace_id) + if not mission: + return BaseResponse(success=False, code=404, msg="Mission not found", data=None) + return BaseResponse(success=True, code=200, msg="ok", data=_to_summary(mission)) + + +@router.patch("/{mission_id}", response_model=BaseResponse[MissionSummary]) +async def update_mission( + mission_id: uuid.UUID, + request: UpdateMissionRequest, + workspace_id: uuid.UUID = Query(...), + current_user: CurrentUser = Depends(), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[MissionSummary]: + service = MissionService(db) + updates = request.model_dump(exclude_unset=True) + mission = await service.update_mission(mission_id, workspace_id, **updates) + if not mission: + return BaseResponse(success=False, code=404, msg="Mission not found", data=None) + return BaseResponse(success=True, code=200, msg="Mission updated", data=_to_summary(mission)) + + +@router.post("/{mission_id}/assign", response_model=BaseResponse[MissionSummary]) +async def assign_mission( + mission_id: uuid.UUID, + request: AssignMissionRequest, + workspace_id: uuid.UUID = Query(...), + current_user: CurrentUser = Depends(), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[MissionSummary]: + service = MissionService(db) + try: + mission = await service.assign_to_agent( + mission_id=mission_id, + workspace_id=workspace_id, + agent_profile_id=request.agent_profile_id, + ) + except ValueError as exc: + raise BadRequestException(str(exc)) + return BaseResponse(success=True, code=200, msg="Mission assigned", data=_to_summary(mission)) + + +@router.post("/{mission_id}/dispatch", response_model=BaseResponse[MissionSummary]) +async def dispatch_mission( + mission_id: uuid.UUID, + request: DispatchMissionRequest, + workspace_id: uuid.UUID = Query(...), + current_user: CurrentUser = Depends(), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[MissionSummary]: + service = MissionService(db) + try: + mission, _execution = await service.dispatch_mission( + mission_id=mission_id, + workspace_id=workspace_id, + user_id=str(current_user.id), + runtime_config=request.runtime_config, + ) + except ValueError as exc: + raise BadRequestException(str(exc)) + return BaseResponse(success=True, code=200, msg="Mission dispatched", data=_to_summary(mission)) + + +@router.post("/{mission_id}/cancel", response_model=BaseResponse[MissionSummary]) +async def cancel_mission( + mission_id: uuid.UUID, + workspace_id: uuid.UUID = Query(...), + current_user: CurrentUser = Depends(), + db: AsyncSession = Depends(get_db), +) -> BaseResponse[MissionSummary]: + service = MissionService(db) + mission = await service.cancel_mission(mission_id=mission_id, workspace_id=workspace_id) + if not mission: + return BaseResponse(success=False, code=404, msg="Mission not found", data=None) + return BaseResponse(success=True, code=200, msg="Mission cancelled", data=_to_summary(mission)) diff --git a/backend/app/main.py b/backend/app/main.py index 7ef2b5f0f..548d31089 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,7 @@ from app.websocket.chat_ws_handler import ChatWsHandler from app.websocket.notification_manager import NotificationType, notification_manager from app.websocket.openclaw_handler import openclaw_bridge_handler +from app.websocket.execution_subscription_handler import execution_subscription_handler from app.websocket.run_subscription_handler import run_subscription_handler setup_logging() @@ -191,6 +192,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: logger.warning(f" ⚠️ Checkpointer initialization failed: {e}") logger.warning(" App will continue starting, checkpoint features may be unavailable") + # Initialize CLI runtime providers + try: + from app.core.agent.cli_backends.registry import init_providers + + init_providers() + logger.info(" ✓ CLI runtime providers initialized") + except Exception as e: + logger.warning(f" ⚠️ CLI runtime provider initialization failed: {e}") + yield # Shutdown: Drain sandbox pool (stop all containers gracefully) @@ -347,6 +357,18 @@ async def runs_websocket_endpoint(websocket: WebSocket): await run_subscription_handler.handle_connection(websocket, str(user_id)) +@app.websocket("/ws/executions") +async def executions_websocket_endpoint(websocket: WebSocket): + """Subscription endpoint for CLI execution snapshot/replay/live events.""" + is_authenticated, user_id = await authenticate_websocket(websocket) + + if not is_authenticated or not user_id: + await reject_websocket(websocket, code=WebSocketCloseCode.UNAUTHORIZED, reason="Authentication required") + return + + await execution_subscription_handler.handle_connection(websocket, str(user_id)) + + @app.websocket("/ws/openclaw/dashboard") async def openclaw_dashboard_websocket_endpoint(websocket: WebSocket): """WebSocket proxy for Control UI — auth from cookie, no user_id in path.""" diff --git a/backend/app/schemas/execution.py b/backend/app/schemas/execution.py new file mode 100644 index 000000000..92b0e5f83 --- /dev/null +++ b/backend/app/schemas/execution.py @@ -0,0 +1,165 @@ +""" +Pydantic schemas for Mission, AgentProfile, and Execution APIs. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Mission +# --------------------------------------------------------------------------- + +class CreateMissionRequest(BaseModel): + workspace_id: uuid.UUID + title: str = Field(..., max_length=500) + description: Optional[str] = None + objective: Optional[str] = None + priority: str = "none" + parent_mission_id: Optional[uuid.UUID] = None + tags: Optional[list[str]] = None + position: float = 0.0 + + +class UpdateMissionRequest(BaseModel): + title: Optional[str] = Field(None, max_length=500) + description: Optional[str] = None + objective: Optional[str] = None + priority: Optional[str] = None + status: Optional[str] = None + assignee_type: Optional[str] = None + assignee_id: Optional[uuid.UUID] = None + parent_mission_id: Optional[uuid.UUID] = None + due_date: Optional[datetime] = None + position: Optional[float] = None + tags: Optional[list[str]] = None + + +class AssignMissionRequest(BaseModel): + agent_profile_id: uuid.UUID + + +class DispatchMissionRequest(BaseModel): + runtime_config: Optional[dict[str, Any]] = None + + +class MissionSummary(BaseModel): + id: uuid.UUID + workspace_id: uuid.UUID + title: str + status: str + priority: str + assignee_type: Optional[str] = None + assignee_id: Optional[uuid.UUID] = None + creator_id: str + current_execution_id: Optional[uuid.UUID] = None + parent_mission_id: Optional[uuid.UUID] = None + tags: Optional[list[str]] = None + position: float + created_at: datetime + updated_at: datetime + + +class MissionListResponse(BaseModel): + items: list[MissionSummary] + + +# --------------------------------------------------------------------------- +# AgentProfile +# --------------------------------------------------------------------------- + +class CreateAgentProfileRequest(BaseModel): + workspace_id: uuid.UUID + name: str = Field(..., max_length=255) + runtime_type: str = Field(..., max_length=50) + description: Optional[str] = None + avatar: Optional[str] = None + instructions: Optional[str] = None + skill_ids: Optional[list[str]] = None + custom_env: Optional[dict[str, Any]] = None + runtime_config: Optional[dict[str, Any]] = None + max_concurrent_tasks: int = 1 + + +class UpdateAgentProfileRequest(BaseModel): + name: Optional[str] = Field(None, max_length=255) + description: Optional[str] = None + avatar: Optional[str] = None + instructions: Optional[str] = None + skill_ids: Optional[list[str]] = None + custom_env: Optional[dict[str, Any]] = None + runtime_config: Optional[dict[str, Any]] = None + max_concurrent_tasks: Optional[int] = None + runtime_type: Optional[str] = None + visibility: Optional[str] = None + + +class AgentProfileSummary(BaseModel): + id: uuid.UUID + workspace_id: uuid.UUID + name: str + runtime_type: str + status: str + description: Optional[str] = None + avatar: Optional[str] = None + max_concurrent_tasks: int + created_at: datetime + updated_at: datetime + + +class AgentProfileListResponse(BaseModel): + items: list[AgentProfileSummary] + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + +class ExecutionSummary(BaseModel): + id: uuid.UUID + workspace_id: uuid.UUID + user_id: str + source: str + status: str + title: Optional[str] = None + mission_id: Optional[uuid.UUID] = None + agent_profile_id: Optional[uuid.UUID] = None + runtime_type: str + container_id: Optional[str] = None + session_id: Optional[str] = None + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + last_seq: int + error_code: Optional[str] = None + error_message: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class ExecutionListResponse(BaseModel): + items: list[ExecutionSummary] + + +class ExecutionSnapshotResponse(BaseModel): + execution_id: uuid.UUID + status: str + last_seq: int + projection: dict[str, Any] + + +class ExecutionEventResponse(BaseModel): + seq: int + event_type: str + payload: dict[str, Any] + created_at: datetime + + +class ExecutionEventsPageResponse(BaseModel): + execution_id: uuid.UUID + events: list[ExecutionEventResponse] + next_after_seq: int From 91564e1d68d02fce0e02551ec4a6ff7125aa3b4d Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:39:21 +0800 Subject: [PATCH 09/69] feat: add CLI agent Docker image with Claude Code pre-installed Node 22 slim base with Claude Code CLI, non-root agent user, git/curl/jq utilities. Added to docker-compose build profile alongside openclaw-image. Co-Authored-By: Claude Opus 4.6 (1M context) --- deploy/docker-compose.yml | 12 +++++++++ deploy/docker/cli-agent.Dockerfile | 41 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 deploy/docker/cli-agent.Dockerfile diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 20f4f7d7b..a85ddb47a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -208,6 +208,18 @@ services: condition: service_healthy + # CLI Agent base image build service (used by CLIContainerService for executions) + # This service only builds the image; it does not run as a long-lived container. + # Per-execution containers are dynamically created by CLIContainerService. + cli-agent-image: + image: joysafeter/cli-agent:latest + build: + context: .. + dockerfile: deploy/docker/cli-agent.Dockerfile + profiles: + - build + command: ["echo", "Image built successfully"] + # OpenClaw base image build service (used by backend to create per-user containers) # This service only builds the image; it does not run as a long-lived container. # Per-user OpenClaw instances are dynamically created by the backend's diff --git a/deploy/docker/cli-agent.Dockerfile b/deploy/docker/cli-agent.Dockerfile new file mode 100644 index 000000000..8c35afd4b --- /dev/null +++ b/deploy/docker/cli-agent.Dockerfile @@ -0,0 +1,41 @@ +# ============================================================================= +# CLI Agent Docker Image +# ============================================================================= +# Pre-installs Claude Code CLI for autonomous agent execution. +# Used by CLIContainerService to spin up per-execution containers. +# +# Build: +# docker build -f deploy/docker/cli-agent.Dockerfile -t joysafeter/cli-agent:latest . +# ============================================================================= + +FROM node:22-slim + +# System deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + ca-certificates \ + openssh-client \ + jq \ + && rm -rf /var/lib/apt/lists/* + +# Install Claude Code CLI globally +RUN npm install -g @anthropic-ai/claude-code + +# Create non-root user +RUN groupadd -r agent && useradd -r -g agent -m -d /home/agent -s /bin/bash agent + +# Workspace directory +RUN mkdir -p /workspace && chown agent:agent /workspace + +# Default working directory +WORKDIR /workspace + +# Switch to non-root user +USER agent + +# Verify installation +RUN claude --version || echo "Claude CLI installed" + +# Default entrypoint keeps container alive for docker exec +CMD ["sleep", "infinity"] From 3450d193f8998efb494e7a5da7787e4bd41841e9 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:43:46 +0800 Subject: [PATCH 10/69] feat: add integration tests for mission-to-execution pipeline 10 integration tests validating the full data flow: schema round-trips, prompt building, registry lifecycle, container injection pipeline, event sourcing with reducer, WebSocket subscription manager, message mapping, Claude Code NDJSON parsing, container lifecycle, and end-to-end data flow from mission to completed execution projection. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/tests/test_core/test_integration.py | 496 ++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 backend/tests/test_core/test_integration.py diff --git a/backend/tests/test_core/test_integration.py b/backend/tests/test_core/test_integration.py new file mode 100644 index 000000000..341581dbd --- /dev/null +++ b/backend/tests/test_core/test_integration.py @@ -0,0 +1,496 @@ +""" +Integration test: validates the full mission-to-execution pipeline. + +Exercises the complete flow without a live database or Docker daemon: + AgentProfile creation → Mission creation → assign to agent → + dispatch (creates execution) → verify event sourcing → verify reducer → + verify subscription manager → verify cleanup lifecycle. +""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.core.agent.cli_backends.base import CLIMessage, CLIResult, RuntimeSession +from app.core.agent.cli_backends.claude_code import ClaudeCodeProvider +from app.core.agent.cli_backends.container_service import ContainerConfig, ContainerInfo +from app.core.agent.cli_backends.execution_runner import ExecutionRunner +from app.core.agent.cli_backends.injectors import ( + CLISkillInjector, + CredentialInjector, + RuntimeConfigInjector, +) +from app.core.agent.cli_backends.registry import RuntimeProviderRegistry +from app.models.execution import ExecutionSource, ExecutionStatus +from app.models.mission import MissionPriority, MissionStatus +from app.schemas.execution import ( + AgentProfileListResponse, + AgentProfileSummary, + CreateAgentProfileRequest, + CreateMissionRequest, + ExecutionEventsPageResponse, + ExecutionEventResponse, + ExecutionListResponse, + ExecutionSnapshotResponse, + ExecutionSummary, + MissionListResponse, + MissionSummary, +) +from app.services.execution_reducer import apply_execution_event, make_initial_projection +from app.services.mission_service import build_execution_prompt +from app.websocket.execution_subscription_manager import ExecutionSubscriptionManager + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_ws(): + ws = MagicMock() + ws.send_text = AsyncMock() + return ws + + +class FakeContainerService: + """Records calls without touching Docker.""" + + def __init__(self): + self.calls: list[tuple[str, list]] = [] + self.created: list[str] = [] + self.removed: list[str] = [] + + async def create_container(self, *, execution_id, config=None, env=None): + cid = f"fake-ctr-{execution_id!s:.8}" + self.created.append(cid) + return ContainerInfo( + container_id=cid, name=f"cli-agent-{execution_id!s:.12}", + status="running", working_dir="/workspace", + ) + + async def exec_in_container(self, container_id, cmd, workdir=None): + self.calls.append((container_id, cmd)) + return "" + + async def remove_container(self, container_id, force=True): + self.removed.append(container_id) + + async def stop_container(self, container_id, timeout=10): + pass + + async def copy_to_container(self, container_id, src, dest): + pass + + +# --------------------------------------------------------------------------- +# 1. Schema validation +# --------------------------------------------------------------------------- + +def test_schemas_round_trip(): + """Verify Pydantic schemas serialize/deserialize correctly.""" + profile = AgentProfileSummary( + id=uuid.uuid4(), workspace_id=uuid.uuid4(), name="test-agent", + runtime_type="claude_code", status="idle", max_concurrent_tasks=1, + created_at="2025-01-01T00:00:00Z", updated_at="2025-01-01T00:00:00Z", + ) + data = profile.model_dump() + assert data["name"] == "test-agent" + assert data["runtime_type"] == "claude_code" + + mission = MissionSummary( + id=uuid.uuid4(), workspace_id=uuid.uuid4(), title="Fix bug", + status="todo", priority="high", creator_id="user-1", position=0.0, + created_at="2025-01-01T00:00:00Z", updated_at="2025-01-01T00:00:00Z", + ) + data = mission.model_dump() + assert data["title"] == "Fix bug" + + execution = ExecutionSummary( + id=uuid.uuid4(), workspace_id=uuid.uuid4(), user_id="user-1", + source="mission", status="running", runtime_type="claude_code", + last_seq=5, created_at="2025-01-01T00:00:00Z", + updated_at="2025-01-01T00:00:00Z", + ) + data = execution.model_dump() + assert data["source"] == "mission" + assert data["last_seq"] == 5 + + +# --------------------------------------------------------------------------- +# 2. Prompt building from mission +# --------------------------------------------------------------------------- + +def test_prompt_building_integration(): + """Verify prompt building produces valid agent instructions.""" + mission = MagicMock() + mission.title = "Implement OAuth2 login" + mission.description = "Add Google OAuth2 provider to the auth module." + mission.objective = "Users can log in with Google accounts." + mission.tags = ["auth", "oauth", "backend"] + + prompt = build_execution_prompt(mission) + + assert "# Mission: Implement OAuth2 login" in prompt + assert "Google OAuth2" in prompt + assert "Users can log in" in prompt + assert "auth" in prompt + assert "oauth" in prompt + + +# --------------------------------------------------------------------------- +# 3. Registry + provider lookup +# --------------------------------------------------------------------------- + +def test_registry_lifecycle(): + """Register a provider, look it up, list it.""" + reg = RuntimeProviderRegistry() + provider = ClaudeCodeProvider(executable_path="/usr/bin/claude") + reg.register(provider) + + assert reg.get("claude_code") is provider + assert "claude_code" in reg.list_providers() + + with pytest.raises(ValueError): + reg.get("nonexistent_provider") + + +# --------------------------------------------------------------------------- +# 4. Container + injection pipeline +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_container_injection_pipeline(): + """Create container → inject credentials → inject skills → inject config.""" + svc = FakeContainerService() + exec_id = uuid.uuid4() + + container = await svc.create_container(execution_id=exec_id) + assert container.status == "running" + assert container.container_id.startswith("fake-ctr-") + + # Inject credentials + cred_injector = CredentialInjector(svc) + await cred_injector.inject(container.container_id, { + "ANTHROPIC_API_KEY": "sk-test-key", + "GITHUB_TOKEN": "ghp-test", + }) + assert len(svc.calls) == 1 + + # Inject skills + skill_injector = CLISkillInjector(svc) + await skill_injector.inject(container.container_id, [ + {"name": "lint", "command": "ruff check ."}, + {"name": "test", "command": "pytest -x"}, + ]) + assert len(svc.calls) == 4 # 1 cred + 1 mkdir + 2 skills + + # Inject CLAUDE.md config + config_injector = RuntimeConfigInjector(svc) + await config_injector.inject( + container.container_id, + instructions="Always write tests.", + skill_names=["lint", "test"], + working_dir="/workspace", + ) + assert len(svc.calls) == 5 # + 1 config write + + +# --------------------------------------------------------------------------- +# 5. Event sourcing + reducer pipeline +# --------------------------------------------------------------------------- + +def test_event_sourcing_full_lifecycle(): + """Walk through a complete execution event sequence and verify projection.""" + proj = make_initial_projection( + {"source": "mission", "mission_id": "m-1", "agent_profile_id": "a-1"}, + "queued", + ) + assert proj["status"] == "queued" + assert proj["messages"] == [] + + # Execution starts + proj = apply_execution_event( + proj, event_type="execution_started", + payload={"container_id": "ctr-abc", "session_id": "s-1"}, + status="running", + ) + assert proj["container_id"] == "ctr-abc" + + # User prompt sent + proj = apply_execution_event( + proj, event_type="prompt_sent", + payload={"message": {"role": "user", "content": "Fix the login bug"}}, + status="running", + ) + assert len(proj["messages"]) == 1 + + # Agent thinks + proj = apply_execution_event( + proj, event_type="thinking", + payload={"content": "Let me analyze the auth module..."}, + status="running", + ) + assert proj["meta"]["last_thinking"] == "Let me analyze the auth module..." + + # Agent responds + proj = apply_execution_event( + proj, event_type="assistant_text", + payload={"message": {"role": "assistant", "content": "Found the issue", "id": "a1"}}, + status="running", + ) + assert len(proj["messages"]) == 2 + + # Content delta + proj = apply_execution_event( + proj, event_type="content_delta", + payload={"delta": " in auth.py", "message_id": "a1"}, + status="running", + ) + assert proj["messages"][-1]["content"] == "Found the issue in auth.py" + + # Tool use + proj = apply_execution_event( + proj, event_type="tool_use_start", + payload={"tool": {"name": "Edit", "call_id": "t1", "input": {"file": "auth.py"}, "status": "running"}}, + status="running", + ) + assert len(proj["tool_calls"]) == 1 + assert proj["tool_calls"][0]["status"] == "running" + + proj = apply_execution_event( + proj, event_type="tool_use_end", + payload={"call_id": "t1", "output": "File edited successfully"}, + status="running", + ) + assert proj["tool_calls"][0]["status"] == "completed" + + # Approval flow + proj = apply_execution_event( + proj, event_type="approval_requested", + payload={"tool": "Bash", "command": "git push"}, + status="approval_wait", + ) + assert "pending_approval" in proj["meta"] + + proj = apply_execution_event( + proj, event_type="approval_resolved", + payload={"approved": True}, + status="running", + ) + assert "pending_approval" not in proj["meta"] + + # Artifact + proj = apply_execution_event( + proj, event_type="artifact_created", + payload={"artifact": {"type": "file", "path": "/workspace/auth.py"}}, + status="running", + ) + assert len(proj["artifacts"]) == 1 + + # Completion + proj = apply_execution_event( + proj, event_type="execution_completed", + payload={"result_summary": {"files_changed": 2, "tests_passed": True}}, + status="completed", + ) + assert proj["status"] == "completed" + assert proj["meta"]["completed"] is True + assert proj["meta"]["result_summary"]["files_changed"] == 2 + + +# --------------------------------------------------------------------------- +# 6. WebSocket subscription manager +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_subscription_manager_full_flow(): + """Subscribe, broadcast, unsubscribe, disconnect.""" + mgr = ExecutionSubscriptionManager() + ws1 = _make_ws() + ws2 = _make_ws() + exec_id = str(uuid.uuid4()) + + # Subscribe both + await mgr.add_subscription(ws1, exec_id) + await mgr.add_subscription(ws2, exec_id) + + # Broadcast event + count = await mgr.broadcast_event(exec_id, { + "type": "event", "execution_id": exec_id, "seq": 1, + "event_type": "assistant_text", "data": {"content": "hello"}, + }) + assert count == 2 + + # Verify both received + assert ws1.send_text.call_count == 1 + assert ws2.send_text.call_count == 1 + + # Unsubscribe ws1 + mgr.remove_subscription(ws1, exec_id) + count = await mgr.broadcast_event(exec_id, {"type": "event", "seq": 2}) + assert count == 1 + assert ws2.send_text.call_count == 2 + + # Disconnect ws2 + mgr.disconnect(ws2) + count = await mgr.broadcast_event(exec_id, {"type": "event", "seq": 3}) + assert count == 0 + + +# --------------------------------------------------------------------------- +# 7. ExecutionRunner message mapping +# --------------------------------------------------------------------------- + +def test_runner_message_mapping_pipeline(): + """Verify CLIMessage → event_type + payload mapping for all message types.""" + test_cases = [ + (CLIMessage(type="text", content="hello"), "assistant_text", {"content": "hello"}), + (CLIMessage(type="thinking", content="hmm"), "thinking", {"content": "hmm"}), + ( + CLIMessage(type="tool_use", tool="Bash", call_id="c1", input={"command": "ls"}), + "tool_use_start", + {"tool": {"name": "Bash", "call_id": "c1", "input": {"command": "ls"}, "status": "running"}}, + ), + ( + CLIMessage(type="tool_result", tool="Bash", call_id="c1", output="file.txt"), + "tool_use_end", + {"call_id": "c1", "tool_name": "Bash", "output": "file.txt"}, + ), + (CLIMessage(type="error", content="OOM"), "error", {"message": "OOM"}), + (CLIMessage(type="artifact", content="data"), "artifact_created", {"artifact": {"content": "data"}}), + ] + + for msg, expected_type, expected_payload in test_cases: + assert ExecutionRunner._msg_to_event_type(msg) == expected_type + assert ExecutionRunner._msg_to_payload(msg) == expected_payload + + +# --------------------------------------------------------------------------- +# 8. Claude Code NDJSON parsing +# --------------------------------------------------------------------------- + +def test_claude_code_ndjson_parsing(): + """Verify ClaudeCodeProvider parses a realistic NDJSON event stream.""" + provider = ClaudeCodeProvider() + + events = [ + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Let me look at the code"}, + {"type": "text", "text": "I'll fix the bug now"}, + {"type": "tool_use", "name": "Bash", "id": "t1", "input": {"command": "grep -r 'login' ."}}, + ] + }, + }, + { + "type": "tool_result", + "tool": "Bash", + "call_id": "t1", + "output": "auth/login.py:42: def login():", + }, + { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Found it. Applying fix..."}, + ] + }, + }, + ] + + all_messages = [] + for event in events: + all_messages.extend(provider._parse_event(event)) + + assert len(all_messages) == 5 + assert all_messages[0].type == "thinking" + assert all_messages[1].type == "text" + assert all_messages[1].content == "I'll fix the bug now" + assert all_messages[2].type == "tool_use" + assert all_messages[2].tool == "Bash" + assert all_messages[3].type == "tool_result" + assert "auth/login.py" in all_messages[3].output + assert all_messages[4].type == "text" + + +# --------------------------------------------------------------------------- +# 9. Container lifecycle tracking +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_container_lifecycle(): + """Create → use → remove container, verify tracking.""" + svc = FakeContainerService() + exec_id = uuid.uuid4() + + # Create + container = await svc.create_container(execution_id=exec_id) + assert len(svc.created) == 1 + + # Use (exec commands) + await svc.exec_in_container(container.container_id, ["echo", "hello"]) + assert len(svc.calls) == 1 + + # Remove + await svc.remove_container(container.container_id) + assert len(svc.removed) == 1 + assert svc.removed[0] == container.container_id + + +# --------------------------------------------------------------------------- +# 10. End-to-end data flow validation +# --------------------------------------------------------------------------- + +def test_end_to_end_data_flow(): + """Validate the complete data flow from mission to execution result. + + This test verifies that data flows correctly through all layers: + Mission → prompt → execution events → reducer → final projection. + """ + # 1. Build prompt from mission + mission = MagicMock() + mission.title = "Add rate limiting" + mission.description = "Implement token bucket rate limiter for API endpoints." + mission.objective = "Prevent API abuse." + mission.tags = ["security", "api"] + + prompt = build_execution_prompt(mission) + assert "rate limiting" in prompt.lower() + + # 2. Initialize projection + proj = make_initial_projection( + {"source": "mission", "mission_id": "m-1"}, + "queued", + ) + + # 3. Simulate execution events + events = [ + ("execution_started", {"container_id": "ctr-1", "session_id": "s-1"}, "running"), + ("prompt_sent", {"message": {"role": "user", "content": prompt}}, "running"), + ("assistant_text", {"message": {"role": "assistant", "content": "Implementing rate limiter", "id": "a1"}}, "running"), + ("tool_use_start", {"tool": {"name": "Write", "call_id": "t1", "input": {"file": "rate_limiter.py"}, "status": "running"}}, "running"), + ("tool_use_end", {"call_id": "t1", "output": "File written"}, "running"), + ("execution_completed", {"result_summary": {"files_created": 1}}, "completed"), + ] + + for event_type, payload, status in events: + proj = apply_execution_event(proj, event_type=event_type, payload=payload, status=status) + + # 4. Verify final state + assert proj["status"] == "completed" + assert proj["container_id"] == "ctr-1" + assert proj["session_id"] == "s-1" + assert len(proj["messages"]) == 2 # user prompt + assistant text + assert len(proj["tool_calls"]) == 1 + assert proj["tool_calls"][0]["status"] == "completed" + assert proj["meta"]["completed"] is True + assert proj["meta"]["result_summary"]["files_created"] == 1 + + # 5. Verify immutability — original events didn't mutate + assert events[0][1]["container_id"] == "ctr-1" # unchanged From b1099b8fd72a6f932fee77da28b54222fd9b5228 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 15:56:53 +0800 Subject: [PATCH 11/69] feat: implement mission-driven multi-agent execution architecture with new models and services --- ...0a9_add_mission_agent_execution_tables.py} | 54 ++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) rename backend/alembic/versions/{20260415_000000_a1b2c3d4e5f6_add_mission_agent_execution_tables.py => 20260415_000000_b4b3b2b1b0a9_add_mission_agent_execution_tables.py} (80%) diff --git a/backend/alembic/versions/20260415_000000_a1b2c3d4e5f6_add_mission_agent_execution_tables.py b/backend/alembic/versions/20260415_000000_b4b3b2b1b0a9_add_mission_agent_execution_tables.py similarity index 80% rename from backend/alembic/versions/20260415_000000_a1b2c3d4e5f6_add_mission_agent_execution_tables.py rename to backend/alembic/versions/20260415_000000_b4b3b2b1b0a9_add_mission_agent_execution_tables.py index ec2736f22..d95cde485 100644 --- a/backend/alembic/versions/20260415_000000_a1b2c3d4e5f6_add_mission_agent_execution_tables.py +++ b/backend/alembic/versions/20260415_000000_b4b3b2b1b0a9_add_mission_agent_execution_tables.py @@ -1,6 +1,6 @@ """add_mission_agent_execution_tables -Revision ID: a1b2c3d4e5f6 +Revision ID: b4b3b2b1b0a9 Revises: 0f7082711f20 Create Date: 2026-04-15 00:00:00.000000 @@ -14,7 +14,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = "a1b2c3d4e5f6" +revision: str = "b4b3b2b1b0a9" down_revision: Union[str, None] = "0f7082711f20" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -22,11 +22,41 @@ def upgrade() -> None: # --- Enum types --- - op.execute("CREATE TYPE missionstatus AS ENUM ('backlog','todo','in_progress','in_review','done','blocked','cancelled')") - op.execute("CREATE TYPE missionpriority AS ENUM ('none','low','medium','high','urgent')") - op.execute("CREATE TYPE agentstatus AS ENUM ('idle','working','blocked','error','offline')") - op.execute("CREATE TYPE executionstatus_v2 AS ENUM ('queued','dispatched','running','interrupt_wait','approval_wait','completed','failed','cancelled')") - op.execute("CREATE TYPE executionsource AS ENUM ('mission','chat','graph','coordinator','api')") + op.execute(""" + DO $$ BEGIN + CREATE TYPE missionstatus AS ENUM ('backlog','todo','in_progress','in_review','done','blocked','cancelled'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + op.execute(""" + DO $$ BEGIN + CREATE TYPE missionpriority AS ENUM ('none','low','medium','high','urgent'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + op.execute(""" + DO $$ BEGIN + CREATE TYPE agentstatus AS ENUM ('idle','working','blocked','error','offline'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + op.execute(""" + DO $$ BEGIN + CREATE TYPE executionstatus_v2 AS ENUM ('queued','dispatched','running','interrupt_wait','approval_wait','completed','failed','cancelled'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + op.execute(""" + DO $$ BEGIN + CREATE TYPE executionsource AS ENUM ('mission','chat','graph','coordinator','api'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) # --- missions --- op.create_table( @@ -36,8 +66,8 @@ def upgrade() -> None: sa.Column("title", sa.String(500), nullable=False), sa.Column("description", sa.Text(), nullable=True), sa.Column("objective", sa.Text(), nullable=True), - sa.Column("status", sa.Enum("backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled", name="missionstatus", create_type=False), nullable=False, server_default="backlog"), - sa.Column("priority", sa.Enum("none", "low", "medium", "high", "urgent", name="missionpriority", create_type=False), nullable=False, server_default="none"), + sa.Column("status", postgresql.ENUM("backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled", name="missionstatus", create_type=False), nullable=False, server_default="backlog"), + sa.Column("priority", postgresql.ENUM("none", "low", "medium", "high", "urgent", name="missionpriority", create_type=False), nullable=False, server_default="none"), sa.Column("assignee_type", sa.String(50), nullable=True), sa.Column("assignee_id", postgresql.UUID(as_uuid=True), nullable=True), sa.Column("creator_id", sa.String(255), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False), @@ -62,7 +92,7 @@ def upgrade() -> None: sa.Column("avatar", sa.String(500), nullable=True), sa.Column("description", sa.Text(), nullable=True), sa.Column("runtime_type", sa.String(50), nullable=False), - sa.Column("status", sa.Enum("idle", "working", "blocked", "error", "offline", name="agentstatus", create_type=False), nullable=False, server_default="offline"), + sa.Column("status", postgresql.ENUM("idle", "working", "blocked", "error", "offline", name="agentstatus", create_type=False), nullable=False, server_default="offline"), sa.Column("max_concurrent_tasks", sa.Integer(), nullable=False, server_default="1"), sa.Column("skill_ids", postgresql.JSONB(), nullable=True), sa.Column("instructions", sa.Text(), nullable=True), @@ -81,9 +111,9 @@ def upgrade() -> None: sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("workspace_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), sa.Column("user_id", sa.String(255), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False), - sa.Column("source", sa.Enum("mission", "chat", "graph", "coordinator", "api", name="executionsource", create_type=False), nullable=False), + sa.Column("source", postgresql.ENUM("mission", "chat", "graph", "coordinator", "api", name="executionsource", create_type=False), nullable=False), sa.Column("source_id", sa.String(255), nullable=True), - sa.Column("status", sa.Enum("queued", "dispatched", "running", "interrupt_wait", "approval_wait", "completed", "failed", "cancelled", name="executionstatus_v2", create_type=False), nullable=False, server_default="queued"), + sa.Column("status", postgresql.ENUM("queued", "dispatched", "running", "interrupt_wait", "approval_wait", "completed", "failed", "cancelled", name="executionstatus_v2", create_type=False), nullable=False, server_default="queued"), sa.Column("title", sa.String(500), nullable=True), sa.Column("mission_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("missions.id", ondelete="SET NULL"), nullable=True), sa.Column("agent_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("agent_profiles.id", ondelete="SET NULL"), nullable=True), From 7342c710c23f333f4a7158caf99c446101524fac Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 16:31:42 +0800 Subject: [PATCH 12/69] =?UTF-8?q?fix:=20address=20critical=20review=20issu?= =?UTF-8?q?es=20=E2=80=94=20credential=20handling,=20enum=20naming,=20disp?= =?UTF-8?q?atch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: Remove CredentialInjector.inject() file-writing logic that wrote plaintext API keys to /etc/environment. The class now only builds the env dict. C2: Rename ExecutionStatus → MissionExecutionStatus in execution.py to avoid collision with graph_execution.py's ExecutionStatus. Updated all imports, the Alembic migration enum name, and removed the AgentExecutionStatus alias. C3: Switch container_service.create_container from -e flags to --env-file with a temp file (mode 0600) deleted after creation, so API keys no longer appear in the process tree. Removed env parameter from container_bridge exec_streaming since credentials are set at container creation time. I2: dispatch_mission now launches an ExecutionRunner via asyncio.create_task so executions actually start instead of staying QUEUED forever. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...b0a9_add_mission_agent_execution_tables.py | 6 ++-- backend/app/api/v1/executions.py | 4 +-- .../agent/cli_backends/container_bridge.py | 4 --- .../agent/cli_backends/container_service.py | 34 +++++++++++++++--- .../agent/cli_backends/execution_runner.py | 26 +++++--------- .../app/core/agent/cli_backends/injectors.py | 27 +++++--------- backend/app/models/__init__.py | 4 +-- backend/app/models/execution.py | 8 ++--- backend/app/repositories/execution.py | 4 +-- backend/app/services/execution_service.py | 12 +++---- backend/app/services/mission_service.py | 35 +++++++++++++++++-- .../test_core/test_container_injectors.py | 21 ++++------- backend/tests/test_core/test_integration.py | 14 ++++---- backend/tests/test_models/test_execution.py | 8 ++--- 14 files changed, 117 insertions(+), 90 deletions(-) diff --git a/backend/alembic/versions/20260415_000000_b4b3b2b1b0a9_add_mission_agent_execution_tables.py b/backend/alembic/versions/20260415_000000_b4b3b2b1b0a9_add_mission_agent_execution_tables.py index d95cde485..7f56cb15f 100644 --- a/backend/alembic/versions/20260415_000000_b4b3b2b1b0a9_add_mission_agent_execution_tables.py +++ b/backend/alembic/versions/20260415_000000_b4b3b2b1b0a9_add_mission_agent_execution_tables.py @@ -45,7 +45,7 @@ def upgrade() -> None: """) op.execute(""" DO $$ BEGIN - CREATE TYPE executionstatus_v2 AS ENUM ('queued','dispatched','running','interrupt_wait','approval_wait','completed','failed','cancelled'); + CREATE TYPE missionexecutionstatus AS ENUM ('queued','dispatched','running','interrupt_wait','approval_wait','completed','failed','cancelled'); EXCEPTION WHEN duplicate_object THEN null; END $$; @@ -113,7 +113,7 @@ def upgrade() -> None: sa.Column("user_id", sa.String(255), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False), sa.Column("source", postgresql.ENUM("mission", "chat", "graph", "coordinator", "api", name="executionsource", create_type=False), nullable=False), sa.Column("source_id", sa.String(255), nullable=True), - sa.Column("status", postgresql.ENUM("queued", "dispatched", "running", "interrupt_wait", "approval_wait", "completed", "failed", "cancelled", name="executionstatus_v2", create_type=False), nullable=False, server_default="queued"), + sa.Column("status", postgresql.ENUM("queued", "dispatched", "running", "interrupt_wait", "approval_wait", "completed", "failed", "cancelled", name="missionexecutionstatus", create_type=False), nullable=False, server_default="queued"), sa.Column("title", sa.String(500), nullable=True), sa.Column("mission_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("missions.id", ondelete="SET NULL"), nullable=True), sa.Column("agent_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("agent_profiles.id", ondelete="SET NULL"), nullable=True), @@ -176,7 +176,7 @@ def downgrade() -> None: op.drop_table("agent_profiles") op.drop_table("missions") op.execute("DROP TYPE IF EXISTS executionsource") - op.execute("DROP TYPE IF EXISTS executionstatus_v2") + op.execute("DROP TYPE IF EXISTS missionexecutionstatus") op.execute("DROP TYPE IF EXISTS agentstatus") op.execute("DROP TYPE IF EXISTS missionpriority") op.execute("DROP TYPE IF EXISTS missionstatus") diff --git a/backend/app/api/v1/executions.py b/backend/app/api/v1/executions.py index 5ef68cf3f..c752cce39 100644 --- a/backend/app/api/v1/executions.py +++ b/backend/app/api/v1/executions.py @@ -9,7 +9,7 @@ from app.common.dependencies import CurrentUser from app.core.database import get_db -from app.models.execution import Execution, ExecutionStatus +from app.models.execution import Execution, MissionExecutionStatus from app.schemas import BaseResponse from app.schemas.execution import ( ExecutionEventsPageResponse, @@ -145,7 +145,7 @@ async def cancel_execution( execution = await service.mark_status( execution_id=execution_id, user_id=str(current_user.id), - status=ExecutionStatus.CANCELLED, + status=MissionExecutionStatus.CANCELLED, error_code="cancelled", error_message="Cancelled by user", ) diff --git a/backend/app/core/agent/cli_backends/container_bridge.py b/backend/app/core/agent/cli_backends/container_bridge.py index dfc252e22..3061d2f05 100644 --- a/backend/app/core/agent/cli_backends/container_bridge.py +++ b/backend/app/core/agent/cli_backends/container_bridge.py @@ -10,15 +10,11 @@ async def exec_streaming( self, container_id: str, cmd: list[str], - env: dict[str, str] | None = None, workdir: str | None = None, ) -> asyncio.subprocess.Process: docker_cmd = ["docker", "exec", "-i"] if workdir: docker_cmd.extend(["-w", workdir]) - if env: - for k, v in env.items(): - docker_cmd.extend(["-e", f"{k}={v}"]) docker_cmd.append(container_id) docker_cmd.extend(cmd) diff --git a/backend/app/core/agent/cli_backends/container_service.py b/backend/app/core/agent/cli_backends/container_service.py index a39a55ee1..2813fb6ea 100644 --- a/backend/app/core/agent/cli_backends/container_service.py +++ b/backend/app/core/agent/cli_backends/container_service.py @@ -5,6 +5,8 @@ from __future__ import annotations import asyncio +import os +import tempfile import uuid from dataclasses import dataclass, field from typing import Optional @@ -57,15 +59,25 @@ async def create_container( for k, v in cfg.labels.items(): docker_cmd.extend(["--label", f"{k}={v}"]) docker_cmd.extend(["--label", f"execution_id={execution_id}"]) + + env_file_path: Optional[str] = None if env: - for k, v in env.items(): - docker_cmd.extend(["-e", f"{k}={v}"]) + env_file_path = self._write_env_file(env) + docker_cmd.extend(["--env-file", env_file_path]) + docker_cmd.append(cfg.image) docker_cmd.append("sleep") docker_cmd.append("infinity") - container_id = await self._run_docker(docker_cmd) - container_id = container_id.strip() + try: + container_id = await self._run_docker(docker_cmd) + container_id = container_id.strip() + finally: + if env_file_path: + try: + os.unlink(env_file_path) + except OSError: + pass await self._run_docker(["docker", "start", container_id]) @@ -77,6 +89,20 @@ async def create_container( working_dir=cfg.working_dir, ) + @staticmethod + def _write_env_file(env: dict[str, str]) -> str: + """Write env vars to a temp file (mode 0600) and return its path.""" + fd, path = tempfile.mkstemp(prefix="cli_agent_env_", suffix=".env") + try: + with os.fdopen(fd, "w") as f: + for k, v in env.items(): + f.write(f"{k}={v}\n") + os.chmod(path, 0o600) + except Exception: + os.unlink(path) + raise + return path + async def stop_container(self, container_id: str, timeout: int = 10) -> None: try: await self._run_docker( diff --git a/backend/app/core/agent/cli_backends/execution_runner.py b/backend/app/core/agent/cli_backends/execution_runner.py index a1f1ecde4..8bbdcffcb 100644 --- a/backend/app/core/agent/cli_backends/execution_runner.py +++ b/backend/app/core/agent/cli_backends/execution_runner.py @@ -26,12 +26,11 @@ ) from app.core.agent.cli_backends.injectors import ( CLISkillInjector, - CredentialInjector, RuntimeConfigInjector, ) from app.core.agent.cli_backends.registry import runtime_registry from app.models.agent_profile import AgentProfile, AgentStatus -from app.models.execution import Execution, ExecutionStatus +from app.models.execution import Execution, MissionExecutionStatus from app.repositories.agent_profile import AgentProfileRepository from app.services.execution_service import ExecutionService from app.utils.datetime import utc_now @@ -73,7 +72,7 @@ async def run( # 1. Mark as dispatched await self.execution_service.mark_status( execution_id=execution_id, - status=ExecutionStatus.DISPATCHED, + status=MissionExecutionStatus.DISPATCHED, ) # 2. Create container @@ -84,14 +83,13 @@ async def run( ) await self.execution_service.mark_status( execution_id=execution_id, - status=ExecutionStatus.RUNNING, + status=MissionExecutionStatus.RUNNING, container_id=container.container_id, ) - # 3. Inject credentials, skills, config + # 3. Inject skills and config (credentials already set via --env-file) await self._inject( container_id=container.container_id, - credentials=credentials, skills=skills, agent_profile=agent_profile, working_dir=container.working_dir, @@ -116,7 +114,6 @@ async def run( model=model, timeout=timeout, resume_session_id=execution.prior_session_id, - env=credentials, ) # 6. Drain messages → events @@ -163,18 +160,13 @@ async def _inject( self, *, container_id: str, - credentials: Optional[dict[str, str]], skills: Optional[list[dict[str, Any]]], agent_profile: Optional[AgentProfile], working_dir: str, ) -> None: - cred_injector = CredentialInjector(self.container_service) skill_injector = CLISkillInjector(self.container_service) config_injector = RuntimeConfigInjector(self.container_service) - if credentials: - await cred_injector.inject(container_id, credentials) - if skills: await skill_injector.inject(container_id, skills) @@ -214,15 +206,15 @@ async def _finalize( agent_profile: Optional[AgentProfile], ) -> None: if result.status == "completed": - status = ExecutionStatus.COMPLETED + status = MissionExecutionStatus.COMPLETED elif result.status == "timeout": - status = ExecutionStatus.FAILED + status = MissionExecutionStatus.FAILED else: - status = ExecutionStatus.FAILED + status = MissionExecutionStatus.FAILED await self.execution_service.append_event( execution_id=execution_id, - event_type="execution_completed" if status == ExecutionStatus.COMPLETED else "error", + event_type="execution_completed" if status == MissionExecutionStatus.COMPLETED else "error", payload={ "result_summary": {"output_length": len(result.output)}, "message": result.error or "", @@ -254,7 +246,7 @@ async def _mark_failed( ) await self.execution_service.mark_status( execution_id=execution_id, - status=ExecutionStatus.FAILED, + status=MissionExecutionStatus.FAILED, error_message=error[:2000], ) except Exception as exc: diff --git a/backend/app/core/agent/cli_backends/injectors.py b/backend/app/core/agent/cli_backends/injectors.py index be99d7d80..7d5999ebf 100644 --- a/backend/app/core/agent/cli_backends/injectors.py +++ b/backend/app/core/agent/cli_backends/injectors.py @@ -5,7 +5,6 @@ from __future__ import annotations import json -import textwrap from typing import Any, Optional from loguru import logger @@ -14,26 +13,16 @@ class CredentialInjector: - """Injects API keys into a container as environment variables.""" + """Builds the env dict for CLI agent containers. - def __init__(self, container_service: CLIContainerService): - self.container_service = container_service + Credentials are passed to ``create_container()`` which sets them via + Docker's ``-e`` / ``--env-file`` flags. This class only resolves and + returns the dict — it never writes to the container filesystem. + """ - async def inject( - self, - container_id: str, - credentials: dict[str, str], - ) -> None: - """Write credentials as env vars in the container's /etc/environment.""" - if not credentials: - return - lines = [f"{k}={v}" for k, v in credentials.items()] - content = "\n".join(lines) + "\n" - await self.container_service.exec_in_container( - container_id, - ["sh", "-c", f"cat >> /etc/environment << 'ENVEOF'\n{content}ENVEOF"], - ) - logger.debug(f"Injected {len(credentials)} credentials into {container_id[:12]}") + def build_env(self, credentials: dict[str, str]) -> dict[str, str]: + """Return a sanitised copy of *credentials* suitable for Docker env.""" + return dict(credentials) if credentials else {} class CLISkillInjector: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d14456ccc..f496ee99a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -46,7 +46,7 @@ from .workspace import Workspace, WorkspaceFolder, WorkspaceMember, WorkspaceMemberRole, WorkspaceStatus from .agent_profile import AgentProfile, AgentStatus from .execution import Execution, ExecutionEvent, ExecutionSnapshot, ExecutionSource -from .execution import ExecutionStatus as AgentExecutionStatus +from .execution import MissionExecutionStatus from .mission import Mission, MissionPriority, MissionStatus from .workspace_files import WorkspaceFile, WorkspaceStoredFile @@ -119,5 +119,5 @@ "ExecutionEvent", "ExecutionSnapshot", "ExecutionSource", - "AgentExecutionStatus", + "MissionExecutionStatus", ] diff --git a/backend/app/models/execution.py b/backend/app/models/execution.py index 97d284243..26f07374d 100644 --- a/backend/app/models/execution.py +++ b/backend/app/models/execution.py @@ -15,7 +15,7 @@ from .base import BaseModel, TimestampMixin -class ExecutionStatus(str, enum.Enum): +class MissionExecutionStatus(str, enum.Enum): QUEUED = "queued" DISPATCHED = "dispatched" RUNNING = "running" @@ -54,10 +54,10 @@ class Execution(BaseModel): ) source_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) - status: Mapped[ExecutionStatus] = mapped_column( - Enum(ExecutionStatus, values_callable=lambda e: [m.value for m in e], name="executionstatus_v2"), + status: Mapped[MissionExecutionStatus] = mapped_column( + Enum(MissionExecutionStatus, values_callable=lambda e: [m.value for m in e], name="missionexecutionstatus"), nullable=False, - default=ExecutionStatus.QUEUED, + default=MissionExecutionStatus.QUEUED, ) title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) diff --git a/backend/app/repositories/execution.py b/backend/app/repositories/execution.py index efd6cc5cc..ad8d8748e 100644 --- a/backend/app/repositories/execution.py +++ b/backend/app/repositories/execution.py @@ -11,7 +11,7 @@ from sqlalchemy import and_, desc, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.models.execution import Execution, ExecutionEvent, ExecutionSnapshot, ExecutionStatus +from app.models.execution import Execution, ExecutionEvent, ExecutionSnapshot, MissionExecutionStatus from .base import BaseRepository @@ -81,7 +81,7 @@ async def list_by_workspace( async def list_recoverable_stale( self, *, stale_before: datetime ) -> Sequence[Execution]: - recoverable = (ExecutionStatus.QUEUED, ExecutionStatus.DISPATCHED, ExecutionStatus.RUNNING) + recoverable = (MissionExecutionStatus.QUEUED, MissionExecutionStatus.DISPATCHED, MissionExecutionStatus.RUNNING) result = await self.db.execute( select(Execution) .where( diff --git a/backend/app/services/execution_service.py b/backend/app/services/execution_service.py index 17743d5e0..cd7244205 100644 --- a/backend/app/services/execution_service.py +++ b/backend/app/services/execution_service.py @@ -15,7 +15,7 @@ ExecutionEvent, ExecutionSnapshot, ExecutionSource, - ExecutionStatus, + MissionExecutionStatus, ) from app.repositories.execution import ExecutionRepository from app.services.execution_reducer import apply_execution_event, make_initial_projection @@ -48,7 +48,7 @@ async def create_execution( user_id=user_id, source=source, source_id=source_id, - status=ExecutionStatus.QUEUED, + status=MissionExecutionStatus.QUEUED, title=title, mission_id=mission_id, agent_profile_id=agent_profile_id, @@ -121,7 +121,7 @@ async def mark_status( *, execution_id: uuid.UUID, user_id: Optional[str] = None, - status: ExecutionStatus, + status: MissionExecutionStatus, container_id: Optional[str] = None, session_id: Optional[str] = None, error_code: Optional[str] = None, @@ -145,9 +145,9 @@ async def mark_status( if result_summary is not None: execution.result_summary = result_summary - if status == ExecutionStatus.RUNNING and not execution.started_at: + if status == MissionExecutionStatus.RUNNING and not execution.started_at: execution.started_at = now - if status in {ExecutionStatus.COMPLETED, ExecutionStatus.FAILED, ExecutionStatus.CANCELLED}: + if status in {MissionExecutionStatus.COMPLETED, MissionExecutionStatus.FAILED, MissionExecutionStatus.CANCELLED}: execution.finished_at = now snapshot = await self.repo.get_snapshot(execution_id) @@ -223,7 +223,7 @@ async def touch_heartbeat( execution = await self.repo.get_for_update(execution_id) if not execution: return None - active = {ExecutionStatus.QUEUED, ExecutionStatus.DISPATCHED, ExecutionStatus.RUNNING} + active = {MissionExecutionStatus.QUEUED, MissionExecutionStatus.DISPATCHED, MissionExecutionStatus.RUNNING} if execution.status not in active: return execution execution.last_heartbeat_at = utc_now() diff --git a/backend/app/services/mission_service.py b/backend/app/services/mission_service.py index b93996c83..807e02e92 100644 --- a/backend/app/services/mission_service.py +++ b/backend/app/services/mission_service.py @@ -4,6 +4,7 @@ from __future__ import annotations +import asyncio import uuid from typing import Any, Optional @@ -11,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.agent_profile import AgentStatus -from app.models.execution import ExecutionSource, ExecutionStatus +from app.models.execution import ExecutionSource, MissionExecutionStatus from app.models.mission import Mission, MissionPriority, MissionStatus from app.repositories.agent_profile import AgentProfileRepository from app.repositories.mission import MissionRepository @@ -19,6 +20,30 @@ from app.utils.datetime import utc_now +def _start_execution_runner( + execution_id: uuid.UUID, + prompt: str, + credentials: dict[str, str] | None, +) -> None: + """Fire-and-forget: launch an ExecutionRunner in a background task.""" + from app.core.agent.cli_backends.execution_runner import ExecutionRunner + from app.core.database import AsyncSessionLocal + + async def _run() -> None: + async with AsyncSessionLocal() as db: + runner = ExecutionRunner(db) + try: + await runner.run( + execution_id=execution_id, + prompt=prompt, + credentials=credentials, + ) + except Exception as exc: + logger.error(f"Background runner failed for {execution_id}: {exc}") + + asyncio.create_task(_run()) + + def build_execution_prompt(mission: Mission) -> str: """Build the prompt sent to the CLI agent for a mission.""" parts: list[str] = [] @@ -195,6 +220,12 @@ async def dispatch_mission( f"Dispatched mission {mission_id} -> execution {execution.id} " f"(agent={agent.name}, runtime={agent.runtime_type})" ) + + # Start the runner in the background so the execution doesn't stay QUEUED + prompt = build_execution_prompt(mission) + credentials = dict(agent.custom_env or {}) + _start_execution_runner(execution.id, prompt, credentials or None) + return mission, execution async def complete_mission( @@ -226,7 +257,7 @@ async def cancel_mission( if mission.current_execution_id: await self.execution_service.mark_status( execution_id=mission.current_execution_id, - status=ExecutionStatus.CANCELLED, + status=MissionExecutionStatus.CANCELLED, ) mission.status = MissionStatus.CANCELLED diff --git a/backend/tests/test_core/test_container_injectors.py b/backend/tests/test_core/test_container_injectors.py index 3e0dc01f2..8203142d4 100644 --- a/backend/tests/test_core/test_container_injectors.py +++ b/backend/tests/test_core/test_container_injectors.py @@ -109,24 +109,17 @@ def test_build_claude_md_full(): # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_credential_injector_inject(): - svc = _FakeContainerService() - injector = CredentialInjector(svc) - await injector.inject("ctr-1", {"ANTHROPIC_API_KEY": "sk-test", "GITHUB_TOKEN": "ghp-abc"}) - assert len(svc.calls) == 1 - container_id, cmd = svc.calls[0] - assert container_id == "ctr-1" - # The command should write to /etc/environment - cmd_str = " ".join(cmd) - assert "/etc/environment" in cmd_str +async def test_credential_injector_build_env(): + injector = CredentialInjector() + env = injector.build_env({"ANTHROPIC_API_KEY": "sk-test", "GITHUB_TOKEN": "ghp-abc"}) + assert env == {"ANTHROPIC_API_KEY": "sk-test", "GITHUB_TOKEN": "ghp-abc"} @pytest.mark.asyncio async def test_credential_injector_empty(): - svc = _FakeContainerService() - injector = CredentialInjector(svc) - await injector.inject("ctr-1", {}) - assert len(svc.calls) == 0 + injector = CredentialInjector() + env = injector.build_env({}) + assert env == {} @pytest.mark.asyncio diff --git a/backend/tests/test_core/test_integration.py b/backend/tests/test_core/test_integration.py index 341581dbd..f736dc039 100644 --- a/backend/tests/test_core/test_integration.py +++ b/backend/tests/test_core/test_integration.py @@ -26,7 +26,7 @@ RuntimeConfigInjector, ) from app.core.agent.cli_backends.registry import RuntimeProviderRegistry -from app.models.execution import ExecutionSource, ExecutionStatus +from app.models.execution import ExecutionSource, MissionExecutionStatus from app.models.mission import MissionPriority, MissionStatus from app.schemas.execution import ( AgentProfileListResponse, @@ -172,13 +172,13 @@ async def test_container_injection_pipeline(): assert container.status == "running" assert container.container_id.startswith("fake-ctr-") - # Inject credentials - cred_injector = CredentialInjector(svc) - await cred_injector.inject(container.container_id, { + # Build credentials env (no longer writes to container filesystem) + cred_injector = CredentialInjector() + env = cred_injector.build_env({ "ANTHROPIC_API_KEY": "sk-test-key", "GITHUB_TOKEN": "ghp-test", }) - assert len(svc.calls) == 1 + assert env == {"ANTHROPIC_API_KEY": "sk-test-key", "GITHUB_TOKEN": "ghp-test"} # Inject skills skill_injector = CLISkillInjector(svc) @@ -186,7 +186,7 @@ async def test_container_injection_pipeline(): {"name": "lint", "command": "ruff check ."}, {"name": "test", "command": "pytest -x"}, ]) - assert len(svc.calls) == 4 # 1 cred + 1 mkdir + 2 skills + assert len(svc.calls) == 3 # 1 mkdir + 2 skills # Inject CLAUDE.md config config_injector = RuntimeConfigInjector(svc) @@ -196,7 +196,7 @@ async def test_container_injection_pipeline(): skill_names=["lint", "test"], working_dir="/workspace", ) - assert len(svc.calls) == 5 # + 1 config write + assert len(svc.calls) == 4 # + 1 config write # --------------------------------------------------------------------------- diff --git a/backend/tests/test_models/test_execution.py b/backend/tests/test_models/test_execution.py index fcd41c5c1..d49ec0248 100644 --- a/backend/tests/test_models/test_execution.py +++ b/backend/tests/test_models/test_execution.py @@ -3,7 +3,7 @@ os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests") -from app.models.execution import Execution, ExecutionStatus, ExecutionSource +from app.models.execution import Execution, MissionExecutionStatus, ExecutionSource def test_execution_column_defaults(): @@ -11,7 +11,7 @@ def test_execution_column_defaults(): status_col = Execution.__table__.c.status last_seq_col = Execution.__table__.c.last_seq - assert status_col.default.arg == ExecutionStatus.QUEUED + assert status_col.default.arg == MissionExecutionStatus.QUEUED assert last_seq_col.default.arg == 0 @@ -21,9 +21,9 @@ def test_execution_explicit_values(): user_id="user-1", source=ExecutionSource.MISSION, runtime_type="claude_code", - status=ExecutionStatus.RUNNING, + status=MissionExecutionStatus.RUNNING, last_seq=42, ) - assert e.status == ExecutionStatus.RUNNING + assert e.status == MissionExecutionStatus.RUNNING assert e.last_seq == 42 assert e.source == ExecutionSource.MISSION From 78864322271121e38453ef971bd15464aa926f09 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 17:48:18 +0800 Subject: [PATCH 13/69] fix: correct CurrentUser parameter ordering in API endpoints --- backend/app/api/v1/agent_profiles.py | 4 ++-- backend/app/api/v1/missions.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/app/api/v1/agent_profiles.py b/backend/app/api/v1/agent_profiles.py index 16af48cbc..6385cc4f4 100644 --- a/backend/app/api/v1/agent_profiles.py +++ b/backend/app/api/v1/agent_profiles.py @@ -84,8 +84,8 @@ async def create_agent_profile( @router.get("/{profile_id}", response_model=BaseResponse[AgentProfileSummary]) async def get_agent_profile( profile_id: uuid.UUID, + current_user: CurrentUser, workspace_id: uuid.UUID = Query(...), - current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ) -> BaseResponse[AgentProfileSummary]: service = AgentProfileService(db) @@ -99,8 +99,8 @@ async def get_agent_profile( async def update_agent_profile( profile_id: uuid.UUID, request: UpdateAgentProfileRequest, + current_user: CurrentUser, workspace_id: uuid.UUID = Query(...), - current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ) -> BaseResponse[AgentProfileSummary]: service = AgentProfileService(db) diff --git a/backend/app/api/v1/missions.py b/backend/app/api/v1/missions.py index de735d550..020580d14 100644 --- a/backend/app/api/v1/missions.py +++ b/backend/app/api/v1/missions.py @@ -92,8 +92,8 @@ async def create_mission( @router.get("/{mission_id}", response_model=BaseResponse[MissionSummary]) async def get_mission( mission_id: uuid.UUID, + current_user: CurrentUser, workspace_id: uuid.UUID = Query(...), - current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ) -> BaseResponse[MissionSummary]: service = MissionService(db) @@ -107,8 +107,8 @@ async def get_mission( async def update_mission( mission_id: uuid.UUID, request: UpdateMissionRequest, + current_user: CurrentUser, workspace_id: uuid.UUID = Query(...), - current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ) -> BaseResponse[MissionSummary]: service = MissionService(db) @@ -123,8 +123,8 @@ async def update_mission( async def assign_mission( mission_id: uuid.UUID, request: AssignMissionRequest, + current_user: CurrentUser, workspace_id: uuid.UUID = Query(...), - current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ) -> BaseResponse[MissionSummary]: service = MissionService(db) @@ -143,8 +143,8 @@ async def assign_mission( async def dispatch_mission( mission_id: uuid.UUID, request: DispatchMissionRequest, + current_user: CurrentUser, workspace_id: uuid.UUID = Query(...), - current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ) -> BaseResponse[MissionSummary]: service = MissionService(db) @@ -163,8 +163,8 @@ async def dispatch_mission( @router.post("/{mission_id}/cancel", response_model=BaseResponse[MissionSummary]) async def cancel_mission( mission_id: uuid.UUID, + current_user: CurrentUser, workspace_id: uuid.UUID = Query(...), - current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ) -> BaseResponse[MissionSummary]: service = MissionService(db) From ec2b8a8dee8cc93b4c30d417085506633f44f21c Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Wed, 15 Apr 2026 20:12:14 +0800 Subject: [PATCH 14/69] feat: add Mission/Agent navigation, API clients, types, and hooks Adds sidebar entries for Missions and Agents, placeholder page routes, TypeScript types, service layers, and React Query hooks for the mission-driven multi-agent execution frontend foundation. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/app/agents/layout.tsx | 3 + frontend/app/agents/page.tsx | 14 ++ .../app/executions/[executionId]/page.tsx | 20 +++ frontend/app/missions/layout.tsx | 3 + frontend/app/missions/page.tsx | 14 ++ .../components/app-sidebar/app-sidebar.tsx | 14 ++ frontend/hooks/queries/agentProfiles.ts | 96 +++++++++++++ frontend/hooks/queries/executions.ts | 119 +++++++++++++++ frontend/hooks/queries/index.ts | 3 + frontend/hooks/queries/missions.ts | 135 ++++++++++++++++++ frontend/lib/i18n/locales/en.ts | 2 + frontend/lib/i18n/locales/zh.ts | 2 + frontend/services/agentProfileService.ts | 26 ++++ frontend/services/executionService.ts | 38 +++++ frontend/services/missionService.ts | 43 ++++++ frontend/types/agents.ts | 57 ++++++++ frontend/types/executions.ts | 48 +++++++ frontend/types/missions.ts | 68 +++++++++ 18 files changed, 705 insertions(+) create mode 100644 frontend/app/agents/layout.tsx create mode 100644 frontend/app/agents/page.tsx create mode 100644 frontend/app/executions/[executionId]/page.tsx create mode 100644 frontend/app/missions/layout.tsx create mode 100644 frontend/app/missions/page.tsx create mode 100644 frontend/hooks/queries/agentProfiles.ts create mode 100644 frontend/hooks/queries/executions.ts create mode 100644 frontend/hooks/queries/missions.ts create mode 100644 frontend/services/agentProfileService.ts create mode 100644 frontend/services/executionService.ts create mode 100644 frontend/services/missionService.ts create mode 100644 frontend/types/agents.ts create mode 100644 frontend/types/executions.ts create mode 100644 frontend/types/missions.ts diff --git a/frontend/app/agents/layout.tsx b/frontend/app/agents/layout.tsx new file mode 100644 index 000000000..9adbfd295 --- /dev/null +++ b/frontend/app/agents/layout.tsx @@ -0,0 +1,3 @@ +export default function AgentsLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/frontend/app/agents/page.tsx b/frontend/app/agents/page.tsx new file mode 100644 index 000000000..c39cdad0b --- /dev/null +++ b/frontend/app/agents/page.tsx @@ -0,0 +1,14 @@ +'use client' + +export default function AgentsPage() { + return ( +
+
+

AI Agents

+
+
+

Agent management coming soon...

+
+
+ ) +} diff --git a/frontend/app/executions/[executionId]/page.tsx b/frontend/app/executions/[executionId]/page.tsx new file mode 100644 index 000000000..70231da4f --- /dev/null +++ b/frontend/app/executions/[executionId]/page.tsx @@ -0,0 +1,20 @@ +'use client' + +import { useParams } from 'next/navigation' + +export default function ExecutionDetailPage() { + const { executionId } = useParams<{ executionId: string }>() + + return ( +
+
+

+ Execution {executionId?.slice(0, 8)} +

+
+
+

Execution detail coming soon...

+
+
+ ) +} diff --git a/frontend/app/missions/layout.tsx b/frontend/app/missions/layout.tsx new file mode 100644 index 000000000..66d8eec9a --- /dev/null +++ b/frontend/app/missions/layout.tsx @@ -0,0 +1,3 @@ +export default function MissionsLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/frontend/app/missions/page.tsx b/frontend/app/missions/page.tsx new file mode 100644 index 000000000..dd084f833 --- /dev/null +++ b/frontend/app/missions/page.tsx @@ -0,0 +1,14 @@ +'use client' + +export default function MissionsPage() { + return ( +
+
+

Missions

+
+
+

Mission board coming soon...

+
+
+ ) +} diff --git a/frontend/components/app-sidebar/app-sidebar.tsx b/frontend/components/app-sidebar/app-sidebar.tsx index af3fd21bf..f638e763d 100644 --- a/frontend/components/app-sidebar/app-sidebar.tsx +++ b/frontend/components/app-sidebar/app-sidebar.tsx @@ -8,6 +8,8 @@ import { Brain, Clapperboard, Activity, + Target, + Bot, } from 'lucide-react' import Link from 'next/link' import { usePathname } from 'next/navigation' @@ -46,6 +48,18 @@ const menuItems = [ icon: ShieldCheck, href: '/skills', }, + { + id: 'missions', + labelKey: 'sidebar.missions', + icon: Target, + href: '/missions', + }, + { + id: 'agents', + labelKey: 'sidebar.agents', + icon: Bot, + href: '/agents', + }, { id: 'runs', labelKey: 'sidebar.runCenter', diff --git a/frontend/hooks/queries/agentProfiles.ts b/frontend/hooks/queries/agentProfiles.ts new file mode 100644 index 000000000..45a8a9805 --- /dev/null +++ b/frontend/hooks/queries/agentProfiles.ts @@ -0,0 +1,96 @@ +/** + * Agent Profiles Queries + * + * Follow project standards: + * - Use camelCase for types + * - API response: { success: true, data: {...} } + */ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { agentProfileService } from '@/services/agentProfileService' +import type { AgentProfile, CreateAgentRequest, UpdateAgentRequest } from '@/types/agents' + +import { STALE_TIME } from './constants' + +// ==================== Query Keys ==================== + +export const agentProfileKeys = { + all: ['agent-profiles'] as const, + list: (workspaceId: string) => [...agentProfileKeys.all, 'list', workspaceId] as const, + detail: (agentId: string, workspaceId: string) => + [...agentProfileKeys.all, 'detail', agentId, workspaceId] as const, +} + +// ==================== Query Hooks ==================== + +export function useAgentProfiles(workspaceId: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: agentProfileKeys.list(workspaceId), + queryFn: async (): Promise => { + const agents = await agentProfileService.list(workspaceId) + return agents || [] + }, + enabled: Boolean(workspaceId) && options?.enabled !== false, + staleTime: STALE_TIME.STANDARD, + refetchOnWindowFocus: true, + placeholderData: keepPreviousData, + }) +} + +export function useAgentProfile( + agentId: string, + workspaceId: string, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: agentProfileKeys.detail(agentId, workspaceId), + queryFn: () => agentProfileService.get(agentId, workspaceId), + enabled: Boolean(agentId) && Boolean(workspaceId) && options?.enabled !== false, + staleTime: STALE_TIME.STANDARD, + }) +} + +// ==================== Mutation Hooks ==================== + +export function useCreateAgentProfile() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: CreateAgentRequest) => { + return agentProfileService.create(data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: agentProfileKeys.all }) + }, + }) +} + +export function useUpdateAgentProfile() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + agentId, + workspaceId, + ...updates + }: UpdateAgentRequest & { agentId: string; workspaceId: string }) => { + return agentProfileService.update(agentId, workspaceId, updates) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: agentProfileKeys.all }) + }, + }) +} + +export function useDeleteAgentProfile() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ agentId, workspaceId }: { agentId: string; workspaceId: string }) => { + await agentProfileService.delete(agentId, workspaceId) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: agentProfileKeys.all }) + }, + }) +} diff --git a/frontend/hooks/queries/executions.ts b/frontend/hooks/queries/executions.ts new file mode 100644 index 000000000..830fdc735 --- /dev/null +++ b/frontend/hooks/queries/executions.ts @@ -0,0 +1,119 @@ +/** + * Executions Queries + * + * Follow project standards: + * - Use camelCase for types + * - API response: { success: true, data: {...} } + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { executionService } from '@/services/executionService' +import type { Execution, ExecutionSnapshot } from '@/types/executions' +import type { ExecutionEventsPage } from '@/services/executionService' + +import { STALE_TIME } from './constants' + +// ==================== Query Keys ==================== + +export const executionKeys = { + all: ['executions'] as const, + list: (workspaceId: string, filters?: { mission_id?: string; status?: string; limit?: number }) => + [ + ...executionKeys.all, + 'list', + workspaceId, + filters?.mission_id || '', + filters?.status || '', + filters?.limit || 50, + ] as const, + detail: (executionId: string, workspaceId: string) => + [...executionKeys.all, 'detail', executionId, workspaceId] as const, + events: (executionId: string, workspaceId: string) => + [...executionKeys.all, 'events', executionId, workspaceId] as const, + snapshot: (executionId: string, workspaceId: string) => + [...executionKeys.all, 'snapshot', executionId, workspaceId] as const, +} + +// ==================== Query Hooks ==================== + +export function useExecutions( + workspaceId: string, + filters?: { mission_id?: string; status?: string; limit?: number }, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: executionKeys.list(workspaceId, filters), + queryFn: async (): Promise => { + const executions = await executionService.list(workspaceId, filters) + return executions || [] + }, + enabled: Boolean(workspaceId) && options?.enabled !== false, + staleTime: STALE_TIME.SHORT, + refetchInterval: 15000, + refetchOnWindowFocus: true, + }) +} + +export function useExecution( + executionId: string, + workspaceId: string, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: executionKeys.detail(executionId, workspaceId), + queryFn: () => executionService.get(executionId, workspaceId), + enabled: Boolean(executionId) && Boolean(workspaceId) && options?.enabled !== false, + staleTime: STALE_TIME.SHORT, + refetchInterval: 10000, + }) +} + +export function useExecutionEvents( + executionId: string, + workspaceId: string, + afterSeq?: number, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: [...executionKeys.events(executionId, workspaceId), afterSeq], + queryFn: () => executionService.getEvents(executionId, workspaceId, afterSeq), + enabled: Boolean(executionId) && Boolean(workspaceId) && options?.enabled !== false, + staleTime: STALE_TIME.SHORT, + refetchInterval: 5000, + }) +} + +export function useExecutionSnapshot( + executionId: string, + workspaceId: string, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: executionKeys.snapshot(executionId, workspaceId), + queryFn: () => executionService.getSnapshot(executionId, workspaceId), + enabled: Boolean(executionId) && Boolean(workspaceId) && options?.enabled !== false, + staleTime: STALE_TIME.SHORT, + refetchInterval: 5000, + }) +} + +// ==================== Mutation Hooks ==================== + +export function useCancelExecution() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + executionId, + workspaceId, + }: { + executionId: string + workspaceId: string + }) => { + return executionService.cancel(executionId, workspaceId) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: executionKeys.all }) + }, + }) +} diff --git a/frontend/hooks/queries/index.ts b/frontend/hooks/queries/index.ts index 601de5e98..9ecdd7bc2 100644 --- a/frontend/hooks/queries/index.ts +++ b/frontend/hooks/queries/index.ts @@ -13,4 +13,7 @@ export * from './custom-tools' export * from './general-settings' export * from './useMemories' export * from './platformTokens' +export * from './missions' +export * from './agentProfiles' +export * from './executions' export * from './constants' diff --git a/frontend/hooks/queries/missions.ts b/frontend/hooks/queries/missions.ts new file mode 100644 index 000000000..a0dac8d79 --- /dev/null +++ b/frontend/hooks/queries/missions.ts @@ -0,0 +1,135 @@ +/** + * Missions Queries + * + * Follow project standards: + * - Use camelCase for types + * - API response: { success: true, data: {...} } + */ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { missionService } from '@/services/missionService' +import type { Mission, CreateMissionRequest, UpdateMissionRequest } from '@/types/missions' + +import { STALE_TIME } from './constants' + +// ==================== Query Keys ==================== + +export const missionKeys = { + all: ['missions'] as const, + list: (workspaceId: string, filters?: { status?: string; limit?: number }) => + [...missionKeys.all, 'list', workspaceId, filters?.status || '', filters?.limit || 50] as const, + detail: (missionId: string, workspaceId: string) => + [...missionKeys.all, 'detail', missionId, workspaceId] as const, +} + +// ==================== Query Hooks ==================== + +export function useMissions( + workspaceId: string, + filters?: { status?: string; limit?: number }, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: missionKeys.list(workspaceId, filters), + queryFn: async (): Promise => { + const missions = await missionService.list(workspaceId, filters) + return missions || [] + }, + enabled: Boolean(workspaceId) && options?.enabled !== false, + staleTime: STALE_TIME.SHORT, + refetchOnWindowFocus: true, + placeholderData: keepPreviousData, + }) +} + +export function useMission( + missionId: string, + workspaceId: string, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: missionKeys.detail(missionId, workspaceId), + queryFn: () => missionService.get(missionId, workspaceId), + enabled: Boolean(missionId) && Boolean(workspaceId) && options?.enabled !== false, + staleTime: STALE_TIME.SHORT, + }) +} + +// ==================== Mutation Hooks ==================== + +export function useCreateMission() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: CreateMissionRequest) => { + return missionService.create(data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: missionKeys.all }) + }, + }) +} + +export function useUpdateMission() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + missionId, + workspaceId, + ...updates + }: UpdateMissionRequest & { missionId: string; workspaceId: string }) => { + return missionService.update(missionId, workspaceId, updates) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: missionKeys.all }) + }, + }) +} + +export function useAssignMission() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + missionId, + workspaceId, + agentProfileId, + }: { + missionId: string + workspaceId: string + agentProfileId: string + }) => { + return missionService.assign(missionId, workspaceId, agentProfileId) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: missionKeys.all }) + }, + }) +} + +export function useDispatchMission() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ missionId, workspaceId }: { missionId: string; workspaceId: string }) => { + return missionService.dispatch(missionId, workspaceId) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: missionKeys.all }) + }, + }) +} + +export function useCancelMission() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ missionId, workspaceId }: { missionId: string; workspaceId: string }) => { + return missionService.cancel(missionId, workspaceId) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: missionKeys.all }) + }, + }) +} diff --git a/frontend/lib/i18n/locales/en.ts b/frontend/lib/i18n/locales/en.ts index 8313b378e..60d0e7f7b 100644 --- a/frontend/lib/i18n/locales/en.ts +++ b/frontend/lib/i18n/locales/en.ts @@ -33,6 +33,8 @@ const en = { discoverComingSoon: 'Discover feature is under development, stay tuned...', notifications: 'Notifications', openclaw: 'OpenClaw', + missions: 'Missions', + agents: 'Agents', version: 'Version', }, runs: { diff --git a/frontend/lib/i18n/locales/zh.ts b/frontend/lib/i18n/locales/zh.ts index dd036d700..5681c22c4 100644 --- a/frontend/lib/i18n/locales/zh.ts +++ b/frontend/lib/i18n/locales/zh.ts @@ -28,6 +28,8 @@ const zh = { runCenter: '运行中心', toolsAndMcp: '工具与 MCP', openclaw: 'OpenClaw', + missions: '任务看板', + agents: 'AI Agents', collapse: '折叠侧边栏', expand: '展开侧边栏', knowledgeComingSoon: '知识库功能正在开发中,敬请期待...', diff --git a/frontend/services/agentProfileService.ts b/frontend/services/agentProfileService.ts new file mode 100644 index 000000000..4a0422ca2 --- /dev/null +++ b/frontend/services/agentProfileService.ts @@ -0,0 +1,26 @@ +'use client' + +import { apiGet, apiPost, apiPatch, apiDelete } from '@/lib/api-client' +import type { AgentProfile, CreateAgentRequest, UpdateAgentRequest } from '@/types/agents' + +export const agentProfileService = { + list: async (workspaceId: string): Promise => { + return apiGet(`agent-profiles?workspace_id=${workspaceId}`) + }, + + get: async (agentId: string, workspaceId: string): Promise => { + return apiGet(`agent-profiles/${agentId}?workspace_id=${workspaceId}`) + }, + + create: async (data: CreateAgentRequest): Promise => { + return apiPost('agent-profiles', data) + }, + + update: async (agentId: string, workspaceId: string, data: UpdateAgentRequest): Promise => { + return apiPatch(`agent-profiles/${agentId}?workspace_id=${workspaceId}`, data) + }, + + delete: async (agentId: string, workspaceId: string): Promise => { + await apiDelete(`agent-profiles/${agentId}?workspace_id=${workspaceId}`) + }, +} diff --git a/frontend/services/executionService.ts b/frontend/services/executionService.ts new file mode 100644 index 000000000..875fe5b8d --- /dev/null +++ b/frontend/services/executionService.ts @@ -0,0 +1,38 @@ +'use client' + +import { apiGet, apiPost } from '@/lib/api-client' +import type { Execution, ExecutionEvent, ExecutionSnapshot } from '@/types/executions' + +export interface ExecutionEventsPage { + execution_id: string + events: ExecutionEvent[] + next_after_seq: number +} + +export const executionService = { + list: async (workspaceId: string, params?: { mission_id?: string; status?: string; limit?: number }): Promise => { + const searchParams = new URLSearchParams({ workspace_id: workspaceId }) + if (params?.mission_id) searchParams.set('mission_id', params.mission_id) + if (params?.status) searchParams.set('status', params.status) + if (params?.limit) searchParams.set('limit', String(params.limit)) + return apiGet(`executions?${searchParams}`) + }, + + get: async (executionId: string, workspaceId: string): Promise => { + return apiGet(`executions/${executionId}?workspace_id=${workspaceId}`) + }, + + getEvents: async (executionId: string, workspaceId: string, afterSeq?: number): Promise => { + const searchParams = new URLSearchParams({ workspace_id: workspaceId }) + if (afterSeq !== undefined) searchParams.set('after_seq', String(afterSeq)) + return apiGet(`executions/${executionId}/events?${searchParams}`) + }, + + getSnapshot: async (executionId: string, workspaceId: string): Promise => { + return apiGet(`executions/${executionId}/snapshot?workspace_id=${workspaceId}`) + }, + + cancel: async (executionId: string, workspaceId: string): Promise => { + return apiPost(`executions/${executionId}/cancel?workspace_id=${workspaceId}`, {}) + }, +} diff --git a/frontend/services/missionService.ts b/frontend/services/missionService.ts new file mode 100644 index 000000000..2378ef688 --- /dev/null +++ b/frontend/services/missionService.ts @@ -0,0 +1,43 @@ +'use client' + +import { apiGet, apiPost, apiPatch } from '@/lib/api-client' +import type { Mission, CreateMissionRequest, UpdateMissionRequest } from '@/types/missions' + +export interface MissionListResponse { + items: Mission[] +} + +export const missionService = { + list: async (workspaceId: string, params?: { status?: string; limit?: number }): Promise => { + const searchParams = new URLSearchParams({ workspace_id: workspaceId }) + if (params?.status) searchParams.set('status', params.status) + if (params?.limit) searchParams.set('limit', String(params.limit)) + return apiGet(`missions?${searchParams}`) + }, + + get: async (missionId: string, workspaceId: string): Promise => { + return apiGet(`missions/${missionId}?workspace_id=${workspaceId}`) + }, + + create: async (data: CreateMissionRequest): Promise => { + return apiPost('missions', data) + }, + + update: async (missionId: string, workspaceId: string, data: UpdateMissionRequest): Promise => { + return apiPatch(`missions/${missionId}?workspace_id=${workspaceId}`, data) + }, + + assign: async (missionId: string, workspaceId: string, agentProfileId: string): Promise => { + return apiPost(`missions/${missionId}/assign?workspace_id=${workspaceId}`, { + agent_profile_id: agentProfileId, + }) + }, + + dispatch: async (missionId: string, workspaceId: string): Promise => { + return apiPost(`missions/${missionId}/dispatch?workspace_id=${workspaceId}`, {}) + }, + + cancel: async (missionId: string, workspaceId: string): Promise => { + return apiPost(`missions/${missionId}/cancel?workspace_id=${workspaceId}`, {}) + }, +} diff --git a/frontend/types/agents.ts b/frontend/types/agents.ts new file mode 100644 index 000000000..690f31964 --- /dev/null +++ b/frontend/types/agents.ts @@ -0,0 +1,57 @@ +export interface AgentProfile { + id: string + workspace_id: string + name: string + avatar?: string | null + description?: string | null + runtime_type: RuntimeType + status: AgentStatus + max_concurrent_tasks: number + skill_ids?: string[] | null + instructions?: string | null + custom_env?: Record | null + runtime_config?: Record | null + visibility: 'workspace' | 'private' + created_at: string + updated_at: string +} + +export type RuntimeType = 'claude_code' | 'codex' | 'openclaw' | 'langgraph' +export type AgentStatus = 'idle' | 'working' | 'blocked' | 'error' | 'offline' + +export interface CreateAgentRequest { + workspace_id: string + name: string + runtime_type: RuntimeType + description?: string + instructions?: string + skill_ids?: string[] + custom_env?: Record + runtime_config?: Record + max_concurrent_tasks?: number +} + +export interface UpdateAgentRequest { + name?: string + description?: string + instructions?: string + skill_ids?: string[] + custom_env?: Record + runtime_config?: Record + max_concurrent_tasks?: number +} + +export const RUNTIME_TYPE_LABELS: Record = { + claude_code: 'Claude Code', + codex: 'Codex', + openclaw: 'OpenClaw', + langgraph: 'LangGraph', +} + +export const AGENT_STATUS_LABELS: Record = { + idle: 'Idle', + working: 'Working', + blocked: 'Blocked', + error: 'Error', + offline: 'Offline', +} diff --git a/frontend/types/executions.ts b/frontend/types/executions.ts new file mode 100644 index 000000000..b56d53d22 --- /dev/null +++ b/frontend/types/executions.ts @@ -0,0 +1,48 @@ +export interface Execution { + id: string + workspace_id: string + user_id: string + source: ExecutionSource + source_id?: string | null + status: ExecutionStatus + title?: string | null + mission_id?: string | null + agent_profile_id?: string | null + parent_execution_id?: string | null + runtime_type: string + container_id?: string | null + started_at?: string | null + finished_at?: string | null + last_seq: number + session_id?: string | null + created_at: string + updated_at: string +} + +export type ExecutionStatus = 'queued' | 'dispatched' | 'running' | 'interrupt_wait' | 'approval_wait' | 'completed' | 'failed' | 'cancelled' +export type ExecutionSource = 'mission' | 'chat' | 'graph' | 'coordinator' | 'api' + +export interface ExecutionEvent { + id: string + execution_id: string + seq: number + event_type: ExecutionEventType + payload: Record + created_at: string +} + +export type ExecutionEventType = 'text' | 'thinking' | 'tool_use' | 'tool_result' | 'error' | 'artifact' | 'approval_request' | 'user_message' | 'status' + +export interface ExecutionSnapshot { + execution_id: string + last_seq: number + status: string + projection: { + last_text?: string + tool_count?: number + current_tool?: string | null + artifacts?: Record[] + approval_pending?: Record | null + error?: string | null + } +} diff --git a/frontend/types/missions.ts b/frontend/types/missions.ts new file mode 100644 index 000000000..4f52ac88f --- /dev/null +++ b/frontend/types/missions.ts @@ -0,0 +1,68 @@ +export interface Mission { + id: string + workspace_id: string + title: string + description?: string | null + objective?: string | null + status: MissionStatus + priority: MissionPriority + assignee_type?: 'member' | 'agent' | null + assignee_id?: string | null + creator_id: string + current_execution_id?: string | null + parent_mission_id?: string | null + tags?: string[] | null + position: number + due_date?: string | null + created_at: string + updated_at: string +} + +export type MissionStatus = 'backlog' | 'todo' | 'in_progress' | 'in_review' | 'done' | 'blocked' | 'cancelled' +export type MissionPriority = 'none' | 'low' | 'medium' | 'high' | 'urgent' + +export interface CreateMissionRequest { + workspace_id: string + title: string + description?: string + objective?: string + priority?: MissionPriority + parent_mission_id?: string + tags?: string[] +} + +export interface UpdateMissionRequest { + title?: string + description?: string + objective?: string + status?: MissionStatus + priority?: MissionPriority + position?: number + tags?: string[] +} + +export interface AssignMissionRequest { + agent_profile_id: string +} + +export const MISSION_STATUS_ORDER: MissionStatus[] = [ + 'backlog', 'todo', 'in_progress', 'in_review', 'done', +] + +export const MISSION_STATUS_LABELS: Record = { + backlog: 'Backlog', + todo: 'To Do', + in_progress: 'In Progress', + in_review: 'In Review', + done: 'Done', + blocked: 'Blocked', + cancelled: 'Cancelled', +} + +export const MISSION_PRIORITY_LABELS: Record = { + none: 'None', + low: 'Low', + medium: 'Medium', + high: 'High', + urgent: 'Urgent', +} From 8df9f9f9402ce083b1af0682ce35d0c450160865 Mon Sep 17 00:00:00 2001 From: yuzzjj Date: Thu, 16 Apr 2026 08:31:39 +0800 Subject: [PATCH 15/69] feat: add Agent management page with grid view and create/edit dialog --- frontend/app/agents/page.tsx | 122 ++++++++++- frontend/components/agents/agent-card.tsx | 83 ++++++++ .../components/agents/agent-form-dialog.tsx | 197 ++++++++++++++++++ frontend/components/agents/agent-status.tsx | 39 ++++ 4 files changed, 436 insertions(+), 5 deletions(-) create mode 100644 frontend/components/agents/agent-card.tsx create mode 100644 frontend/components/agents/agent-form-dialog.tsx create mode 100644 frontend/components/agents/agent-status.tsx diff --git a/frontend/app/agents/page.tsx b/frontend/app/agents/page.tsx index c39cdad0b..b11ec1919 100644 --- a/frontend/app/agents/page.tsx +++ b/frontend/app/agents/page.tsx @@ -1,14 +1,126 @@ 'use client' +import { Bot, Loader2, Plus } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +import { AgentCard } from '@/components/agents/agent-card' +import { AgentFormDialog } from '@/components/agents/agent-form-dialog' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { + useAgentProfiles, + useCreateAgentProfile, + useUpdateAgentProfile, +} from '@/hooks/queries/agentProfiles' +import { useWorkspaces } from '@/hooks/queries/workspaces' +import type { AgentProfile, CreateAgentRequest } from '@/types/agents' + export default function AgentsPage() { + const router = useRouter() + const { data: workspaces = [] } = useWorkspaces() + const personalWorkspace = workspaces.find((ws) => ws.type === 'personal') + const workspaceId = personalWorkspace?.id || '' + + const { data: agents = [], isLoading } = useAgentProfiles(workspaceId) + const createMutation = useCreateAgentProfile() + const updateMutation = useUpdateAgentProfile() + + const [dialogOpen, setDialogOpen] = useState(false) + const [editingAgent, setEditingAgent] = useState(null) + + function handleCreate() { + setEditingAgent(null) + setDialogOpen(true) + } + + function handleConfigure(agent: AgentProfile) { + setEditingAgent(agent) + setDialogOpen(true) + } + + function handleHistory(agent: AgentProfile) { + router.push(`/runs?agent=${encodeURIComponent(agent.name)}`) + } + + function handleSubmit(data: CreateAgentRequest) { + if (editingAgent) { + const { workspace_id, name, runtime_type, ...rest } = data + updateMutation.mutate( + { agentId: editingAgent.id, workspaceId, name, ...rest }, + { onSuccess: () => setDialogOpen(false) }, + ) + } else { + createMutation.mutate(data, { + onSuccess: () => setDialogOpen(false), + }) + } + } + + const isPending = createMutation.isPending || updateMutation.isPending + return ( -
-
-

AI Agents

+
+ {/* Header */} +
+
+
+ +

AI Agents

+
+ +
-
-

Agent management coming soon...

+ + {/* Content */} +
+ {isLoading ? ( +
+ + Loading agents... +
+ ) : agents.length === 0 ? ( + +
+ +
+

+ No agents yet +

+

+ Create your first AI agent to get started. +

+ +
+ ) : ( +
+ {agents.map((agent) => ( + + ))} +
+ )}
+ + {/* Dialog */} +
) } diff --git a/frontend/components/agents/agent-card.tsx b/frontend/components/agents/agent-card.tsx new file mode 100644 index 000000000..dca5dffde --- /dev/null +++ b/frontend/components/agents/agent-card.tsx @@ -0,0 +1,83 @@ +'use client' + +import { Bot, Settings, History } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import type { AgentProfile } from '@/types/agents' +import { RUNTIME_TYPE_LABELS } from '@/types/agents' + +import { AgentStatusIndicator } from './agent-status' + +interface AgentCardProps { + agent: AgentProfile + onConfigure: (agent: AgentProfile) => void + onHistory: (agent: AgentProfile) => void +} + +export function AgentCard({ agent, onConfigure, onHistory }: AgentCardProps) { + const skillCount = agent.skill_ids?.length ?? 0 + + return ( + + {/* Header */} +
+
+
+ +
+
+

+ {agent.name} +

+
+
+ +
+ + {/* Runtime badge */} +
+ + {RUNTIME_TYPE_LABELS[agent.runtime_type]} + +
+ + {/* Description */} +

+ {agent.description || 'No description'} +

+ + {/* Meta */} +
+ Skills: {skillCount} + Max tasks: {agent.max_concurrent_tasks} +
+ + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/frontend/components/agents/agent-form-dialog.tsx b/frontend/components/agents/agent-form-dialog.tsx new file mode 100644 index 000000000..a4b21a514 --- /dev/null +++ b/frontend/components/agents/agent-form-dialog.tsx @@ -0,0 +1,197 @@ +'use client' + +import { useState, useEffect } from 'react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import type { AgentProfile, CreateAgentRequest, RuntimeType } from '@/types/agents' +import { RUNTIME_TYPE_LABELS } from '@/types/agents' + +interface AgentFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + agent?: AgentProfile | null + workspaceId: string + onSubmit: (data: CreateAgentRequest) => void + isPending?: boolean +} + +const RUNTIME_OPTIONS: RuntimeType[] = ['claude_code', 'codex', 'openclaw', 'langgraph'] + +export function AgentFormDialog({ + open, + onOpenChange, + agent, + workspaceId, + onSubmit, + isPending, +}: AgentFormDialogProps) { + const isEdit = Boolean(agent) + + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [runtimeType, setRuntimeType] = useState('claude_code') + const [instructions, setInstructions] = useState('') + const [maxConcurrentTasks, setMaxConcurrentTasks] = useState(1) + const [skillIdsText, setSkillIdsText] = useState('') + + useEffect(() => { + if (open) { + if (agent) { + setName(agent.name) + setDescription(agent.description || '') + setRuntimeType(agent.runtime_type) + setInstructions(agent.instructions || '') + setMaxConcurrentTasks(agent.max_concurrent_tasks) + setSkillIdsText(agent.skill_ids?.join(', ') || '') + } else { + setName('') + setDescription('') + setRuntimeType('claude_code') + setInstructions('') + setMaxConcurrentTasks(1) + setSkillIdsText('') + } + } + }, [open, agent]) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!name.trim()) return + + const skillIds = skillIdsText + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + onSubmit({ + workspace_id: workspaceId, + name: name.trim(), + runtime_type: runtimeType, + description: description.trim() || undefined, + instructions: instructions.trim() || undefined, + max_concurrent_tasks: maxConcurrentTasks, + skill_ids: skillIds.length > 0 ? skillIds : undefined, + }) + } + + return ( + + + + {isEdit ? 'Edit Agent' : 'New Agent'} + + {isEdit + ? 'Update the agent configuration.' + : 'Create a new AI agent for your workspace.'} + + + +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g. Security Auditor" + required + /> +
+ + {/* Description */} +
+ +