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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3a68b0fa07
commit
0cd9194b64
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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"])
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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()]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -0,0 +1 @@
|
||||
"""MCP gateway (owned by Agent 1)."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user