From 0cd9194b6459916aa7266bf01063860590c04488 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 23:21:17 +0330 Subject: [PATCH] v2 foundation: shared contracts, app profile schema, wiring stubs - projects: app profile columns + one app per org; project_external_git, deploy_targets, org_model_grants tables (migration a0f0e1d2c3b4) - ports + fakes: RepoWorkspace, DeliveryHooks, DeployTarget, Memory, ModelGrants, WorkforceBootstrap; AppProfile + AppProfileRepository - per-workstream wiring (container_org/engine/delivery), config blocks, empty routers, delivery worker, MCP/delivery service shells, web stubs - admin nav derives from ADMIN_ITEMS; plan docs updated for ext4 worktrees Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 34 +++ .gitignore | 3 + .../versions/a0f0e1d2c3b4_v2_foundation.py | 126 ++++++++++ apps/api/app/application/container.py | 13 + .../api/app/application/container_delivery.py | 29 +++ apps/api/app/application/container_engine.py | 17 ++ apps/api/app/application/container_org.py | 44 ++++ .../app/application/ports/delivery_hooks.py | 48 ++++ .../app/application/ports/deploy_target.py | 51 ++++ apps/api/app/application/ports/memory.py | 76 ++++++ .../api/app/application/ports/model_grants.py | 50 ++++ .../app/application/ports/repo_workspace.py | 185 ++++++++++++++ .../application/ports/workforce_bootstrap.py | 22 ++ apps/api/app/core/config.py | 30 +++ apps/api/app/domain/projects/profile.py | 57 +++++ apps/api/app/infrastructure/db/columns.py | 18 ++ apps/api/app/infrastructure/db/models.py | 100 ++++++++ .../app/infrastructure/db/models_delivery.py | 10 + .../app/infrastructure/db/models_engine.py | 10 + apps/api/app/infrastructure/db/models_org.py | 10 + .../repositories/app_profile.py | 82 +++++++ .../interfaces/http/routers/admin_domains.py | 11 + .../interfaces/http/routers/admin_grants.py | 11 + .../app/interfaces/http/routers/app_setup.py | 11 + .../app/interfaces/http/routers/delivery.py | 11 + .../app/interfaces/http/routers/mcp_tokens.py | 11 + .../api/app/interfaces/http/routers/memory.py | 11 + apps/api/app/interfaces/mcp/__init__.py | 1 + apps/api/app/interfaces/mcp/server.py | 14 ++ apps/api/app/interfaces/realtime/rpc_app.py | 13 + apps/api/app/workers/__main__.py | 16 +- apps/api/app/workers/delivery.py | 15 ++ apps/api/pyproject.toml | 3 + apps/api/tests/test_foundation.py | 226 ++++++++++++++++++ apps/web/src/app/(app)/admin/domains/page.tsx | 17 ++ apps/web/src/app/(app)/admin/layout.tsx | 13 +- apps/web/src/app/(app)/admin/mcp/page.tsx | 16 ++ .../src/app/(app)/admin/model-grants/page.tsx | 17 ++ apps/web/src/app/(app)/admin/sandbox/page.tsx | 17 ++ apps/web/src/app/(app)/app/page.tsx | 18 ++ apps/web/src/app/(app)/memory/page.tsx | 18 ++ apps/web/src/lib/api-delivery.ts | 10 + apps/web/src/lib/api-engine.ts | 10 + apps/web/src/lib/api-org.ts | 10 + apps/web/src/lib/nav.ts | 8 +- apps/web/src/test/command-palette.test.tsx | 4 +- docs/plan/PLAN-v2.md | 18 +- docs/plan/TODO-0-foundation.md | 6 +- docs/plan/TODO-agent-1-org-platform.md | 4 +- docs/plan/TODO-agent-2-ai-workforce.md | 4 +- docs/plan/TODO-agent-3-delivery-infra.md | 2 +- docs/plan/reports/.gitkeep | 0 package.json | 2 + pnpm-lock.yaml | 4 + services/delivery-worker/README.md | 8 + services/delivery-worker/package.json | 11 + services/mcp-gateway/README.md | 6 + services/mcp-gateway/package.json | 10 + 58 files changed, 1576 insertions(+), 26 deletions(-) create mode 100644 apps/api/alembic/versions/a0f0e1d2c3b4_v2_foundation.py create mode 100644 apps/api/app/application/container_delivery.py create mode 100644 apps/api/app/application/container_engine.py create mode 100644 apps/api/app/application/container_org.py create mode 100644 apps/api/app/application/ports/delivery_hooks.py create mode 100644 apps/api/app/application/ports/deploy_target.py create mode 100644 apps/api/app/application/ports/memory.py create mode 100644 apps/api/app/application/ports/model_grants.py create mode 100644 apps/api/app/application/ports/repo_workspace.py create mode 100644 apps/api/app/application/ports/workforce_bootstrap.py create mode 100644 apps/api/app/domain/projects/profile.py create mode 100644 apps/api/app/infrastructure/db/columns.py create mode 100644 apps/api/app/infrastructure/db/models_delivery.py create mode 100644 apps/api/app/infrastructure/db/models_engine.py create mode 100644 apps/api/app/infrastructure/db/models_org.py create mode 100644 apps/api/app/infrastructure/repositories/app_profile.py create mode 100644 apps/api/app/interfaces/http/routers/admin_domains.py create mode 100644 apps/api/app/interfaces/http/routers/admin_grants.py create mode 100644 apps/api/app/interfaces/http/routers/app_setup.py create mode 100644 apps/api/app/interfaces/http/routers/delivery.py create mode 100644 apps/api/app/interfaces/http/routers/mcp_tokens.py create mode 100644 apps/api/app/interfaces/http/routers/memory.py create mode 100644 apps/api/app/interfaces/mcp/__init__.py create mode 100644 apps/api/app/interfaces/mcp/server.py create mode 100644 apps/api/app/workers/delivery.py create mode 100644 apps/api/tests/test_foundation.py create mode 100644 apps/web/src/app/(app)/admin/domains/page.tsx create mode 100644 apps/web/src/app/(app)/admin/mcp/page.tsx create mode 100644 apps/web/src/app/(app)/admin/model-grants/page.tsx create mode 100644 apps/web/src/app/(app)/admin/sandbox/page.tsx create mode 100644 apps/web/src/app/(app)/app/page.tsx create mode 100644 apps/web/src/app/(app)/memory/page.tsx create mode 100644 apps/web/src/lib/api-delivery.ts create mode 100644 apps/web/src/lib/api-engine.ts create mode 100644 apps/web/src/lib/api-org.ts create mode 100644 docs/plan/reports/.gitkeep create mode 100644 services/delivery-worker/README.md create mode 100644 services/delivery-worker/package.json create mode 100644 services/mcp-gateway/README.md create mode 100644 services/mcp-gateway/package.json diff --git a/.env.example b/.env.example index 14657a6..f13836e 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,37 @@ ERROR_LOG_ENABLED=1 # genuine bugs. Turn it on while debugging a misbehaving client. # 5xx responses are always recorded, whatever this is set to. ERROR_LOG_CLIENT_ERRORS=0 + +# =========================================================================== +# v2 workstream settings. Each agent edits ONLY its own block. +# =========================================================================== +# >>> agent-1 (org platform) +MCP_GATEWAY_HOST=127.0.0.1 +MCP_GATEWAY_PORT=8765 +MCP_TOKEN_TTL_DAYS=90 +MEMORY_ORG_CHAR_CAP=60000 +PLATFORM_INVITE_TTL_HOURS=72 +# <<< agent-1 + +# >>> agent-2 (AI workforce engine) +ENGINE_MAX_TOOL_CALLS_PER_TURN=40 +ENGINE_TURN_TIMEOUT_SECONDS=900 +ENGINE_MAX_CYCLES=6 +ENGINE_MAX_EMPLOYEES=20 +ENGINE_TICK_SECONDS=10 +# <<< agent-2 + +# >>> agent-3 (delivery & infra) +SANDBOX_DOCKER_BIN=docker +SANDBOX_WORKSPACES_ROOT=./.data/workspaces +SANDBOX_DEFAULT_CPUS=1 +SANDBOX_DEFAULT_MEMORY_MB=1024 +DEMO_PORT_START=8100 +DEMO_PORT_END=8999 +DELIVERY_POLL_SECONDS=60 +DEPLOY_ALLOW_PRIVATE_HOSTS=false +ARTIFACTS_ROOT=./.data/artifacts +# fake (default, no git on the host) | local (real git workspaces) +GIT_WORKSPACE=fake +ALLOW_CUSTOM_DOMAINS=false +# <<< agent-3 diff --git a/.gitignore b/.gitignore index 6c45d1d..7f18480 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ htmlcov/ # agent working memory — session state, not project source .workbuddy-ai/ + +# v2 runtime data (workspaces, artifacts) +.data/ diff --git a/apps/api/alembic/versions/a0f0e1d2c3b4_v2_foundation.py b/apps/api/alembic/versions/a0f0e1d2c3b4_v2_foundation.py new file mode 100644 index 0000000..5553f20 --- /dev/null +++ b/apps/api/alembic/versions/a0f0e1d2c3b4_v2_foundation.py @@ -0,0 +1,126 @@ +"""v2 foundation: app profile, one app per org, external git, deploy targets, model grants + +Revision ID: a0f0e1d2c3b4 +Revises: d4e5f6a7b8c9 +Create Date: 2026-10-13 22:30:00.000000 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a0f0e1d2c3b4" +down_revision: str | None = "d4e5f6a7b8c9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_PROFILE_COLUMNS: list[sa.Column] = [ + sa.Column("app_type", sa.String(length=32), nullable=True), + sa.Column("language", sa.String(length=32), nullable=True), + sa.Column("framework", sa.String(length=48), nullable=True), + sa.Column("runtime_version", sa.String(length=32), nullable=True), + sa.Column("package_manager", sa.String(length=32), nullable=True), + sa.Column("setup_command", sa.Text(), nullable=True), + sa.Column("build_command", sa.Text(), nullable=True), + sa.Column("test_command", sa.Text(), nullable=True), + sa.Column("start_command", sa.Text(), nullable=True), + sa.Column("app_port", sa.Integer(), nullable=True), + sa.Column("healthcheck_path", sa.String(length=255), nullable=False, server_default="/"), + sa.Column("git_source", sa.String(length=16), nullable=False, server_default="platform"), + sa.Column("external_repo_url", sa.String(length=512), nullable=True), + sa.Column("wizard_completed_at", sa.DateTime(timezone=True), nullable=True), +] + + +def upgrade() -> None: + with op.batch_alter_table("projects") as batch: + for column in _PROFILE_COLUMNS: + batch.add_column(column) + batch.create_unique_constraint("uq_project_one_per_org", ["organization_id"]) + + op.create_table( + "project_external_git", + sa.Column( + "project_id", + sa.String(length=32), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("auth_type", sa.String(length=16), nullable=False, server_default="none"), + sa.Column("username", sa.String(length=160), nullable=True), + sa.Column("secret_encrypted", sa.Text(), nullable=True), + sa.Column("known_hosts", sa.Text(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + op.create_table( + "deploy_targets", + sa.Column("id", sa.String(length=32), primary_key=True), + sa.Column( + "organization_id", + sa.String(length=32), + sa.ForeignKey("organizations.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "project_id", + sa.String(length=32), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("kind", sa.String(length=8), nullable=False), + sa.Column("host", sa.String(length=255), nullable=False), + sa.Column("port", sa.Integer(), nullable=False), + sa.Column("username", sa.String(length=160), nullable=False), + sa.Column("auth_type", sa.String(length=16), nullable=False, server_default="password"), + sa.Column("secret_encrypted", sa.Text(), nullable=True), + sa.Column("remote_path", sa.String(length=512), nullable=False), + sa.Column("post_deploy_command", sa.Text(), nullable=True), + sa.Column("host_fingerprint", sa.String(length=255), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("last_tested_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_test_ok", sa.Boolean(), nullable=True), + sa.Column("last_test_message", sa.String(length=500), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_deploy_targets_organization_id", "deploy_targets", ["organization_id"]) + op.create_index("ix_deploy_target_project", "deploy_targets", ["project_id"]) + + op.create_table( + "org_model_grants", + sa.Column("id", sa.String(length=32), primary_key=True), + sa.Column( + "organization_id", + sa.String(length=32), + sa.ForeignKey("organizations.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "ai_model_id", + sa.String(length=32), + sa.ForeignKey("ai_models.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("daily_credit_cap", sa.Float(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("organization_id", "ai_model_id", name="uq_org_model_grant"), + ) + op.create_index("ix_org_model_grants_organization_id", "org_model_grants", ["organization_id"]) + + +def downgrade() -> None: + op.drop_index("ix_org_model_grants_organization_id", table_name="org_model_grants") + op.drop_table("org_model_grants") + op.drop_index("ix_deploy_target_project", table_name="deploy_targets") + op.drop_index("ix_deploy_targets_organization_id", table_name="deploy_targets") + op.drop_table("deploy_targets") + op.drop_table("project_external_git") + with op.batch_alter_table("projects") as batch: + batch.drop_constraint("uq_project_one_per_org", type_="unique") + for column in reversed(_PROFILE_COLUMNS): + batch.drop_column(column.name) diff --git a/apps/api/app/application/container.py b/apps/api/app/application/container.py index ab3af4c..0a684a2 100644 --- a/apps/api/app/application/container.py +++ b/apps/api/app/application/container.py @@ -15,6 +15,13 @@ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession +from app.application.container_delivery import ( + build_delivery_hooks, + build_deploy_target_tester, + build_repo_workspace, +) +from app.application.container_engine import build_workforce_bootstrap +from app.application.container_org import build_memory, build_model_grants from app.application.services.billing_service import BillingService from app.application.services.knowledge_service import KnowledgeService from app.application.services.project_service import ProjectService @@ -158,6 +165,12 @@ def build_sandbox_service(session: AsyncSession) -> SandboxService: __all__ = [ "build_billing_service", + "build_delivery_hooks", + "build_deploy_target_tester", + "build_memory", + "build_model_grants", + "build_repo_workspace", + "build_workforce_bootstrap", "build_knowledge_service", "build_project_service", "build_sandbox_service", diff --git a/apps/api/app/application/container_delivery.py b/apps/api/app/application/container_delivery.py new file mode 100644 index 0000000..51063b8 --- /dev/null +++ b/apps/api/app/application/container_delivery.py @@ -0,0 +1,29 @@ +"""Wiring for the delivery workstream (repo workspaces, delivery hooks, deploy tester). + +Foundation defaults are in-memory fakes. The delivery agent replaces the bodies (behind +settings) with the real adapters; the function names and return types are FROZEN. +""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks +from app.application.ports.deploy_target import DeployTargetTester, FakeDeployTargetTester +from app.application.ports.repo_workspace import FakeRepoWorkspace, RepoWorkspacePort + +_fake_workspace = FakeRepoWorkspace() +_null_hooks = NullDeliveryHooks() +_fake_deploy_tester = FakeDeployTargetTester() + + +def build_repo_workspace(session: AsyncSession) -> RepoWorkspacePort: + return _fake_workspace + + +def build_delivery_hooks(session: AsyncSession) -> DeliveryHooks: + return _null_hooks + + +def build_deploy_target_tester(session: AsyncSession) -> DeployTargetTester: + return _fake_deploy_tester diff --git a/apps/api/app/application/container_engine.py b/apps/api/app/application/container_engine.py new file mode 100644 index 0000000..1fdc496 --- /dev/null +++ b/apps/api/app/application/container_engine.py @@ -0,0 +1,17 @@ +"""Wiring for the AI-workforce workstream (team bootstrap). + +Foundation default: a no-op. The engine agent replaces the body with the real bootstrap; +the function name and return type are FROZEN. +""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.ports.workforce_bootstrap import NullWorkforceBootstrap, WorkforceBootstrapPort + +_null_bootstrap = NullWorkforceBootstrap() + + +def build_workforce_bootstrap(session: AsyncSession) -> WorkforceBootstrapPort: + return _null_bootstrap diff --git a/apps/api/app/application/container_org.py b/apps/api/app/application/container_org.py new file mode 100644 index 0000000..2a3acfc --- /dev/null +++ b/apps/api/app/application/container_org.py @@ -0,0 +1,44 @@ +"""Wiring for the org-platform workstream (memory, model grants). + +Foundation defaults: no memory, and every active model granted to every org (today's +behaviour). The org-platform agent replaces the bodies with the real services; the +function names and return types are FROZEN so other workstreams can call them now. +""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.application.ports.memory import MemoryPort, NullMemory +from app.application.ports.model_grants import ( + AllActiveModelsGrants, + GrantedModel, + ModelGrantPort, + tier_for, +) +from app.infrastructure.db.models import AiModelRow + +_null_memory = NullMemory() + + +def build_memory(session: AsyncSession) -> MemoryPort: + return _null_memory + + +def build_model_grants(session: AsyncSession) -> ModelGrantPort: + async def _all_active() -> list[GrantedModel]: + rows = (await session.scalars(select(AiModelRow).where(AiModelRow.is_active))).all() + return [ + GrantedModel( + ai_model_id=row.id, + tier=tier_for( + capabilities=list(row.capabilities or []), + output_price_per_1m=row.output_price_per_1m, + ), + capabilities=tuple(row.capabilities or []), + ) + for row in rows + ] + + return AllActiveModelsGrants(_all_active) diff --git a/apps/api/app/application/ports/delivery_hooks.py b/apps/api/app/application/ports/delivery_hooks.py new file mode 100644 index 0000000..5544439 --- /dev/null +++ b/apps/api/app/application/ports/delivery_hooks.py @@ -0,0 +1,48 @@ +"""Delivery hooks — how the org wizard and the engine tell the delivery side to act. + +Implemented by the delivery workstream; called by the org-platform workstream (app created) +and the engine (branch pushed). FROZEN contract: see docs/plan/PLAN-v2.md §5. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class DeliveryHooks(Protocol): + async def on_app_created(self, *, organization_id: str, project_id: str) -> None: ... + + async def on_branch_pushed( + self, + *, + organization_id: str, + project_id: str, + branch: str, + sha: str, + source: str, # "engine" | "poll" | "manual" + ) -> None: ... + + +class NullDeliveryHooks: + """Records calls and does nothing else (default until the real hooks are wired).""" + + def __init__(self) -> None: + self.created: list[dict] = [] + self.pushed: list[dict] = [] + + async def on_app_created(self, *, organization_id: str, project_id: str) -> None: + self.created.append({"organization_id": organization_id, "project_id": project_id}) + + async def on_branch_pushed( + self, *, organization_id: str, project_id: str, branch: str, sha: str, source: str + ) -> None: + self.pushed.append( + { + "organization_id": organization_id, + "project_id": project_id, + "branch": branch, + "sha": sha, + "source": source, + } + ) diff --git a/apps/api/app/application/ports/deploy_target.py b/apps/api/app/application/ports/deploy_target.py new file mode 100644 index 0000000..dd0b98c --- /dev/null +++ b/apps/api/app/application/ports/deploy_target.py @@ -0,0 +1,51 @@ +"""Deploy target tester — checks an SSH/SFTP/FTP/FTPS production target. + +Implemented by the delivery workstream; called by the org wizard. FROZEN contract: +see docs/plan/PLAN-v2.md §5. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +DEPLOY_KINDS = ("ssh", "sftp", "ftp", "ftps") + + +@dataclass(slots=True, kw_only=True) +class DeployTargetSpec: + kind: str + host: str + port: int + username: str + auth_type: str = "password" # password | key + secret: str | None = None # plaintext, in memory for one call only + remote_path: str = "/" + known_fingerprint: str | None = None + + +@dataclass(slots=True, kw_only=True) +class DeployCheck: + ok: bool + #: Plain-language Persian message; never contains credentials. + message: str = "" + #: Host key fingerprint seen (SSH/SFTP), to be pinned on first success. + fingerprint: str | None = None + + +@runtime_checkable +class DeployTargetTester(Protocol): + async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck: ... + + +class FakeDeployTargetTester: + """OK unless the host contains ``fail``.""" + + def __init__(self) -> None: + self.calls: list[DeployTargetSpec] = [] + + async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck: + self.calls.append(target) + if "fail" in target.host: + return DeployCheck(ok=False, message="اتصال برقرار نشد") + return DeployCheck(ok=True, message="اتصال موفق", fingerprint="SHA256:fake") diff --git a/apps/api/app/application/ports/memory.py b/apps/api/app/application/ports/memory.py new file mode 100644 index 0000000..93d02c5 --- /dev/null +++ b/apps/api/app/application/ports/memory.py @@ -0,0 +1,76 @@ +"""Org memory port — the self-improving memory of an organization. + +Implemented by the org-platform workstream; used by the engine. FROZEN contract: +see docs/plan/PLAN-v2.md §5. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +MEMORY_KINDS = ("rule", "preference", "lesson", "fact", "code_map") +MEMORY_SOURCES = ("user", "manager", "system", "reflection") + + +@dataclass(slots=True, kw_only=True) +class TaskOutcome: + task_id: str + title: str + status: str # completed | failed | cancelled + summary: str = "" + changed_files: list[str] = field(default_factory=list) + cycles: int = 0 + gate_failures: list[str] = field(default_factory=list) + + +@runtime_checkable +class MemoryPort(Protocol): + async def build_context( + self, *, organization_id: str, role: str, query: str, budget_chars: int = 2500 + ) -> str: ... + + async def record( + self, + *, + organization_id: str, + kind: str, + content: str, + source: str, + evidence_task_id: str | None = None, + key: str | None = None, + ) -> str | None: ... + + async def on_task_finished(self, *, organization_id: str, task: TaskOutcome) -> None: ... + + +class NullMemory: + """No memory: empty context, nothing stored (default until the real service is wired).""" + + def __init__(self) -> None: + self.recorded: list[dict] = [] + self.finished: list[TaskOutcome] = [] + + async def build_context( + self, *, organization_id: str, role: str, query: str, budget_chars: int = 2500 + ) -> str: + return "" + + async def record( + self, + *, + organization_id: str, + kind: str, + content: str, + source: str, + evidence_task_id: str | None = None, + key: str | None = None, + ) -> str | None: + self.recorded.append( + {"organization_id": organization_id, "kind": kind, "content": content, + "source": source, "evidence_task_id": evidence_task_id, "key": key} + ) + return None + + async def on_task_finished(self, *, organization_id: str, task: TaskOutcome) -> None: + self.finished.append(task) diff --git a/apps/api/app/application/ports/model_grants.py b/apps/api/app/application/ports/model_grants.py new file mode 100644 index 0000000..b0adc69 --- /dev/null +++ b/apps/api/app/application/ports/model_grants.py @@ -0,0 +1,50 @@ +"""Model grants port — which models the platform owner lets an organization use. + +Implemented by the org-platform workstream; used by the engine. FROZEN contract: +see docs/plan/PLAN-v2.md §5. ``tier`` is a neutral label; users never see model names. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +TIERS = ("fast", "balanced", "strong") + + +@dataclass(slots=True, frozen=True, kw_only=True) +class GrantedModel: + ai_model_id: str + tier: str = "balanced" # fast | balanced | strong + capabilities: tuple[str, ...] = field(default_factory=tuple) + daily_credit_cap: float | None = None + + +def tier_for(*, capabilities: list[str] | tuple[str, ...], output_price_per_1m: float) -> str: + """Neutral tier label from capabilities and price (never from the model name).""" + if "reasoning" in capabilities: + return "strong" + return "fast" if output_price_per_1m < 2.0 else "balanced" + + +@runtime_checkable +class ModelGrantPort(Protocol): + async def granted_models(self, *, organization_id: str) -> list[GrantedModel]: ... + + async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: ... + + +class AllActiveModelsGrants: + """Today's behaviour: every active model is usable by every organization. + + Reads active models through a callable so the application layer needs no ORM import. + """ + + def __init__(self, list_active_models) -> None: # noqa: ANN001 - async callable + self._list_active = list_active_models + + async def granted_models(self, *, organization_id: str) -> list[GrantedModel]: + return list(await self._list_active()) + + async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: + return any(m.ai_model_id == ai_model_id for m in await self._list_active()) diff --git a/apps/api/app/application/ports/repo_workspace.py b/apps/api/app/application/ports/repo_workspace.py new file mode 100644 index 0000000..fd68ac4 --- /dev/null +++ b/apps/api/app/application/ports/repo_workspace.py @@ -0,0 +1,185 @@ +"""Repo workspace port — the PLATFORM runs git on the host; the AI never does. + +Implemented by the delivery workstream (``LocalGitWorkspaceAdapter``), used by the engine. +``FakeRepoWorkspace`` is an in-memory implementation for tests and for wiring before the +real adapter exists. FROZEN contract: see docs/plan/PLAN-v2.md §5. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + + +@dataclass(slots=True, frozen=True, kw_only=True) +class WorkspaceRef: + project_id: str + host_path: str + sandbox_path: str = "/workspace" + default_branch: str = "dev" + + +@dataclass(slots=True, frozen=True, kw_only=True) +class WorktreeRef: + project_id: str + task_id: str + branch: str + host_path: str + #: Path of this worktree INSIDE the workspace container. + sandbox_path: str + + +@dataclass(slots=True, kw_only=True) +class DiffResult: + text: str = "" + changed_files: list[str] = field(default_factory=list) + #: Paths of symlinks that point outside the repo (guard input). + escaping_symlinks: list[str] = field(default_factory=list) + largest_file_bytes: int = 0 + + @property + def is_empty(self) -> bool: + return not self.changed_files + + +@dataclass(slots=True, kw_only=True) +class MergeOutcome: + ok: bool + sha: str | None = None + conflict_files: list[str] = field(default_factory=list) + #: True when the base moved, so the engine must re-run the tests. + base_changed: bool = False + message: str = "" + + +@dataclass(slots=True, kw_only=True) +class PushOutcome: + ok: bool + sha: str | None = None + rejected: bool = False + message: str = "" + + +@dataclass(slots=True, kw_only=True) +class RemoteCredentials: + auth_type: str = "none" # none | token | ssh_key + username: str | None = None + secret: str | None = None # plaintext, only ever held in memory for one call + + +@dataclass(slots=True, kw_only=True) +class RemoteCheck: + ok: bool + default_branch: str | None = None + branches: list[str] = field(default_factory=list) + #: Plain-language Persian message; never contains credentials or raw stderr. + message: str = "" + + +@runtime_checkable +class RepoWorkspacePort(Protocol): + async def ensure_workspace(self, *, project_id: str) -> WorkspaceRef: ... + + async def create_task_worktree( + self, *, project_id: str, task_id: str, base: str = "dev" + ) -> WorktreeRef: ... + + async def diff(self, *, worktree: WorktreeRef) -> DiffResult: ... + + async def commit(self, *, worktree: WorktreeRef, message: str, author: str) -> str | None: ... + + async def sync_with_base(self, *, worktree: WorktreeRef) -> MergeOutcome: ... + + async def merge_into( + self, *, project_id: str, branch: str, target: str = "dev" + ) -> MergeOutcome: ... + + async def push(self, *, project_id: str, branch: str) -> PushOutcome: ... + + async def remove_worktree(self, *, worktree: WorktreeRef) -> None: ... + + async def head(self, *, project_id: str, branch: str) -> str | None: ... + + async def test_remote(self, *, url: str, credentials: RemoteCredentials) -> RemoteCheck: ... + + +class FakeRepoWorkspace: + """In-memory ``RepoWorkspacePort`` that records every call. + + Tests can pre-load ``diffs`` (task_id → DiffResult), ``merge_outcomes`` / + ``push_outcomes`` (queues consumed in order) and ``remote_checks`` (url → RemoteCheck). + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + self.diffs: dict[str, DiffResult] = {} + self.merge_outcomes: list[MergeOutcome] = [] + self.sync_outcomes: list[MergeOutcome] = [] + self.push_outcomes: list[PushOutcome] = [] + self.remote_checks: dict[str, RemoteCheck] = {} + self.heads: dict[tuple[str, str], str] = {} + self._commits = 0 + + def _rec(self, name: str, **kw: object) -> None: + self.calls.append((name, kw)) + + async def ensure_workspace(self, *, project_id: str) -> WorkspaceRef: + self._rec("ensure_workspace", project_id=project_id) + return WorkspaceRef(project_id=project_id, host_path=f"/fake/{project_id}/work") + + async def create_task_worktree( + self, *, project_id: str, task_id: str, base: str = "dev" + ) -> WorktreeRef: + self._rec("create_task_worktree", project_id=project_id, task_id=task_id, base=base) + return WorktreeRef( + project_id=project_id, + task_id=task_id, + branch=f"task/{task_id}", + host_path=f"/fake/{project_id}/work/.worktrees/{task_id}", + sandbox_path=f"/workspace/.worktrees/{task_id}", + ) + + async def diff(self, *, worktree: WorktreeRef) -> DiffResult: + self._rec("diff", task_id=worktree.task_id) + return self.diffs.get(worktree.task_id, DiffResult()) + + async def commit(self, *, worktree: WorktreeRef, message: str, author: str) -> str | None: + self._rec("commit", task_id=worktree.task_id, message=message, author=author) + if self.diffs.get(worktree.task_id, DiffResult()).is_empty: + return None + self._commits += 1 + sha = f"{self._commits:040x}" + self.heads[(worktree.project_id, worktree.branch)] = sha + return sha + + async def sync_with_base(self, *, worktree: WorktreeRef) -> MergeOutcome: + self._rec("sync_with_base", task_id=worktree.task_id) + return self.sync_outcomes.pop(0) if self.sync_outcomes else MergeOutcome(ok=True) + + async def merge_into( + self, *, project_id: str, branch: str, target: str = "dev" + ) -> MergeOutcome: + self._rec("merge_into", project_id=project_id, branch=branch, target=target) + if self.merge_outcomes: + return self.merge_outcomes.pop(0) + sha = self.heads.get((project_id, branch)) or "0" * 40 + self.heads[(project_id, target)] = sha + return MergeOutcome(ok=True, sha=sha) + + async def push(self, *, project_id: str, branch: str) -> PushOutcome: + self._rec("push", project_id=project_id, branch=branch) + if self.push_outcomes: + return self.push_outcomes.pop(0) + return PushOutcome(ok=True, sha=self.heads.get((project_id, branch))) + + async def remove_worktree(self, *, worktree: WorktreeRef) -> None: + self._rec("remove_worktree", task_id=worktree.task_id) + + async def head(self, *, project_id: str, branch: str) -> str | None: + return self.heads.get((project_id, branch)) + + async def test_remote(self, *, url: str, credentials: RemoteCredentials) -> RemoteCheck: + self._rec("test_remote", url=url, auth_type=credentials.auth_type) + return self.remote_checks.get( + url, RemoteCheck(ok=True, default_branch="main", branches=["main"]) + ) diff --git a/apps/api/app/application/ports/workforce_bootstrap.py b/apps/api/app/application/ports/workforce_bootstrap.py new file mode 100644 index 0000000..d971751 --- /dev/null +++ b/apps/api/app/application/ports/workforce_bootstrap.py @@ -0,0 +1,22 @@ +"""Workforce bootstrap port — creates the starting AI team of a new organization. + +Implemented by the engine workstream; called by the org wizard after commit. +FROZEN contract: see docs/plan/PLAN-v2.md §5. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class WorkforceBootstrapPort(Protocol): + async def bootstrap_team(self, *, organization_id: str, project_id: str) -> None: ... + + +class NullWorkforceBootstrap: + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def bootstrap_team(self, *, organization_id: str, project_id: str) -> None: + self.calls.append({"organization_id": organization_id, "project_id": project_id}) diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index 5de0487..3bfeb57 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -127,6 +127,36 @@ class Settings(BaseSettings): #: 5xx responses are recorded regardless of this setting. error_log_client_errors: bool = False + # >>> agent-1 (org platform): add settings ONLY between these markers + mcp_gateway_host: str = "127.0.0.1" + mcp_gateway_port: int = 8765 + mcp_token_ttl_days: int = 90 + memory_org_char_cap: int = 60000 + platform_invite_ttl_hours: int = 72 + # <<< agent-1 + + # >>> agent-2 (AI workforce engine): add settings ONLY between these markers + engine_max_tool_calls_per_turn: int = 40 + engine_turn_timeout_seconds: int = 900 + engine_max_cycles: int = 6 + engine_max_employees: int = 20 + engine_tick_seconds: float = 10.0 + # <<< agent-2 + + # >>> agent-3 (delivery & infra): add settings ONLY between these markers + sandbox_docker_bin: str = "docker" + sandbox_workspaces_root: str = "./.data/workspaces" + sandbox_default_cpus: float = 1.0 + sandbox_default_memory_mb: int = 1024 + demo_port_start: int = 8100 + demo_port_end: int = 8999 + delivery_poll_seconds: int = 60 + deploy_allow_private_hosts: bool = False + artifacts_root: str = "./.data/artifacts" + git_workspace: str = "fake" # fake | local + allow_custom_domains: bool = False + # <<< agent-3 + @property def cors_origin_list(self) -> list[str]: return [o.strip() for o in self.cors_origins.split(",") if o.strip()] diff --git a/apps/api/app/domain/projects/profile.py b/apps/api/app/domain/projects/profile.py new file mode 100644 index 0000000..cf19deb --- /dev/null +++ b/apps/api/app/domain/projects/profile.py @@ -0,0 +1,57 @@ +"""The application profile of an organization's single app (filled by the wizard).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +APP_TYPES = ("web_app", "api", "static_site", "bot", "worker", "other") +GIT_SOURCES = ("platform", "external") + + +@dataclass(slots=True, frozen=True, kw_only=True) +class AppProfile: + """Everything the engine, the sandbox and CI/CD need to build and run the app. + + Secrets never live here: external git credentials and deploy targets have + their own encrypted tables. + """ + + project_id: str + app_type: str | None = None + language: str | None = None + framework: str | None = None + runtime_version: str | None = None + package_manager: str | None = None + setup_command: str | None = None + build_command: str | None = None + test_command: str | None = None + start_command: str | None = None + app_port: int | None = None + healthcheck_path: str = "/" + git_source: str = "platform" + external_repo_url: str | None = None + default_branch: str = "main" + wizard_completed: bool = False + + @classmethod + def from_row(cls, row: Any) -> AppProfile: + """Build from a ``ProjectRow`` (duck-typed so the domain stays ORM-free).""" + return cls( + project_id=row.id, + app_type=row.app_type, + language=row.language, + framework=row.framework, + runtime_version=row.runtime_version, + package_manager=row.package_manager, + setup_command=row.setup_command, + build_command=row.build_command, + test_command=row.test_command, + start_command=row.start_command, + app_port=row.app_port, + healthcheck_path=row.healthcheck_path or "/", + git_source=row.git_source or "platform", + external_repo_url=row.external_repo_url, + default_branch=row.default_branch or "main", + wizard_completed=row.wizard_completed_at is not None, + ) diff --git a/apps/api/app/infrastructure/db/columns.py b/apps/api/app/infrastructure/db/columns.py new file mode 100644 index 0000000..d624019 --- /dev/null +++ b/apps/api/app/infrastructure/db/columns.py @@ -0,0 +1,18 @@ +"""Column helpers shared by every ORM module (kept here to avoid import cycles).""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.domain.base import new_id, utcnow + + +def pk() -> Mapped[str]: + return mapped_column(String(32), primary_key=True, default=new_id) + + +def created() -> Mapped[datetime]: + return mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) diff --git a/apps/api/app/infrastructure/db/models.py b/apps/api/app/infrastructure/db/models.py index 78c98e9..f6797f2 100644 --- a/apps/api/app/infrastructure/db/models.py +++ b/apps/api/app/infrastructure/db/models.py @@ -221,6 +221,8 @@ class ProjectRow(Base): __tablename__ = "projects" __table_args__ = ( UniqueConstraint("organization_id", "slug", name="uq_project_org_slug"), + # v2 rule: one organization has exactly one application. + UniqueConstraint("organization_id", name="uq_project_one_per_org"), # The project list is always scoped to one organization and ordered by # creation, so one composite index serves both the filter and the sort. Index("ix_project_org_created", "organization_id", "created_at"), @@ -237,6 +239,94 @@ class ProjectRow(Base): ForeignKey("users.id"), nullable=True, index=True ) default_branch: Mapped[str] = mapped_column(String(80), default="main", nullable=False) + # --- v2 application profile (filled by the organization wizard) --- + app_type: Mapped[str | None] = mapped_column(String(32), nullable=True) + language: Mapped[str | None] = mapped_column(String(32), nullable=True) + framework: Mapped[str | None] = mapped_column(String(48), nullable=True) + runtime_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + package_manager: Mapped[str | None] = mapped_column(String(32), nullable=True) + setup_command: Mapped[str | None] = mapped_column(Text, nullable=True) + build_command: Mapped[str | None] = mapped_column(Text, nullable=True) + test_command: Mapped[str | None] = mapped_column(Text, nullable=True) + start_command: Mapped[str | None] = mapped_column(Text, nullable=True) + app_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + healthcheck_path: Mapped[str] = mapped_column(String(255), default="/", nullable=False) + git_source: Mapped[str] = mapped_column(String(16), default="platform", nullable=False) + external_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + wizard_completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = _created() + + +class ProjectExternalGitRow(Base): + """Credentials for a user-supplied git remote. Secrets are encrypted at rest.""" + + __tablename__ = "project_external_git" + + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True + ) + auth_type: Mapped[str] = mapped_column(String(16), default="none", nullable=False) + username: Mapped[str | None] = mapped_column(String(160), nullable=True) + secret_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True) + known_hosts: Mapped[str | None] = mapped_column(Text, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False + ) + + +class DeployTargetRow(Base): + """A production deploy target (ssh / sftp / ftp / ftps). Secrets are encrypted.""" + + __tablename__ = "deploy_targets" + __table_args__ = (Index("ix_deploy_target_project", "project_id"),) + + id: Mapped[str] = _pk() + organization_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True + ) + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + kind: Mapped[str] = mapped_column(String(8), nullable=False) + host: Mapped[str] = mapped_column(String(255), nullable=False) + port: Mapped[int] = mapped_column(Integer, nullable=False) + username: Mapped[str] = mapped_column(String(160), nullable=False) + auth_type: Mapped[str] = mapped_column(String(16), default="password", nullable=False) + secret_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True) + remote_path: Mapped[str] = mapped_column(String(512), nullable=False) + post_deploy_command: Mapped[str | None] = mapped_column(Text, nullable=True) + host_fingerprint: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + last_tested_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + last_test_ok: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + last_test_message: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at: Mapped[datetime] = _created() + + +class OrgModelGrantRow(Base): + """A model the platform owner lets one organization's employees use.""" + + __tablename__ = "org_model_grants" + __table_args__ = ( + UniqueConstraint("organization_id", "ai_model_id", name="uq_org_model_grant"), + ) + + id: Mapped[str] = _pk() + organization_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True + ) + ai_model_id: Mapped[str] = mapped_column( + ForeignKey("ai_models.id", ondelete="CASCADE"), nullable=False + ) + daily_credit_cap: Mapped[float | None] = mapped_column(Float, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_by_user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id"), nullable=True + ) created_at: Mapped[datetime] = _created() @@ -926,3 +1016,13 @@ class QualityMetricRow(Base): value: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) dimensions_json: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) created_at: Mapped[datetime] = _created() + + +# --------------------------------------------------------------------------- +# v2 tables owned by one workstream each (kept in separate modules so the three +# parallel agents never edit the same file). Imported last so every table is +# registered on ``Base.metadata`` before ``create_all`` / alembic autogenerate. +# --------------------------------------------------------------------------- +from app.infrastructure.db import models_delivery as _models_delivery # noqa: E402,F401 +from app.infrastructure.db import models_engine as _models_engine # noqa: E402,F401 +from app.infrastructure.db import models_org as _models_org # noqa: E402,F401 diff --git a/apps/api/app/infrastructure/db/models_delivery.py b/apps/api/app/infrastructure/db/models_delivery.py new file mode 100644 index 0000000..57cf3f9 --- /dev/null +++ b/apps/api/app/infrastructure/db/models_delivery.py @@ -0,0 +1,10 @@ +"""ORM tables owned by Agent 3 (delivery & infra). + +Use ``from app.core.database import Base`` and the helpers in +``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from +``app.infrastructure.db.models`` here: that module imports this one (import cycle). +Foreign keys to existing tables are written as plain strings, e.g. +``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6. +""" + +from __future__ import annotations diff --git a/apps/api/app/infrastructure/db/models_engine.py b/apps/api/app/infrastructure/db/models_engine.py new file mode 100644 index 0000000..d02adb1 --- /dev/null +++ b/apps/api/app/infrastructure/db/models_engine.py @@ -0,0 +1,10 @@ +"""ORM tables owned by Agent 2 (AI workforce engine). + +Use ``from app.core.database import Base`` and the helpers in +``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from +``app.infrastructure.db.models`` here: that module imports this one (import cycle). +Foreign keys to existing tables are written as plain strings, e.g. +``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6. +""" + +from __future__ import annotations diff --git a/apps/api/app/infrastructure/db/models_org.py b/apps/api/app/infrastructure/db/models_org.py new file mode 100644 index 0000000..a62615c --- /dev/null +++ b/apps/api/app/infrastructure/db/models_org.py @@ -0,0 +1,10 @@ +"""ORM tables owned by Agent 1 (org platform). + +Use ``from app.core.database import Base`` and the helpers in +``app.infrastructure.db.columns`` (``pk()``, ``created()``). Do NOT import from +``app.infrastructure.db.models`` here: that module imports this one (import cycle). +Foreign keys to existing tables are written as plain strings, e.g. +``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6. +""" + +from __future__ import annotations diff --git a/apps/api/app/infrastructure/repositories/app_profile.py b/apps/api/app/infrastructure/repositories/app_profile.py new file mode 100644 index 0000000..1f44b92 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/app_profile.py @@ -0,0 +1,82 @@ +"""Read/write access to the app profile columns of ``projects``. + +Foundation-owned and shared by all three workstreams (agents may call it, not edit it). +Every lookup is scoped by ``organization_id``. +""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.projects.profile import AppProfile +from app.infrastructure.db.models import ProjectRow + +#: Columns a caller may change through ``update_profile``. +EDITABLE_FIELDS = frozenset( + { + "app_type", + "language", + "framework", + "runtime_version", + "package_manager", + "setup_command", + "build_command", + "test_command", + "start_command", + "app_port", + "healthcheck_path", + "git_source", + "external_repo_url", + "default_branch", + } +) + + +class AppProfileRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_for_org(self, organization_id: str) -> AppProfile | None: + row = await self._row_for_org(organization_id) + return AppProfile.from_row(row) if row else None + + async def get(self, *, organization_id: str, project_id: str) -> AppProfile | None: + row = await self._session.scalar( + select(ProjectRow).where( + ProjectRow.id == project_id, ProjectRow.organization_id == organization_id + ) + ) + return AppProfile.from_row(row) if row else None + + async def update_profile( + self, + *, + organization_id: str, + project_id: str, + values: dict[str, object], + mark_wizard_completed: datetime | None = None, + ) -> AppProfile | None: + unknown = set(values) - EDITABLE_FIELDS + if unknown: + raise ValueError(f"not editable profile fields: {sorted(unknown)}") + row = await self._session.scalar( + select(ProjectRow).where( + ProjectRow.id == project_id, ProjectRow.organization_id == organization_id + ) + ) + if row is None: + return None + for name, value in values.items(): + setattr(row, name, value) + if mark_wizard_completed is not None: + row.wizard_completed_at = mark_wizard_completed + await self._session.flush() + return AppProfile.from_row(row) + + async def _row_for_org(self, organization_id: str) -> ProjectRow | None: + return await self._session.scalar( + select(ProjectRow).where(ProjectRow.organization_id == organization_id) + ) diff --git a/apps/api/app/interfaces/http/routers/admin_domains.py b/apps/api/app/interfaces/http/routers/admin_domains.py new file mode 100644 index 0000000..ceb7497 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/admin_domains.py @@ -0,0 +1,11 @@ +"""owner domain routes: /admin/domains/*. + +Owned by Agent 3 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each +route its full path (no prefix is applied here besides ``/api``). +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(tags=["admin-domains"]) diff --git a/apps/api/app/interfaces/http/routers/admin_grants.py b/apps/api/app/interfaces/http/routers/admin_grants.py new file mode 100644 index 0000000..2914eb9 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/admin_grants.py @@ -0,0 +1,11 @@ +"""owner model grants and org policies: /admin/model-grants/*, /admin/org-policies/*. + +Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each +route its full path (no prefix is applied here besides ``/api``). +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(tags=["admin-grants"]) diff --git a/apps/api/app/interfaces/http/routers/app_setup.py b/apps/api/app/interfaces/http/routers/app_setup.py new file mode 100644 index 0000000..da34900 --- /dev/null +++ b/apps/api/app/interfaces/http/routers/app_setup.py @@ -0,0 +1,11 @@ +"""org wizard, app profile, deploy-target settings: /setup/*, /organizations/{org}/app/*. + +Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each +route its full path (no prefix is applied here besides ``/api``). +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(tags=["app-setup"]) diff --git a/apps/api/app/interfaces/http/routers/delivery.py b/apps/api/app/interfaces/http/routers/delivery.py new file mode 100644 index 0000000..e2fb65a --- /dev/null +++ b/apps/api/app/interfaces/http/routers/delivery.py @@ -0,0 +1,11 @@ +"""CI/CD runs, releases, production deploy: /organizations/{org}/delivery/*. + +Owned by Agent 3 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each +route its full path (no prefix is applied here besides ``/api``). +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(tags=["delivery"]) diff --git a/apps/api/app/interfaces/http/routers/mcp_tokens.py b/apps/api/app/interfaces/http/routers/mcp_tokens.py new file mode 100644 index 0000000..16e67ce --- /dev/null +++ b/apps/api/app/interfaces/http/routers/mcp_tokens.py @@ -0,0 +1,11 @@ +"""MCP tokens: /organizations/{org}/mcp-tokens/*, /admin/mcp/*. + +Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each +route its full path (no prefix is applied here besides ``/api``). +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(tags=["mcp-tokens"]) diff --git a/apps/api/app/interfaces/http/routers/memory.py b/apps/api/app/interfaces/http/routers/memory.py new file mode 100644 index 0000000..06db1cd --- /dev/null +++ b/apps/api/app/interfaces/http/routers/memory.py @@ -0,0 +1,11 @@ +"""org memory: /organizations/{org}/memory/*. + +Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each +route its full path (no prefix is applied here besides ``/api``). +""" + +from __future__ import annotations + +from fastapi import APIRouter + +router = APIRouter(tags=["memory"]) diff --git a/apps/api/app/interfaces/mcp/__init__.py b/apps/api/app/interfaces/mcp/__init__.py new file mode 100644 index 0000000..9726d5d --- /dev/null +++ b/apps/api/app/interfaces/mcp/__init__.py @@ -0,0 +1 @@ +"""MCP gateway (owned by Agent 1).""" diff --git a/apps/api/app/interfaces/mcp/server.py b/apps/api/app/interfaces/mcp/server.py new file mode 100644 index 0000000..4fbd82e --- /dev/null +++ b/apps/api/app/interfaces/mcp/server.py @@ -0,0 +1,14 @@ +"""MCP gateway entry point (owned by Agent 1). Foundation stub.""" + +from __future__ import annotations + + +def main() -> None: + raise SystemExit( + "MCP gateway is not implemented yet " + "(see docs/plan/TODO-agent-1-org-platform.md, Phase 5)" + ) + + +if __name__ == "__main__": + main() diff --git a/apps/api/app/interfaces/realtime/rpc_app.py b/apps/api/app/interfaces/realtime/rpc_app.py index 31eab12..0c233e4 100644 --- a/apps/api/app/interfaces/realtime/rpc_app.py +++ b/apps/api/app/interfaces/realtime/rpc_app.py @@ -23,13 +23,19 @@ from app.interfaces.http.errors import register_exception_handlers from app.interfaces.http.request_context import RequestContextMiddleware from app.interfaces.http.routers import ( admin, + admin_domains, + admin_grants, admin_users, agy, + app_setup, auth, billing, + delivery, health, invites, knowledge, + mcp_tokens, + memory, organizations, projects, sandbox, @@ -71,6 +77,13 @@ def build_rpc_app() -> FastAPI: application.include_router(admin.router, prefix="/api") application.include_router(admin_users.router, prefix="/api") application.include_router(agy.router, prefix="/api") + # v2 routers (one workstream each; see docs/plan/PLAN-v2.md §6). + application.include_router(app_setup.router, prefix="/api") + application.include_router(memory.router, prefix="/api") + application.include_router(mcp_tokens.router, prefix="/api") + application.include_router(admin_grants.router, prefix="/api") + application.include_router(delivery.router, prefix="/api") + application.include_router(admin_domains.router, prefix="/api") application.include_router(realtime_router, prefix="/api") return application diff --git a/apps/api/app/workers/__main__.py b/apps/api/app/workers/__main__.py index 301e5fb..865782b 100644 --- a/apps/api/app/workers/__main__.py +++ b/apps/api/app/workers/__main__.py @@ -3,6 +3,7 @@ python -m app.workers billing [--loop --interval 3600] python -m app.workers ai-runtime [--loop --interval 30 --limit 10] python -m app.workers sandbox-gateway [--loop --interval 600 --idle-minutes 120] + python -m app.workers delivery [--loop --interval 10 --limit 10] Default is **one pass and exit**, which is the shape a cron entry wants. Pass ``--loop`` for a long-running deployment; SIGINT/SIGTERM then stop it cleanly. @@ -24,7 +25,7 @@ def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="app.workers", description="AGY background workers") parser.add_argument( "worker", - choices=["billing", "ai-runtime", "sandbox-gateway"], + choices=["billing", "ai-runtime", "sandbox-gateway", "delivery"], help="which worker to run", ) parser.add_argument("--loop", action="store_true", help="keep running instead of one pass") @@ -92,12 +93,25 @@ def _sandbox_gateway_pass(args: argparse.Namespace): return one_pass +def _delivery_pass(args: argparse.Namespace): + from app.workers.delivery import DeliveryWorker + + worker = DeliveryWorker() + + async def one_pass() -> WorkerOutcome: + return await worker.run_pass(limit=args.limit) + + return one_pass + + def _pass_function(args: argparse.Namespace): """Return a zero-argument coroutine function for one pass.""" if args.worker == "billing": return _billing_pass(args) if args.worker == "ai-runtime": return _ai_runtime_pass(args) + if args.worker == "delivery": + return _delivery_pass(args) return _sandbox_gateway_pass(args) diff --git a/apps/api/app/workers/delivery.py b/apps/api/app/workers/delivery.py new file mode 100644 index 0000000..395e209 --- /dev/null +++ b/apps/api/app/workers/delivery.py @@ -0,0 +1,15 @@ +"""Delivery worker — executes queued CI/CD pipeline runs and polls remotes for pushes. + +Owned by Agent 3 (see docs/plan/PLAN-v2.md). Foundation stub: one no-op pass. +""" + +from __future__ import annotations + +from app.workers.base import WorkerOutcome + + +class DeliveryWorker: + name = "delivery" + + async def run_pass(self, *, limit: int = 10) -> WorkerOutcome: + return WorkerOutcome(name=self.name) diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index a2e7782..8a49a17 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -19,6 +19,9 @@ dependencies = [ "redis>=5.2.0", "httpx>=0.28.0", "python-multipart>=0.0.20", + "asyncssh>=2.17.0", + "aioftp>=0.23.0", + "mcp>=1.2.0", ] [project.optional-dependencies] diff --git a/apps/api/tests/test_foundation.py b/apps/api/tests/test_foundation.py new file mode 100644 index 0000000..2092bb9 --- /dev/null +++ b/apps/api/tests/test_foundation.py @@ -0,0 +1,226 @@ +"""v2 foundation: schema invariants, shared repository, contracts and wiring.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.exc import IntegrityError + +import app.application.container as container +from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks +from app.application.ports.deploy_target import ( + DeployTargetSpec, + DeployTargetTester, + FakeDeployTargetTester, +) +from app.application.ports.memory import MemoryPort, NullMemory, TaskOutcome +from app.application.ports.model_grants import ModelGrantPort, tier_for +from app.application.ports.repo_workspace import ( + DiffResult, + FakeRepoWorkspace, + MergeOutcome, + RemoteCredentials, + RepoWorkspacePort, +) +from app.application.ports.workforce_bootstrap import ( + NullWorkforceBootstrap, + WorkforceBootstrapPort, +) +from app.domain.base import new_id +from app.domain.identity.models import PlatformRole +from app.infrastructure.db.models import ( + AiModelRow, + AiProviderRow, + OrganizationRow, + ProjectRow, + UserRow, +) +from app.infrastructure.repositories.app_profile import AppProfileRepository + + +async def _org_with_user(session, slug: str) -> tuple[str, str]: + user = UserRow( + id=new_id(), + email=f"{slug}@example.com", + password_hash="x", + name="t", + platform_role=PlatformRole.MANAGER.value, + ) + session.add(user) + await session.flush() + org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id) + session.add(org) + await session.flush() + return org.id, user.id + + +@pytest.mark.asyncio +async def test_one_project_per_organization(session_factory): + async with session_factory() as session: + org_id, _ = await _org_with_user(session, "solo") + session.add(ProjectRow(id=new_id(), organization_id=org_id, name="a", slug="a")) + await session.commit() + session.add(ProjectRow(id=new_id(), organization_id=org_id, name="b", slug="b")) + with pytest.raises(IntegrityError): + await session.commit() + + +@pytest.mark.asyncio +async def test_app_profile_repository_is_tenant_scoped(session_factory): + async with session_factory() as session: + org_a, _ = await _org_with_user(session, "org-a") + org_b, _ = await _org_with_user(session, "org-b") + project = ProjectRow(id=new_id(), organization_id=org_a, name="a", slug="a") + session.add(project) + await session.commit() + + repo = AppProfileRepository(session) + profile = await repo.get_for_org(org_a) + assert profile is not None + assert profile.healthcheck_path == "/" + assert profile.git_source == "platform" + assert profile.wizard_completed is False + + # Another org can neither read nor update this project's profile. + assert await repo.get_for_org(org_b) is None + assert await repo.get(organization_id=org_b, project_id=project.id) is None + assert ( + await repo.update_profile( + organization_id=org_b, project_id=project.id, values={"language": "go"} + ) + is None + ) + + updated = await repo.update_profile( + organization_id=org_a, + project_id=project.id, + values={"language": "python", "framework": "fastapi", "app_port": 8000}, + ) + assert updated is not None + assert (updated.language, updated.framework, updated.app_port) == ( + "python", + "fastapi", + 8000, + ) + + with pytest.raises(ValueError): + await repo.update_profile( + organization_id=org_a, project_id=project.id, values={"organization_id": "x"} + ) + + +@pytest.mark.asyncio +async def test_default_model_grants_return_active_models_with_tiers(session_factory): + async with session_factory() as session: + provider = AiProviderRow(id=new_id(), name="agy-test") + session.add(provider) + await session.flush() + session.add_all( + [ + AiModelRow( + id="m-strong", provider_id=provider.id, internal_name="s", + display_name_for_admin="S", capabilities=["text", "reasoning"], + output_price_per_1m=10.0, + ), + AiModelRow( + id="m-fast", provider_id=provider.id, internal_name="f", + display_name_for_admin="F", capabilities=["text"], output_price_per_1m=0.5, + ), + AiModelRow( + id="m-off", provider_id=provider.id, internal_name="o", + display_name_for_admin="O", capabilities=["text"], is_active=False, + ), + ] + ) + await session.commit() + + grants = container.build_model_grants(session) + assert isinstance(grants, ModelGrantPort) + granted = {g.ai_model_id: g.tier for g in await grants.granted_models(organization_id="o")} + assert granted == {"m-strong": "strong", "m-fast": "fast"} + assert await grants.is_granted(organization_id="o", ai_model_id="m-fast") + assert not await grants.is_granted(organization_id="o", ai_model_id="m-off") + + +def test_tier_for_never_uses_names(): + assert tier_for(capabilities=["reasoning"], output_price_per_1m=99) == "strong" + assert tier_for(capabilities=["text"], output_price_per_1m=1) == "fast" + assert tier_for(capabilities=["text"], output_price_per_1m=5) == "balanced" + + +@pytest.mark.asyncio +async def test_contract_fakes_satisfy_their_protocols(): + assert isinstance(FakeRepoWorkspace(), RepoWorkspacePort) + assert isinstance(NullDeliveryHooks(), DeliveryHooks) + assert isinstance(FakeDeployTargetTester(), DeployTargetTester) + assert isinstance(NullMemory(), MemoryPort) + assert isinstance(NullWorkforceBootstrap(), WorkforceBootstrapPort) + + +@pytest.mark.asyncio +async def test_fake_repo_workspace_full_flow(): + repo = FakeRepoWorkspace() + ws = await repo.create_task_worktree(project_id="p", task_id="t1") + assert ws.branch == "task/t1" and ws.sandbox_path.endswith("/t1") + # An empty diff commits nothing. + assert await repo.commit(worktree=ws, message="m", author="a") is None + repo.diffs["t1"] = DiffResult(text="+x", changed_files=["a.py"]) + sha = await repo.commit(worktree=ws, message="m", author="a") + assert sha + merged = await repo.merge_into(project_id="p", branch=ws.branch) + assert merged.ok and merged.sha == sha + assert (await repo.push(project_id="p", branch="dev")).ok + assert await repo.head(project_id="p", branch="dev") == sha + repo.merge_outcomes.append(MergeOutcome(ok=False, conflict_files=["a.py"])) + assert (await repo.merge_into(project_id="p", branch="x")).conflict_files == ["a.py"] + check = await repo.test_remote(url="https://x/y.git", credentials=RemoteCredentials()) + assert check.ok + + +@pytest.mark.asyncio +async def test_fake_deploy_tester_and_hooks_and_memory_record_calls(): + tester = FakeDeployTargetTester() + ok = await tester.test_connection( + target=DeployTargetSpec(kind="ssh", host="h.example.com", port=22, username="u") + ) + bad = await tester.test_connection( + target=DeployTargetSpec(kind="ftp", host="fail.example.com", port=21, username="u") + ) + assert ok.ok and ok.fingerprint and not bad.ok + + hooks = NullDeliveryHooks() + await hooks.on_app_created(organization_id="o", project_id="p") + await hooks.on_branch_pushed( + organization_id="o", project_id="p", branch="dev", sha="abc", source="engine" + ) + assert len(hooks.created) == 1 and hooks.pushed[0]["sha"] == "abc" + + memory = NullMemory() + assert await memory.build_context(organization_id="o", role="developer", query="q") == "" + await memory.on_task_finished( + organization_id="o", task=TaskOutcome(task_id="t", title="x", status="completed") + ) + assert memory.finished[0].task_id == "t" + + +@pytest.mark.asyncio +async def test_container_exposes_all_v2_builders(session_factory): + async with session_factory() as session: + assert isinstance(container.build_repo_workspace(session), RepoWorkspacePort) + assert isinstance(container.build_delivery_hooks(session), DeliveryHooks) + assert isinstance(container.build_deploy_target_tester(session), DeployTargetTester) + assert isinstance(container.build_memory(session), MemoryPort) + assert isinstance(container.build_workforce_bootstrap(session), WorkforceBootstrapPort) + + +@pytest.mark.asyncio +async def test_app_builds_with_v2_routers_and_delivery_worker_runs(client): + # The full handler app imports every v2 router; /health proves it assembled. + assert (await client.get("/health")).status_code == 200 + + from app.workers.__main__ import _build_parser, _pass_function + from app.workers.delivery import DeliveryWorker + + assert (await DeliveryWorker().run_pass()).failed == 0 + # ...and it is selectable from the CLI. + one_pass = _pass_function(_build_parser().parse_args(["delivery"])) + assert (await one_pass()).name == "delivery" diff --git a/apps/web/src/app/(app)/admin/domains/page.tsx b/apps/web/src/app/(app)/admin/domains/page.tsx new file mode 100644 index 0000000..208053c --- /dev/null +++ b/apps/web/src/app/(app)/admin/domains/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { PlaceholderPage } from "@/components/PlaceholderPage"; + +export default function AdminDomainsPage() { + return ( + + ); +} diff --git a/apps/web/src/app/(app)/admin/layout.tsx b/apps/web/src/app/(app)/admin/layout.tsx index 0de0ef9..b5e4517 100644 --- a/apps/web/src/app/(app)/admin/layout.tsx +++ b/apps/web/src/app/(app)/admin/layout.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import * as React from "react"; import { EmptyState, LoadingState } from "@agy/ui"; +import { ADMIN_ITEMS } from "@/lib/nav"; import { useSession } from "@/lib/session"; /** @@ -14,17 +15,7 @@ import { useSession } from "@/lib/session"; * never the security boundary. */ -const SECTIONS = [ - { href: "/admin", label: "نمای کلی" }, - { href: "/admin/users", label: "کاربران و مدیران" }, - { href: "/admin/agy-accounts", label: "اکانت‌های agy" }, - { href: "/admin/models", label: "مدل‌ها" }, - { href: "/admin/routing", label: "مسیریابی" }, - { href: "/admin/rate-cards", label: "تعرفه‌ها" }, - { href: "/admin/usage", label: "مصرف سراسری" }, - { href: "/admin/organizations", label: "سازمان‌ها" }, - { href: "/admin/errors", label: "کنسول خطا" }, -] as const; +const SECTIONS = ADMIN_ITEMS; export default function AdminLayout({ children }: { children: React.ReactNode }) { const { user, ready } = useSession(); diff --git a/apps/web/src/app/(app)/admin/mcp/page.tsx b/apps/web/src/app/(app)/admin/mcp/page.tsx new file mode 100644 index 0000000..ce7bdf7 --- /dev/null +++ b/apps/web/src/app/(app)/admin/mcp/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { PlaceholderPage } from "@/components/PlaceholderPage"; + +export default function AdminMcpPage() { + return ( + + ); +} diff --git a/apps/web/src/app/(app)/admin/model-grants/page.tsx b/apps/web/src/app/(app)/admin/model-grants/page.tsx new file mode 100644 index 0000000..4b50fbe --- /dev/null +++ b/apps/web/src/app/(app)/admin/model-grants/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { PlaceholderPage } from "@/components/PlaceholderPage"; + +export default function AdminModelGrantsPage() { + return ( + + ); +} diff --git a/apps/web/src/app/(app)/admin/sandbox/page.tsx b/apps/web/src/app/(app)/admin/sandbox/page.tsx new file mode 100644 index 0000000..256a365 --- /dev/null +++ b/apps/web/src/app/(app)/admin/sandbox/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { PlaceholderPage } from "@/components/PlaceholderPage"; + +export default function AdminSandboxPage() { + return ( + + ); +} diff --git a/apps/web/src/app/(app)/app/page.tsx b/apps/web/src/app/(app)/app/page.tsx new file mode 100644 index 0000000..951abb3 --- /dev/null +++ b/apps/web/src/app/(app)/app/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { PlaceholderPage } from "@/components/PlaceholderPage"; + +export default function AppPage() { + return ( + + ); +} diff --git a/apps/web/src/app/(app)/memory/page.tsx b/apps/web/src/app/(app)/memory/page.tsx new file mode 100644 index 0000000..834f98a --- /dev/null +++ b/apps/web/src/app/(app)/memory/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { PlaceholderPage } from "@/components/PlaceholderPage"; + +export default function MemoryPage() { + return ( + + ); +} diff --git a/apps/web/src/lib/api-delivery.ts b/apps/web/src/lib/api-delivery.ts new file mode 100644 index 0000000..a7da8bc --- /dev/null +++ b/apps/web/src/lib/api-delivery.ts @@ -0,0 +1,10 @@ +/** + * Typed client functions for the delivery workstream (docs/plan/PLAN-v2.md §6). + * + * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport. + */ +import { apiRequest } from "./api"; + +export const deliveryApi = { + // example: list: (orgId: string) => apiRequest(`/api/organizations/${orgId}/foo`), +} satisfies Record ReturnType>; diff --git a/apps/web/src/lib/api-engine.ts b/apps/web/src/lib/api-engine.ts new file mode 100644 index 0000000..2e3159a --- /dev/null +++ b/apps/web/src/lib/api-engine.ts @@ -0,0 +1,10 @@ +/** + * Typed client functions for the AI-workforce workstream (docs/plan/PLAN-v2.md §6). + * + * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport. + */ +import { apiRequest } from "./api"; + +export const engineApi = { + // example: list: (orgId: string) => apiRequest(`/api/organizations/${orgId}/foo`), +} satisfies Record ReturnType>; diff --git a/apps/web/src/lib/api-org.ts b/apps/web/src/lib/api-org.ts new file mode 100644 index 0000000..7a19232 --- /dev/null +++ b/apps/web/src/lib/api-org.ts @@ -0,0 +1,10 @@ +/** + * Typed client functions for the org-platform workstream (docs/plan/PLAN-v2.md §6). + * + * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport. + */ +import { apiRequest } from "./api"; + +export const orgApi = { + // example: list: (orgId: string) => apiRequest(`/api/organizations/${orgId}/foo`), +} satisfies Record ReturnType>; diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts index 22b77fc..1f95371 100644 --- a/apps/web/src/lib/nav.ts +++ b/apps/web/src/lib/nav.ts @@ -16,10 +16,10 @@ export const NAV_ITEMS: NavItem[] = [ { href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" }, { href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" }, { href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" }, - { href: "/projects", label: "پروژه‌ها", hint: "Git و محیط اجرا" }, + { href: "/app", label: "اپلیکیشن", hint: "دمو، استقرار، Git" }, { href: "/tasks", label: "وظایف", hint: "وضعیت کارها" }, { href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" }, - { href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" }, + { href: "/memory", label: "حافظهٔ سازمان", hint: "قوانین، درس‌ها و دانش" }, { href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" }, { href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" }, ]; @@ -39,5 +39,9 @@ export const ADMIN_ITEMS: NavItem[] = [ { href: "/admin/rate-cards", label: "تعرفه‌ها", hint: "قیمت واحدها" }, { href: "/admin/usage", label: "مصرف سراسری", hint: "گزارش مصرف مستأجرها" }, { href: "/admin/organizations", label: "سازمان‌ها", hint: "مستأجرها" }, + { href: "/admin/model-grants", label: "مدل‌های سازمان‌ها", hint: "تخصیص مدل به هر سازمان" }, + { href: "/admin/domains", label: "دامنه‌ها", hint: "مسیریابی Caddy" }, + { href: "/admin/sandbox", label: "سندباکس‌ها", hint: "کانتینرها و محدودیت‌ها" }, + { href: "/admin/mcp", label: "MCP", hint: "دسترسی عامل‌های بیرونی" }, { href: "/admin/errors", label: "کنسول خطا", hint: "ردیابی خطاها" }, ]; diff --git a/apps/web/src/test/command-palette.test.tsx b/apps/web/src/test/command-palette.test.tsx index e7a22aa..acfd3cc 100644 --- a/apps/web/src/test/command-palette.test.tsx +++ b/apps/web/src/test/command-palette.test.tsx @@ -45,10 +45,10 @@ describe("command palette", () => { openWithShortcut(); fireEvent.change(screen.getByLabelText("جست‌وجوی بخش‌ها"), { - target: { value: "پروژه" }, + target: { value: "اپلیکیشن" }, }); - expect(screen.getByRole("button", { name: /پروژه/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /اپلیکیشن/ })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /داشبورد/ })).not.toBeInTheDocument(); }); diff --git a/docs/plan/PLAN-v2.md b/docs/plan/PLAN-v2.md index ac94849..e76ed0b 100644 --- a/docs/plan/PLAN-v2.md +++ b/docs/plan/PLAN-v2.md @@ -7,7 +7,7 @@ its own TODO file: | File | Agent | Area | |---|---|---| -| `docs/plan/TODO-0-foundation.md` | Claude (runs FIRST, before the agents) | shared contracts, DB foundation, wiring | +| `docs/plan/TODO-0-foundation.md` | Claude (DONE before the agents start) | shared contracts, DB foundation, wiring | | `docs/plan/TODO-agent-1-org-platform.md` | Agent 1 | invites, org wizard, app profile, sharing, owner model grants, memory, MCP | | `docs/plan/TODO-agent-2-ai-workforce.md` | Agent 2 | admin (manager) AI, employees, dynamic pipelines, agent loop, test/commit/push | | `docs/plan/TODO-agent-3-delivery-infra.md` | Agent 3 | docker sandbox, git workspaces, live CI/CD, demo server, Caddy domains, SSH/FTP deploy | @@ -214,6 +214,14 @@ Log events are **batched** (at most one event per 500 ms per stream, max 200 lin `*` = new file (Claude creates an empty stub in TODO-0, the agent fills it). +**Foundation-owned shared helpers (read/call only, never edit):** `app/infrastructure/db/columns.py` +(`pk()`, `created()` for your `models_*.py` files; never import from `db/models.py` there, it causes an import +cycle), `app/infrastructure/repositories/app_profile.py` (`AppProfileRepository`: read the app profile, or write +it with `update_profile`; this is how Agent 1 writes and Agents 2 and 3 read build/test commands, port, healthcheck), +`app/domain/projects/profile.py` (`AppProfile`), `app/application/ports/model_grants.py::tier_for`. +Wiring functions are in `container_org.py` / `container_engine.py` / `container_delivery.py`: you replace **the +body** of your own builders (names and return types are frozen); `container.py` re-exports them. + **Locked files (Claude only):** `db/models.py`, `container.py`, `rpc_app.py`, `workers/__main__.py`, `ports/*.py` contracts, `tests/conftest.py`, `pyproject.toml`, `package.json`, `pnpm-lock.yaml`, `apps/web/src/lib/api.ts`, `apps/web/src/lib/nav.ts`, `components/AppShell.tsx`, `alembic/env.py`, root @@ -222,11 +230,17 @@ Log events are **batched** (at most one event per 500 ms per stream, max 200 lin **Append-only blocks.** `app/core/config.py` and `.env.example` each have three marked blocks (`# >>> agent-1` … `# <<< agent-1`, and so on). You may add lines **only inside your own block**. -Each new migration uses `down_revision = "a0f0e1d2c3b4"` (the foundation). Claude adds the merge +Routers for every new area are already registered in `rpc_app.py` (empty stubs: `app_setup`, `memory`, +`mcp_tokens`, `admin_grants`, `delivery`, `admin_domains`); the delivery worker is registered as `delivery` in +`workers/__main__.py`. The admin console tab list now comes from `ADMIN_ITEMS` in `lib/nav.ts` (the layout no longer +has its own copy). Each new migration uses `down_revision = "a0f0e1d2c3b4"` (the foundation). Claude adds the merge revision when the branches are merged. ## 7. Working rules for every agent +0. **Environment:** the main repo is on an exFAT disk (`/projects`, no symlinks, so no venv/node_modules there). + Your worktree lives on ext4 under `/home/mamad/work/smart-org-wt/` with `.venv` and `node_modules` already + installed. Use `pnpm` (in `~/.local/bin`) and `apps/api/.venv/bin/python`. Python is 3.13 via `uv`. 1. Work **only** in your own git worktree and branch (given in your TODO file). Never `cd` outside it and never write to `/tmp`. Put scratch scripts in `./_scratch/` inside your worktree and delete them at the end. 2. **Do NOT commit, push, merge or rebase.** Claude reviews and commits each phase. diff --git a/docs/plan/TODO-0-foundation.md b/docs/plan/TODO-0-foundation.md index 4014cf0..93dd0db 100644 --- a/docs/plan/TODO-0-foundation.md +++ b/docs/plan/TODO-0-foundation.md @@ -52,9 +52,9 @@ Read `docs/plan/PLAN-v2.md` §4–§6 first. Everything here is mechanical; no p - [ ] Commit on `dev`: `v2 foundation: shared contracts, app profile schema, wiring stubs`. - [ ] Create the worktrees from that commit: ```bash - git worktree add ../smart-org-wt/agent1 -b agent1/org-platform - git worktree add ../smart-org-wt/agent2 -b agent2/ai-workforce - git worktree add ../smart-org-wt/agent3 -b agent3/delivery-infra + git worktree add /home/mamad/work/smart-org-wt/agent1 -b agent1/org-platform + git worktree add /home/mamad/work/smart-org-wt/agent2 -b agent2/ai-workforce + git worktree add /home/mamad/work/smart-org-wt/agent3 -b agent3/delivery-infra ``` In each one: `pnpm install` and `pnpm api:venv` so the agent can run the checks immediately. - [ ] `mkdir docs/plan/reports` (tracked with a `.gitkeep`). diff --git a/docs/plan/TODO-agent-1-org-platform.md b/docs/plan/TODO-agent-1-org-platform.md index 2ed3967..eb73230 100644 --- a/docs/plan/TODO-agent-1-org-platform.md +++ b/docs/plan/TODO-agent-1-org-platform.md @@ -1,13 +1,13 @@ # TODO — Agent 1: Org Platform (invites, wizard, app profile, sharing, model grants, memory, MCP) -- **Worktree:** `/projects/smart-org-wt/agent1` · **Branch:** `agent1/org-platform` (already created, already installed) +- **Worktree:** `/home/mamad/work/smart-org-wt/agent1` · **Branch:** `agent1/org-platform` (already created, already installed) - **Read first:** `docs/plan/PLAN-v2.md` (all of it). Then skim: `apps/api/app/interfaces/http/routers/organizations.py`, `invites.py`, `admin_users.py`, `app/application/services/platform_users.py`, `app/domain/identity/models.py`, `apps/web/src/app/onboarding/page.tsx`, `apps/web/src/app/(app)/settings/`. - **Your files:** only the "Agent 1" column of PLAN-v2 §6, your block in `config.py` / `.env.example`, and the migration `alembic/versions/a1b2c3d4e5f1_org_platform.py` (`down_revision = "a0f0e1d2c3b4"`). - **Never touch:** the locked files and the other agents' files (PLAN-v2 §6). Need something there? → `CHANGE REQUEST` in your report. -- **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`. +- **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`. The worktree is on ext4 (the main repo under /projects is exFAT: no symlinks), so `.venv` and `node_modules` work here; do not copy files to /projects. - **After each phase:** run all checks (PLAN-v2 §7.4), write `docs/plan/reports/agent-1-phase-.md`, then STOP and wait. You consume these contracts (do not change them): `DeployTargetTester`, `RepoWorkspacePort.test_remote`, diff --git a/docs/plan/TODO-agent-2-ai-workforce.md b/docs/plan/TODO-agent-2-ai-workforce.md index f046643..88eab64 100644 --- a/docs/plan/TODO-agent-2-ai-workforce.md +++ b/docs/plan/TODO-agent-2-ai-workforce.md @@ -1,6 +1,6 @@ # TODO — Agent 2: AI Workforce Engine (manager AI, employees, dynamic pipelines, test/commit/push) -- **Worktree:** `/projects/smart-org-wt/agent2` · **Branch:** `agent2/ai-workforce` (already created, already installed) +- **Worktree:** `/home/mamad/work/smart-org-wt/agent2` · **Branch:** `agent2/ai-workforce` (already created, already installed) - **Read first:** `docs/plan/PLAN-v2.md` (all of it, especially §3 and §5). Then read fully: `apps/api/app/application/services/workforce_service.py`, `app/domain/workforce/models.py` + `tools.py`, `app/interfaces/http/routers/workforce.py`, `app/infrastructure/repositories/workforce.py`, @@ -15,7 +15,7 @@ New tables go in `app/infrastructure/db/models_engine.py`. **`db/models.py` is locked**, so new task and employee fields go into new 1:1 side tables (see 1.3), never into `tasks`/`employees` columns. - **Never touch:** the locked files and the other agents' files. Need something there? → `CHANGE REQUEST` in your report. -- **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`. +- **Do NOT commit, push, merge or rebase.** Never `cd` outside the worktree, never write to `/tmp`. The worktree is on ext4 (the main repo under /projects is exFAT: no symlinks), so `.venv` and `node_modules` work here; do not copy files to /projects. - **After each phase:** run all checks (PLAN-v2 §7.4), write `docs/plan/reports/agent-2-phase-.md`, then STOP and wait. You consume: `ModelProviderPort` (existing), `ModelGrantPort`, `MemoryPort`, `RepoWorkspacePort`, diff --git a/docs/plan/TODO-agent-3-delivery-infra.md b/docs/plan/TODO-agent-3-delivery-infra.md index adfc4f2..2d5dae9 100644 --- a/docs/plan/TODO-agent-3-delivery-infra.md +++ b/docs/plan/TODO-agent-3-delivery-infra.md @@ -1,6 +1,6 @@ # TODO — Agent 3: Delivery & Infra (docker sandbox, git workspaces, live CI/CD, demo server, Caddy, SSH/FTP) -- **Worktree:** `/projects/smart-org-wt/agent3` · **Branch:** `agent3/delivery-infra` (already created, already installed) +- **Worktree:** `/home/mamad/work/smart-org-wt/agent3` · **Branch:** `agent3/delivery-infra` (already created, already installed) - **Read first:** `docs/plan/PLAN-v2.md` (all of it, especially D5–D9). Then read fully: `apps/api/app/application/ports/sandbox_provider.py`, `git_provider.py`, `domain_router.py`, `realtime.py`, `app/infrastructure/adapters/registry.py`, `mock.py`, `caddy.py`, `gitea.py`, `sandbox_remote.py`, diff --git a/docs/plan/reports/.gitkeep b/docs/plan/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index 1fed07d..9609250 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "worker:billing": "pnpm --filter @agy/billing-worker once", "worker:ai-runtime": "pnpm --filter @agy/ai-runtime once", "worker:sandbox-gateway": "pnpm --filter @agy/sandbox-gateway once", + "worker:delivery": "pnpm --filter @agy/delivery-worker once", + "mcp:dev": "pnpm --filter @agy/mcp-gateway dev", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules services/*/node_modules" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec05644..b3e5b21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,10 @@ importers: services/billing-worker: {} + services/delivery-worker: {} + + services/mcp-gateway: {} + services/sandbox-gateway: {} packages: diff --git a/services/delivery-worker/README.md b/services/delivery-worker/README.md new file mode 100644 index 0000000..f687b89 --- /dev/null +++ b/services/delivery-worker/README.md @@ -0,0 +1,8 @@ +# delivery-worker + +Runs queued CI/CD pipeline runs (checkout → setup → build → test → package → demo deploy → +healthcheck → route) and polls git remotes for pushes made outside the engine. +Shell around `app/workers/delivery.py`. Owned by the delivery workstream; see `docs/plan/`. + + pnpm worker:delivery # one pass + pnpm --filter @agy/delivery-worker dev # loop diff --git a/services/delivery-worker/package.json b/services/delivery-worker/package.json new file mode 100644 index 0000000..ca95843 --- /dev/null +++ b/services/delivery-worker/package.json @@ -0,0 +1,11 @@ +{ + "name": "@agy/delivery-worker", + "version": "0.1.0", + "private": true, + "description": "Delivery worker — runs queued CI/CD pipelines and polls remotes for pushes.", + "scripts": { + "once": "cd ../../apps/api && .venv/bin/python -m app.workers delivery", + "dev": "cd ../../apps/api && .venv/bin/python -m app.workers delivery --loop --interval 10", + "lint": "cd ../../apps/api && .venv/bin/python -m ruff check app/workers" + } +} diff --git a/services/mcp-gateway/README.md b/services/mcp-gateway/README.md new file mode 100644 index 0000000..800d300 --- /dev/null +++ b/services/mcp-gateway/README.md @@ -0,0 +1,6 @@ +# mcp-gateway + +HTTP (streamable) MCP server. Shell around `apps/api/app/interfaces/mcp/server.py`. +Owned by the org-platform workstream; see `docs/plan/TODO-agent-1-org-platform.md` Phase 5. + + pnpm mcp:dev diff --git a/services/mcp-gateway/package.json b/services/mcp-gateway/package.json new file mode 100644 index 0000000..d8b891d --- /dev/null +++ b/services/mcp-gateway/package.json @@ -0,0 +1,10 @@ +{ + "name": "@agy/mcp-gateway", + "version": "0.1.0", + "private": true, + "description": "MCP gateway — lets AI agents control an organization through scoped tokens.", + "scripts": { + "dev": "cd ../../apps/api && .venv/bin/python -m app.interfaces.mcp.server", + "lint": "cd ../../apps/api && .venv/bin/python -m ruff check app/interfaces/mcp" + } +}