docs: v2 plan (shared plan + foundation + 3 agent TODO files)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-13 22:57:06 +03:30
co-authored by Claude Sonnet 5.5
parent 8eb631aedf
commit 3a68b0fa07
5 changed files with 787 additions and 0 deletions
+266
View File
@@ -0,0 +1,266 @@
# Smart Org v2 — Plan (shared by all agents)
Date: 2026-10-13 · Base branch: `dev` · Read this file fully before your own TODO file.
This file is the **single source of truth** for the v2 work. Three agents work in parallel, each from
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-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 |
---
## 1. Product vision (what the owner asked for)
1. **One platform owner** controls the whole application and its infrastructure: AI accounts, git,
application rules, adding users, domains, sandboxes, models. Nobody else touches infra.
2. An **invited user** lands on an **organization creation wizard**. The wizard connects the org to **our
git (Gitea)** or **the user's own git** and asks for the application type, language, framework, and so on.
**One organization = exactly one application.** A user can create several orgs and can **share** an
org with other people so they help manage it.
3. The wizard also asks (optionally) for **SSH or FTP** details to deploy the app to **production**.
The platform can also run the app inside a **sandbox as a live demo**.
4. The user gives orders to the **admin AI (manager)**. The manager brings in **AI employees**, built
only from the **models the owner grants to that org**. They do the task, **test it and push it**.
5. A **live CI/CD** mechanism shows the deployment on our local **demo sandbox server**, live.
6. **Caddy** routes different domains to the applications.
7. This is the **web version of `/projects/agybot`** (Telegram bot). Reuse agybot's proven ideas, not its code.
8. Every org **improves itself** through **memories**: how its users like to manage the org, and how the
app's logic and code work. It also exposes **MCP** so AI agents can control our app and read or push data.
9. **Main difference from agybot:** users never touch infrastructure or AI accounts. They only give
orders and tasks. Tasks run through **dynamic pipelines**.
## 2. What already exists (audited 2026-10-13, the code is the truth, not old TODO.md ticks)
- **Transport:** the API is WebSocket-RPC only. `app/main.py` exposes only `/health*` and `/api/ws`. Every
handler lives in an internal FastAPI app (`app/interfaces/realtime/rpc_app.py`) that is reached
in-process. Realtime push uses channels `org:<id>`, `task:<id>`, `run:<id>`, `thread:<id>`,
`session:<id>`, `user:<id>`, `admin` (`app/application/ports/realtime.py`, authz in
`app/interfaces/realtime/authz.py`).
- **Roles:** platform roles `owner` (exactly one, enforced by a DB partial unique index), `manager` and
`user`. Org roles are `owner > admin > manager > member > viewer`. There is no public signup. Accounts
come from the owner (`/admin/users`) or from an org invite link.
- **Admin console:** agy (Google OAuth) accounts with pool rotation and 429 cooldown, plus model
catalog, routing policies, rate cards, usage and error console.
- **Projects:** `projects` holds only name/slug/description/default_branch. There is no app profile and
no "1 org = 1 app" rule. Gitea adapter (real) and mock. Preview domains through the Caddy adapter
(real) and mock.
- **Sandbox:** full CRUD API, but only Daytona/E2B adapters (untested live) and a mock that runs nothing.
- **Workforce:** templates, employees, threads, tasks, runs. **The run engine makes ONE LLM call per
task and stores the text.** Tool calls are recorded, never executed. No employee edits code, runs
tests, commits or pushes.
- **Missing entirely:** org wizard, app profile, external git, SSH/FTP deploy, CI/CD, demo server, docker
sandbox, memory system, MCP, per-org model grants.
- **Tests:** about 233 API tests (pytest, DB via `Base.metadata.create_all` in `tests/conftest.py`) and 64
web tests (vitest). Five-step alembic chain ending at `d4e5f6a7b8c9`.
## 3. What we take from agybot (concepts only, never import it)
| agybot | v2 equivalent | Owner |
|---|---|---|
| Roles `manager, planner, programmer, reviewer, tester` (+ optional critic) | same role keys | Agent 2 |
| `[[TAG attr="..."]]body[[/TAG]]` protocol, per-role allowed tags | same, in `app/application/engine/protocol.py` | Agent 2 |
| Pipelines `plan_code_test` (default), `plan_code_review_test`, `code_test`, `analysis_only` | same names | Agent 2 |
| Pipeline choice: manager's `pipeline=` > task-kind default > app default; review pipeline needs an active reviewer | same | Agent 2 |
| Task statuses `pending_manager_review → pending_analysis → pending_implementation → pending_review → pending_test → pending_final_review → merge_request → completed` (+ `pending_user_decision`, `error_review`, `paused`, `cancelled`, `failed`) | same, with optimistic `expected_from` | Agent 2 |
| Gates: real test run after WORK_REPORT, green gate (waiving needs a written reason), diff guard, no-progress stop (2 identical diffs), max cycles, transient-error retries, escalate model on red | same | Agent 2 |
| Worktree per task `task/<id>`, merge into `dev`, push | same, through `RepoWorkspacePort` | Agent 3 (port), Agent 2 (use) |
| Manager `HIRE/UPDATE_EMPLOYEE/FIRE` (max 3 changes per turn, max 20 employees), only on org-shared models | same, only on **owner-granted** models | Agent 2 |
| Org knowledge `[[KNOWLEDGE]]` (max 3 per turn, caps 1,500 chars/key and 6,000/org), lessons with 30-day TTL, project summaries per task | memory system | Agent 1 |
| `app_control_mcp` / `remote_dev_mcp` | MCP gateway with scoped tokens | Agent 1 |
| `caddy_manager` (single-label hosts under root, localhost targets, validate→revert→reload) | existing `CaddyDomainRouterAdapter` | Agent 3 |
| `ssh_manager.push_project`, `ftp_manager.deploy_project` | deploy adapters | Agent 3 |
| `org_live_stream` (ring buffer, polled) | realtime push events (no polling) | Agents 2 & 3 |
**agybot bugs we must NOT repeat:** deploy publishing the wrong code silently; `git_publish` stuck
mid-merge; observe mode applying real state changes; a failed/cancelled dependency deadlocking its
dependents; manager chat cost not counted in the budget; git operations without the repo lock; about
870 silent `except Exception` blocks; plaintext credentials; the AI running directly on the host;
`git status --porcelain` hiding new dirs (always use `-uall`).
## 4. Architecture decisions (fixed, do not re-decide)
- **D1. 1 org = 1 app.** The app is the existing `projects` row (the table name stays). `projects` gets
`UNIQUE(organization_id)`. The UI calls it "Application" (اپلیکیشن). Route `/projects` becomes `/app`.
- **D2. App profile.** These are columns on `projects`: app_type, language, framework, runtime_version,
package_manager, setup/build/test/start commands, app_port, healthcheck_path, git_source
(`platform|external`), external_repo_url, wizard_completed_at. External git credentials and deploy
targets live in their own tables, **encrypted at rest** with the existing crypto helper. They are never
returned raw to the frontend (only `has_secret: true` and masked values).
- **D3. Owner model grants.** `org_model_grants(org_id, ai_model_id, daily_credit_cap, is_active)`. The
engine may only hire or run on granted models. Users never see model names; the UI shows the
employee's role and a neutral "tier" label.
- **D4. Execution engine = in-process agent loop.** It uses the existing `ModelProviderPort` (owner's agy
accounts, pool rotation) with the agybot tag protocol for actions **and** tools. Tools run **inside the
org's sandbox container** through `SandboxProviderPort`. There are no agent CLIs in v1, so AI
credentials never leave the server and users never see them.
- **D5. Sandbox = local Docker** (`SANDBOX_PROVIDER=docker`, the binary is configurable so podman works
too). Per app there is one **workspace container** (employees work in it) and one **demo container**
(runs the latest green build). Hardened: non-root, `--cap-drop ALL`, `no-new-privileges`, cpu/mem/pids
limits, ports published on `127.0.0.1` only.
- **D6. Git is done by the platform, never by the AI.** `RepoWorkspacePort` runs git **on the host** with
a gitdir that is **never mounted** into the container. Git is always called with explicit
`--git-dir/--work-tree`, hooks disabled (`core.hooksPath=/dev/null`), `-uall`, `core.quotePath=false`,
and a per-repo lock. Remote credentials go through `GIT_ASKPASS` or a temporary key file and **never
appear in a URL, log or exception**.
- **D7. CI/CD is platform-native.** A push to `dev` (by the engine or seen by polling the remote) creates
a `pipeline_run`. Steps: checkout → setup → build → test → package → deploy demo → healthcheck → route.
Logs stream live as realtime events on `org:<id>` and a bounded tail is stored. A failed run keeps the
previous demo running.
- **D8. Production deploy is always human-triggered** (org owner/admin, with confirmation and audit).
SSH: release dir + `current` symlink + post-deploy command + rollback. FTP/FTPS: upload a release, keep
the previous one for rollback. User-supplied hosts are SSRF-checked (no private, loopback or link-local
IPs unless the owner allowlists them). SSH host key is pinned on first successful test (TOFU).
- **D9. Domains.** Each app gets an automatic demo host `<org-slug>.<PREVIEW_ROOT_DOMAIN>`. Only the
**owner** can attach extra hosts. Targets are always `127.0.0.1:<allocated port>`. The existing rules in
`app/domain/platform/rules.py` stay authoritative.
- **D10. Memory** (`org_memories`) is the org's long-term self-knowledge: rules, preferences, lessons,
facts and the code map, with caps, dedupe, TTL for lessons and pinning. Users can view, edit, pin and
delete it. The engine reads it through `MemoryPort.build_context` and feeds it through
`MemoryPort.on_task_finished`.
- **D11. MCP gateway** is a separate HTTP entry point (`services/mcp-gateway`) using the official `mcp`
Python SDK (streamable HTTP). Bearer tokens are hashed, scoped, org-bound and expire. Every call is
audited and org-enforced.
- **D12. Platform invites.** The owner can create an invite link for a **new platform account**, which
becomes role `manager` and may create orgs. A logged-in manager with no org is sent to the wizard. Org
invites (join an existing org) stay as they are.
- **D13. Automation safety.** Tasks run automatically up to merge into `dev`, followed by a demo deploy.
Nothing goes to production without a human click. No code path may flip that default.
## 5. Contracts between agents (created by Claude in TODO-0, then FROZEN)
All contracts live in `apps/api/app/application/ports/` and are `typing.Protocol`s with a
working default implementation, so each agent can test alone. **Agents must not change a contract
file.** If you need a change, write a `CHANGE REQUEST` in your phase report, keep coding against the
current signature, and add a local adapter or shim in your own files.
```python
# ports/repo_workspace.py — implemented by Agent 3, used by Agent 2 (and Agent 1 for test_remote)
class RepoWorkspacePort(Protocol):
async def ensure_workspace(self, *, project_id: str) -> WorkspaceRef: ... # clone/fetch app repo on host
async def create_task_worktree(self, *, project_id: str, task_id: str, base: str = "dev") -> WorktreeRef: ...
async def diff(self, *, worktree: WorktreeRef) -> DiffResult: ... # text + changed files (-uall)
async def commit(self, *, worktree: WorktreeRef, message: str, author: str) -> str | None: ... # sha or None if clean
async def sync_with_base(self, *, worktree: WorktreeRef) -> MergeOutcome: ... # merge base into task branch
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: ...
# WorktreeRef has: project_id, task_id, branch, host_path, sandbox_path (path inside the workspace container)
# ports/delivery_hooks.py — implemented by Agent 3, called by Agent 1 (app created) and Agent 2 (pushed)
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) -> None: ...
# ports/deploy_target.py — implemented by Agent 3, called by Agent 1's wizard
class DeployTargetTester(Protocol):
async def test_connection(self, *, target: DeployTargetSpec) -> DeployCheck: ... # ok, message, fingerprint
# ports/memory.py — implemented by Agent 1, used by Agent 2
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: ...
# ports/model_grants.py — implemented by Agent 1, used by Agent 2
class ModelGrantPort(Protocol):
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]: ... # id, capabilities, tier, caps
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool: ...
# ports/workforce_bootstrap.py — implemented by Agent 2, called by Agent 1's wizard
class WorkforceBootstrapPort(Protocol):
async def bootstrap_team(self, *, organization_id: str, project_id: str) -> None: ... # creates the manager + default team
```
Existing contracts that stay as they are: `SandboxProviderPort` (Agent 3 adds the docker adapter),
`GitProviderPort`, `DomainRouterPort`, `ModelProviderPort`, `EventPublisher`/`RealtimeEvent`.
**Realtime event names** (use the existing `RealtimeEvent` + `get_publisher()`):
| Channel | Event types | Producer |
|---|---|---|
| `task:<id>` | `task.status`, `task.step`, `task.gate` | Agent 2 |
| `run:<id>` | `run.log`, `run.tool`, `run.status` | Agent 2 |
| `org:<id>` | `task.created`, `team.changed` | Agent 2 |
| `org:<id>` | `pipeline.queued`, `pipeline.step`, `pipeline.log`, `pipeline.status`, `release.live`, `deploy.status` | Agent 3 |
| `org:<id>` | `memory.changed`, `org.updated` | Agent 1 |
Log events are **batched** (at most one event per 500 ms per stream, max 200 lines per event).
## 6. File ownership (no two agents edit the same file)
| Area | Agent 1 | Agent 2 | Agent 3 |
|---|---|---|---|
| API routers | `app_setup.py`*, `memory.py`*, `mcp_tokens.py`*, `admin_grants.py`*, `organizations.py`, `invites.py`, `admin_users.py`, `auth.py`, `knowledge.py` | `workforce.py` | `projects.py`, `sandbox.py`, `delivery.py`*, `admin_domains.py`* |
| Services | `org_setup_service.py`*, `memory_service.py`*, `model_grant_service.py`*, `mcp_service.py`*, `platform_users.py`, `knowledge_service.py` | `workforce_service.py`, `app/application/engine/**`* | `project_service.py`, `sandbox_service.py`, `delivery_service.py`*, `deploy_service.py`* |
| Adapters | — | — | everything in `app/infrastructure/adapters/` (incl. `registry.py`, `mock.py`) |
| Repositories | `identity.py`, `knowledge.py`, `memory.py`* | `workforce.py` | `projects.py`, `sandbox.py`, `delivery.py`* |
| Domain | `identity/`, `knowledge/`, `memory/`*, `projects/catalog.py`* | `workforce/` | `platform/`, `sandbox/`, `projects/models.py` |
| DB tables | `db/models_org.py`* | `db/models_engine.py`* | `db/models_delivery.py`* |
| Wiring | `container_org.py`* | `container_engine.py`* | `container_delivery.py`* |
| Workers | — | `workers/ai_runtime.py` | `workers/sandbox_gateway.py`, `workers/delivery.py`* |
| Other API | `app/interfaces/mcp/**`*, `services/mcp-gateway/**` | — | — |
| Web pages `apps/web/src/app/` | `onboarding/`, `accept-invite/`, `(app)/settings/`, `(app)/memory/`*, `(app)/knowledge/`, `(app)/admin/users/`, `(app)/admin/model-grants/`*, `(app)/admin/mcp/`*, `(app)/admin/organizations/` | `(app)/manager/`, `(app)/tasks/`, `(app)/workforce/`, `(app)/traces/`, `(app)/dashboard/` | `(app)/app/`*, `(app)/projects/` (delete after moving), `(app)/admin/domains/`*, `(app)/admin/sandbox/`*, `components/sandbox-panel.tsx` |
| Web API client | `lib/api-org.ts`* | `lib/api-engine.ts`* | `lib/api-delivery.ts`* |
| Tests | `tests/test_org_*.py`, `test_memory*.py`, `test_mcp*.py`, `test_model_grants*.py`, `test_invites.py`, `test_platform_hierarchy.py`, web `src/test/org-*.test.tsx` | `tests/test_engine_*.py`, `test_workforce*.py`, `test_workers.py`, web `src/test/engine-*.test.tsx` | `tests/test_docker_*.py`, `test_git_workspace*.py`, `test_delivery*.py`, `test_deploy*.py`, `test_project_repo.py`, `test_domain_isolation.py`, `test_platform_rules.py`, web `src/test/delivery-*.test.tsx` |
| Alembic (one file per agent, more only if chained off your own) | `a1b2c3d4e5f1_org_platform.py` | `a2b3c4d5e6f2_ai_workforce.py` | `a3b4c5d6e7f3_delivery_infra.py` |
`*` = new file (Claude creates an empty stub in TODO-0, the agent fills it).
**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
`README.md`/`PLAN.md`/`TODO.md`, `docs/plan/PLAN-v2.md`, every other agent's TODO file.
**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
revision when the branches are merged.
## 7. Working rules for every agent
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.
3. At the **end of each phase**, stop and write `docs/plan/reports/agent-<N>-phase-<K>.md` containing:
what you did, files changed, test output summary, deviations, open questions, and `CHANGE REQUEST`s.
Then wait.
4. Before declaring a phase done, run **all** checks (not only your tests) and paste the summary lines:
```bash
cd apps/api && .venv/bin/python -m pytest -q && .venv/bin/ruff check . && .venv/bin/mypy app
cd apps/web && pnpm test && pnpm exec tsc --noEmit && pnpm lint
```
Every existing test must stay green. Never delete or weaken an existing test to make it pass. If an
existing test encodes behaviour that this plan changes on purpose, update it and explain why in the report.
5. Every multi-tenant query is filtered by `organization_id`. Every destructive or production action has
a confirmation and an audit event. No secret ever goes to the frontend, a log, an exception message, a
realtime event or an LLM prompt.
6. No silent `except Exception: pass`. Catch narrow errors, or record them through
`app.infrastructure.error_log`.
7. Users (non-owner) never see model names, providers, accounts, containers, ports or raw infra
errors. Translate them to plain Persian messages.
8. UI language is Persian and RTL. Reuse `packages/ui` components and the existing motion classes.
9. Match the existing code style: hexagonal layers (domain has no FastAPI/SQLAlchemy imports), type
hints everywhere, docstrings in the same tone as nearby code.
10. Do not run `agy`, do not call real external services in tests (use fakes). Integration tests that need
docker, network or real git remotes must be skipped unless an env flag is set.
## 8. Integration (Claude, after the agents)
1. Review each phase report and diff, run the full checks, and commit on the agent's branch.
2. Merge the branches into `dev` one by one: Agent 3 → Agent 1 → Agent 2. Run the full tests after **each**
merge.
3. Add the alembic merge revision for the three heads and run `alembic upgrade head` + `alembic check`.
4. Replace the Null/default contract implementations in `container.py` with the real ones and run the
end-to-end smoke test:
wizard → app created → demo domain → manager order → task runs → tests → push → pipeline → demo live
→ production deploy (to a local SSH/FTP test server).
5. Update `README.md`, `TODO.md` and the architecture doc, then commit.
+60
View File
@@ -0,0 +1,60 @@
# TODO-0 — Foundation (Claude does this BEFORE the three agents start)
Goal: put every shared or locked file in its final shape, so the three agents never edit the same
file. When this is done, `dev` has a foundation commit and three worktrees exist.
Read `docs/plan/PLAN-v2.md` §4–§6 first. Everything here is mechanical; no product logic.
## F1. Environment
- [ ] `pnpm install` at root; `pnpm api:venv`; confirm the baseline: `pytest -q`, `ruff`, `mypy`, `pnpm test`, `tsc`. Record the counts.
- [ ] Add API deps to `apps/api/pyproject.toml`: `asyncssh`, `aioftp`, `mcp` (official SDK). Install and re-run the baseline.
## F2. DB foundation (`db/models.py` + migration `a0f0e1d2c3b4_v2_foundation.py`, down_revision `d4e5f6a7b8c9`)
- [ ] `projects`: add app-profile columns (all nullable or defaulted): `app_type`, `language`, `framework`,
`runtime_version`, `package_manager`, `setup_command`, `build_command`, `test_command`, `start_command`,
`app_port` (int), `healthcheck_path` (default `/`), `git_source` (`platform|external`, default `platform`),
`external_repo_url`, `wizard_completed_at`.
- [ ] `projects`: `UniqueConstraint("organization_id", name="uq_project_one_per_org")`. Fix the seed and any test that creates 2 projects in one org.
- [ ] New table `project_external_git` (project_id PK/FK, auth_type `token|ssh_key|none`, username, secret_encrypted, known_hosts, updated_at).
- [ ] New table `deploy_targets` (id, organization_id, project_id, kind `ssh|sftp|ftp|ftps`, host, port, username,
auth_type `password|key`, secret_encrypted, remote_path, post_deploy_command, host_fingerprint, is_active,
last_tested_at, last_test_ok, last_test_message, created_at). At most one active target per project.
- [ ] New table `org_model_grants` (id, organization_id, ai_model_id, daily_credit_cap nullable, is_active, created_by, created_at; unique org+model).
- [ ] At the end of `models.py`, import `models_org`, `models_engine`, `models_delivery` (three empty modules with a docstring naming the owner agent).
- [ ] `alembic upgrade head`, `alembic downgrade -1`, `alembic upgrade head`, `alembic check`, all clean.
## F3. Contracts (`app/application/ports/`), exactly as in PLAN-v2 §5
- [ ] `repo_workspace.py` (+ dataclasses `WorkspaceRef`, `WorktreeRef`, `DiffResult`, `MergeOutcome`, `PushOutcome`, `RemoteCredentials`, `RemoteCheck`) and an in-memory `FakeRepoWorkspace` for tests.
- [ ] `delivery_hooks.py` + `NullDeliveryHooks` (records calls in a list for tests).
- [ ] `deploy_target.py` (`DeployTargetSpec`, `DeployCheck`) + `FakeDeployTargetTester` (ok unless host contains "fail").
- [ ] `memory.py` (`TaskOutcome`) + `NullMemory` (empty context, record returns None).
- [ ] `model_grants.py` (`GrantedModel`) + `AllActiveModelsGrants` (today's behaviour: every active model).
- [ ] `workforce_bootstrap.py` + `NullWorkforceBootstrap`.
- [ ] `app/domain/projects/profile.py`: frozen dataclass `AppProfile` + `AppProfile.from_row(...)`.
## F4. Wiring stubs
- [ ] `app/application/container_org.py`, `container_engine.py`, `container_delivery.py`: each holds the builder functions for its port(s), returning the Null/Fake implementations for now. `container.py` re-exports them.
- [ ] Empty routers (`router = APIRouter()`) registered in `rpc_app.py`: `app_setup`, `memory`, `mcp_tokens`, `admin_grants`, `delivery`, `admin_domains`.
- [ ] `app/workers/delivery.py` stub (one no-op pass) registered in `workers/__main__.py` as `delivery`; `services/delivery-worker/README.md` + `pnpm worker:delivery` script.
- [ ] `services/mcp-gateway/README.md` placeholder + `pnpm mcp:dev` script (points at `app.interfaces.mcp.server:main`, stub module that raises a clear "not implemented yet").
- [ ] `config.py` and `.env.example`: three marked blocks `# >>> agent-1 … # <<< agent-1` (likewise 2 and 3). Pre-fill the obvious settings:
- agent-3: `SANDBOX_DOCKER_BIN=docker`, `SANDBOX_WORKSPACES_ROOT`, `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`.
- agent-2: `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-1: `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`.
## F5. Web stubs
- [ ] `lib/api-org.ts`, `lib/api-engine.ts`, `lib/api-delivery.ts`: each imports `apiRequest` from `./api` and exports an empty object `orgApi` / `engineApi` / `deliveryApi`.
- [ ] `nav.ts`: rename the projects entry to `{ href: "/app", label: "اپلیکیشن", hint: "دمو، استقرار، Git" }`, rename knowledge to `{ href: "/memory", label: "حافظهٔ سازمان", ... }`; admin entries `/admin/model-grants` (مدل‌های سازمان‌ها), `/admin/domains` (دامنه‌ها), `/admin/sandbox` (سندباکس‌ها), `/admin/mcp` (MCP).
- [ ] Placeholder pages (using `PlaceholderPage`) for `(app)/app`, `(app)/memory`, `(app)/admin/model-grants`, `(app)/admin/domains`, `(app)/admin/sandbox`, `(app)/admin/mcp`. `(app)/projects` keeps working until Agent 3 removes it.
## F6. Finish
- [ ] Baseline checks are all green with the same counts as F1 (plus any new tests).
- [ ] 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
```
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`).
+129
View File
@@ -0,0 +1,129 @@
# 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)
- **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`.
- **After each phase:** run all checks (PLAN-v2 §7.4), write `docs/plan/reports/agent-1-phase-<K>.md`, then STOP and wait.
You consume these contracts (do not change them): `DeployTargetTester`, `RepoWorkspacePort.test_remote`,
`DeliveryHooks.on_app_created`, `WorkforceBootstrapPort.bootstrap_team`. Get them only from the builders in
`app/application/container.py`. In tests use the Fake/Null versions from the ports package.
You implement these contracts: `MemoryPort`, `ModelGrantPort`. Your real classes are wired in
`container_org.py`.
---
## Phase 1 — Platform invites, roles and org sharing
Backend
- [ ] 1.1 New table `platform_invites` (in `db/models_org.py`): id, token_hash, email (optional), created_by (owner), role (`manager` only for now), expires_at (`PLATFORM_INVITE_TTL_HOURS`), used_at, used_by_user_id, revoked_at. Migration in your file.
- [ ] 1.2 Owner-only endpoints in `admin_users.py`: `POST /admin/platform-invites` (returns `invite_url` once, token shown once), `GET /admin/platform-invites`, `POST /admin/platform-invites/{id}/revoke`. Audit every action.
- [ ] 1.3 In `invites.py`, extend `GET /invites/{token}` and `POST /invites/accept` to recognise a platform invite: if no account exists, create a `manager` account with the chosen password; if the email exists, require login (same rule as org invites: an invite never resets a password). Single use, constant-time compare, expired or revoked → the same generic 404.
- [ ] 1.4 `GET /auth/me` (you own `auth.py`) gains `needs_onboarding: bool` = platform role is `manager`, the user has zero org memberships, and they are not the owner.
- [ ] 1.5 Org sharing in `organizations.py`: `POST /organizations/{id}/transfer-ownership` (owner → an existing admin member, in one transaction, audited), `POST /organizations/{id}/leave` (the last owner cannot leave), and `GET /organizations/{id}/invites` + `DELETE .../invites/{invite_id}` (list and revoke pending org invites). Keep the role ladder `owner > admin > manager > member > viewer`.
- [ ] 1.6 Tests: `tests/test_org_platform_invites.py`, `tests/test_org_sharing.py`. Cover: non-owner cannot create platform invites; token single use; expired/revoked; existing-email path; transfer + leave edge cases; tenant isolation (a member of org A cannot list org B invites).
Frontend
- [ ] 1.7 `/admin/users`: a "دعوت مدیر جدید" button → modal → shows the link once with a copy button; a list of pending invites with revoke.
- [ ] 1.8 `/accept-invite`: handle the platform-invite variant (title "ساخت حساب مدیر").
- [ ] 1.9 After login: if `needs_onboarding`, redirect to `/onboarding`. Do this in the `onboarding` page and in a small hook in `lib/api-org.ts`. `AppShell` is locked, so if a redirect there is unavoidable, write a CHANGE REQUEST.
- [ ] 1.10 `(app)/settings`: a "اشتراک‌گذاری سازمان" section: members with role selector, invite form (email + role), pending invites with revoke, transfer ownership (confirmation modal that requires typing the org slug), leave org.
- [ ] 1.11 Web tests `src/test/org-sharing.test.tsx`.
**Phase 1 done when:** the owner creates a link → a new person opens it, sets a password → logs in → lands on `/onboarding`. All checks are green.
---
## Phase 2 — Org creation wizard + app profile (1 org = 1 app)
Backend
- [ ] 2.1 `app/domain/projects/catalog.py`: pure-Python catalog. `APP_TYPES` (`web_app`, `api`, `static_site`, `bot`, `worker`, `other`) and `LANGUAGES` → frameworks → defaults (runtime_version, package_manager, setup/build/test/start commands, app_port, healthcheck_path, docker base image name). Cover at least:
- node: next, express, nestjs, vite-react
- python: fastapi, django, flask
- php: laravel, plain
- go: plain
- static: html
Add a function `defaults_for(app_type, language, framework) -> ProfileDefaults`. Unit tests for every entry.
- [ ] 2.2 New table `org_setup_drafts` (user_id, step, data_json **without secrets**, updated_at). Secrets typed during the wizard are held only in the final submit request, never in the draft.
- [ ] 2.3 `org_setup_service.py` + router `app_setup.py`:
- `GET /setup/catalog`.
- `GET/PUT /setup/draft`.
- `POST /setup/check-git`: external URL + credentials → `RepoWorkspacePort.test_remote`. Validate the URL **format** first in your own service: `https://` or `ssh://`/`git@` only, no credentials embedded in the URL, no IP-literal localhost/private hosts. The DNS-resolved SSRF check happens inside `test_remote` (Agent 3); do not import Agent 3's code.
- `POST /setup/check-deploy`: → `DeployTargetTester.test_connection`.
- `POST /setup/complete`: in ONE transaction, create the org (caller = org owner), the project with its app profile (`wizard_completed_at=now`), the encrypted external git credentials or the platform Gitea repo through the existing `GitProviderPort.ensure_project_repo`, the optional deploy target (encrypted), and the default model grants (Phase 3; until then, none). After commit, call `WorkforceBootstrapPort.bootstrap_team` and `DeliveryHooks.on_app_created`. Each hook is called once; a hook failure is recorded with `error_log` and does not undo the org.
- Slug is unique (reuse the existing slug rules). Audit `org.created_via_wizard`.
- [ ] 2.4 App profile editing for org owner/admin: `GET/PATCH /organizations/{org}/app/profile` (commands, port, healthcheck, framework), `PUT /organizations/{org}/app/deploy-target` (+ test), `PUT /organizations/{org}/app/git` (switch platform ↔ external is NOT allowed after creation; only credential rotation). Secrets are write-only: responses return `has_secret` and masked usernames only.
- [ ] 2.5 Enforce 1 org = 1 app: any other path that creates a second project returns a clear 409 (the DB constraint exists already; make the service error friendly). Note: `projects.py` is Agent 3's file. Do NOT edit it; only your service/router may create the app.
- [ ] 2.6 Tests `tests/test_org_wizard.py`: happy path platform git; happy path external git with a fake `test_remote`; a deploy target with a failing test does not block completion (it is saved as `last_test_ok=false`) but the user sees the warning; secrets never in any response, draft, audit metadata or log (assert by searching the captured output and DB rows for the plaintext secret); a second app → 409; a user with role `user` cannot complete the wizard; SSRF cases for the git URL.
Frontend
- [ ] 2.7 Rebuild `/onboarding` as a 5-step wizard (stepper, back/next, draft auto-save, refresh resumes):
1. Organization: name, slug (auto-suggest).
2. Application: name, type, language, framework (cards from the catalog), "advanced" accordion with prefilled commands, port and healthcheck.
3. Git: "Git پلتفرم (پیشنهادی)" or "Git خودم" (URL + token or SSH key + "بررسی اتصال" button).
4. Production (optional, skippable): SSH / SFTP / FTP / FTPS fields + "تست اتصال". Show a clear note that production deploys only happen when they press the button.
5. Review → create → success screen with the demo URL placeholder and a "برو به مدیر AI" button.
- [ ] 2.8 `(app)/settings` → "اپلیکیشن" tab: edit the profile, rotate git credentials, edit or test the deploy target.
- [ ] 2.9 Web tests `src/test/org-wizard.test.tsx` (step navigation, validation, secrets never placed in the draft payload).
**Phase 2 done when:** a fresh manager completes the wizard in the browser and lands on the dashboard of the new org. The DB has exactly one project with a full profile. The Null hooks recorded one `bootstrap_team` and one `on_app_created` call.
---
## Phase 3 — Owner model grants (employees only use what the owner provides)
- [ ] 3.1 `model_grant_service.py` implementing `ModelGrantPort` over `org_model_grants` + `ai_models` (only active models, only active grants). `GrantedModel.tier` is a neutral label derived from capabilities (`سریع`, `متعادل`, `قوی`). Never the model name.
- [ ] 3.2 Owner endpoints `admin_grants.py`: list grants per org, grant/revoke model, set daily credit cap, and "default grant set for new orgs" (new table `default_model_grants`, or a column in your own table; do NOT edit `ai_models`). Audit all.
- [ ] 3.3 Wizard `complete` copies the default grant set into the new org.
- [ ] 3.4 Owner org policy (`org_policies` table, yours): max_employees (≤ `ENGINE_MAX_EMPLOYEES`), monthly credit budget, automation_enabled (bool). Endpoints under `admin_grants.py`. Expose `get_org_policy(org_id)` in `model_grant_service.py` for Agent 2 to read later. Write a CHANGE REQUEST so Claude adds it to the `ModelGrantPort` contract at integration.
- [ ] 3.5 Wire your real `ModelGrantPort` in `container_org.py`.
- [ ] 3.6 UI `/admin/model-grants`: org list → grant matrix (models × checkbox + cap), default set editor, policy editor. Org users see nothing new.
- [ ] 3.7 Tests `tests/test_model_grants.py`: non-owner forbidden everywhere; revoked or inactive model not returned; a grant on an org you query for another org is never returned (isolation); defaults copied on wizard completion.
---
## Phase 4 — Org memory (self-improving org)
- [ ] 4.1 Domain `app/domain/memory/`: `MemoryKind` (`rule`, `preference`, `lesson`, `fact`, `code_map`), `MemorySource` (`user`, `manager`, `system`, `reflection`), validation (key ≤ 80 chars, content ≤ 1,500 chars), normalisation + content hash for dedupe.
- [ ] 4.2 Table `org_memories` (in `models_org.py`): id, organization_id, kind, key (nullable), content, source, evidence_task_id, pinned, confidence (0..1), expires_at (lessons: +30 days unless pinned or confirmed), hits, last_used_at, created_by_user_id, created_at, updated_at, deleted_at (soft). Index `(organization_id, kind)`.
- [ ] 4.3 `memory_service.py` implementing `MemoryPort`:
- `record`: dedupe by hash or same key (update instead of insert), org cap `MEMORY_ORG_CHAR_CAP` (evict oldest unpinned lessons first), max 3 writes per task.
- `build_context(role, query, budget)`: pinned rules and preferences first, then the top memories by keyword overlap with `query` and recency. Role filter: `code_map` and `fact` only for planner/programmer/reviewer/tester; preferences for the manager. Returns a compact Persian/English block within `budget_chars`; updates `hits/last_used_at`.
- `on_task_finished(TaskOutcome)`: one cheap model call (through the existing ai_control routing in "cheap" mode and `ModelProviderPort`, no new adapter) asking for ≤ 2 lessons + a 1-paragraph code-map delta. It stores them with `source=reflection`, `confidence=0.5`. If the call fails, it records the error and returns (it never raises into the engine). Record usage for that call in the usage ledger like other model calls.
- Expire job: function `expire_memories()` (called from the existing billing worker? NO, that is not your file → expose the function and write a CHANGE REQUEST for a worker hook).
- [ ] 4.4 Router `memory.py`: list (filter by kind, search), create (users can "teach" a rule or preference), edit, pin/unpin, delete (soft, audited), and a `confirm` action (lesson → confidence 1, no expiry). Roles: viewer can read; member+ can add preferences; admin+ can edit or delete anything.
- [ ] 4.5 Fold the existing knowledge sources into the same page: keep `knowledge.py` endpoints working and show sources as a second tab.
- [ ] 4.6 UI `/memory` (replace the placeholder; also redirect `/knowledge` → `/memory`): tabs "قوانین و ترجیحات", "درس‌های آموخته", "نقشهٔ کد", "منابع دانش"; inline edit; pin; confirm a lesson; "به سازمان یاد بده" input. Publish `memory.changed` on `org:<id>` and refresh live.
- [ ] 4.7 Wire the real `MemoryPort` in `container_org.py`.
- [ ] 4.8 Tests `tests/test_memory.py`: dedupe, caps and eviction, TTL, role filtering, budget respected, isolation, reflection failure does not raise, no secrets: run content through `app/core/redact.py` before storing and test it.
---
## Phase 5 — MCP gateway (AI agents control our app)
- [ ] 5.1 Table `mcp_tokens` (id, organization_id, name, token_hash, prefix (first 6 chars for display), scopes CSV, created_by, expires_at, last_used_at, revoked_at). Tokens look like `smo_<random>` and are shown once.
- [ ] 5.2 `mcp_service.py`: issue, list, revoke, verify (constant-time; expired or revoked → reject), scope check.
- [ ] 5.3 `app/interfaces/mcp/server.py` with the official `mcp` SDK (streamable HTTP, bound to `MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`) and `main()` for `pnpm mcp:dev`. Tools (each resolves the org from the token, never from arguments):
- `app_get_profile` (`app:read`)
- `tasks_list` / `tasks_get` (`tasks:read`)
- `tasks_create` (`tasks:write`): goes to the manager as a user order through the **current** public `WorkforceService.create_task` signature; read it, do not edit it (Agent 2 owns it; Claude re-wires at integration if it changes)
- `memory_search` (`memory:read`) / `memory_write` (`memory:write`, kinds `fact|preference` only, `source=user`)
- `knowledge_search` (`memory:read`)
- `pipeline_status` (`delivery:read`): register the tool but return `{"available": false}`. Agent 3's tables do not exist in your branch; Claude wires it at integration
Every call is audited (`mcp.tool_call`, token prefix, tool, ok/error), rate limited (60/min per token, in-memory), and its output passes through `redact.py`.
- [ ] 5.4 `services/mcp-gateway/README.md`: how to run it, the Caddy host suggestion `mcp.<root>` (Agent 3/owner configures it), and a client config snippet.
- [ ] 5.5 Router `mcp_tokens.py`: org admin+ can create, list and revoke org tokens. Owner endpoint to disable MCP globally or per org (setting stored in your `org_policies`).
- [ ] 5.6 UI: `(app)/settings` → "یکپارچه‌سازی (MCP)" tab (create with scope checkboxes, show-once modal with a copy button, list, revoke); `/admin/mcp` (global switch, per-org switch, token counts).
- [ ] 5.7 Tests `tests/test_mcp.py`: use the SDK's in-process client or call the tool functions directly. Cover: a token from org A can never read org B; scope enforcement; revoked/expired; rate limit; audit row written; no secret in outputs.
---
## Final report (after Phase 5)
`docs/plan/reports/agent-1-final.md`: summary, all endpoints added (method + path + role), tables added, the
CHANGE REQUESTs still open, manual QA steps for the wizard and MCP, known limitations.
+155
View File
@@ -0,0 +1,155 @@
# 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)
- **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`,
`app/application/ports/model_provider.py`, `app/application/ports/realtime.py`, `app/workers/ai_runtime.py`,
`tests/test_workforce.py`.
- **Reference (concepts only, NEVER import, never edit):** `/projects/agybot/org_manager.py` (lines ~23-101
roles/pipelines/statuses, `_WORK_TRANSITIONS` ~413) and `/projects/agybot/org_orchestrator.py`
(`ROLE_TAGS`/`MODE_TAGS` ~206-223, `STATUS_ROLE` ~178, `DEFAULT_EFFORT_BY_ROLE` ~292,
`DIRECT_MANAGER_SYSTEM_PROMPT` ~662, `_manager_pipeline` ~7671). Read with grep or by line range; those files are huge.
- **Your files:** only the "Agent 2" column of PLAN-v2 §6, your block in `config.py` / `.env.example`, and the
migration `alembic/versions/a2b3c4d5e6f2_ai_workforce.py` (`down_revision = "a0f0e1d2c3b4"`).
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`.
- **After each phase:** run all checks (PLAN-v2 §7.4), write `docs/plan/reports/agent-2-phase-<K>.md`, then STOP and wait.
You consume: `ModelProviderPort` (existing), `ModelGrantPort`, `MemoryPort`, `RepoWorkspacePort`,
`SandboxProviderPort` (existing), `DeliveryHooks.on_branch_pushed`, `EventPublisher`. Get them only from
the builders in `app/application/container.py`. Tests use the Fake/Null versions plus a **scripted fake model**
(you write it in `tests/engine_fakes.py`: returns queued responses in order and records the prompts it got).
You implement: `WorkforceBootstrapPort` (wired in `container_engine.py`).
**Important behaviour rules**
- The AI never runs git. It edits files and runs commands inside the sandbox; the platform does git through `RepoWorkspacePort`.
- Every model call writes a usage event exactly as the current `start_run` does (keep that code path).
Manager chat calls count too (agybot forgot this).
- Users never see model names. Store `ai_model_id` internally; APIs for non-owners return `tier` only.
- Nothing in this engine deploys to production. The furthest it goes is "merged and pushed to `dev`".
---
## Phase 1 — Engine core: protocol, state machine, pipelines (pure logic, no I/O)
Create package `app/application/engine/` (and `app/domain/workforce/` additions where the logic is pure):
- [ ] 1.1 `domain/workforce/pipeline.py`:
- `Stage` enum: `pending_manager_review`, `pending_analysis`, `pending_implementation`, `pending_review`, `pending_test`, `pending_final_review`, `merge_request`, `completed`, plus side states `pending_user_decision`, `error_review`, `paused`, `cancelled`, `failed`.
- `PIPELINES`: `plan_code_test` (default), `plan_code_review_test`, `code_test`, `analysis_only` (no worktree, no merge). Each is an ordered list of stages.
- `TRANSITIONS: dict[Stage, frozenset[Stage]]` and `next_stage(pipeline, current, outcome)`.
- `STAGE_ROLE`: stage → (role, mode). Manager modes are `triage`, `final`, `error`.
- `resolve_pipeline(manager_choice, task_kind, app_default, has_active_reviewer)`: priority manager > kind > app default > `plan_code_test`. A review pipeline without an active reviewer falls back to `plan_code_test`.
- `TASK_KINDS`: `feature`, `bugfix`, `refactor`, `docs`, `analysis` → default pipeline + risk.
- `RoleKey.DEVELOPER` is our "programmer". Keep the existing enum values; do not rename them.
- [ ] 1.2 `engine/protocol.py`: parser for `[[TAG attr="v" ...]]body[[/TAG]]` and self-closing `[[TAG attr="v"/]]`. Tolerant of whitespace; rejects unknown tags; returns typed dataclasses. Allowed tags per role/mode:
- manager/triage: `ASSIGN`, `REJECT`, `ASK_USER`, `KNOWLEDGE`
- manager/final: `COMPLETE`, `REVISE`, `KNOWLEDGE`
- manager/error: `RESOLVE_ERROR`, `CANCEL`, `KNOWLEDGE`
- manager/chat: `CREATE_TASK`, `PAUSE_TASK`, `RESUME_TASK`, `CANCEL_TASK`, `HIRE`, `UPDATE_EMPLOYEE`, `FIRE`, `KNOWLEDGE`
- planner: `PLAN_SPEC`, `SPLIT`, `ASK_USER`
- developer: `WORK_REPORT`
- reviewer: `REVIEW_PASS`, `REVIEW_CHANGES`
- tester: `TEST_PASS`, `TEST_FAIL`
- tool tags (all working roles): `READ_FILE path`, `WRITE_FILE path` (body = content), `EDIT_FILE path` (body = `<<<OLD … === … NEW>>>` search/replace blocks), `LIST_DIR path`, `SEARCH pattern path`, `RUN cmd timeout`
Disallowed tags are ignored and reported back to the model as an error line. Limits: max 3 `CREATE_TASK` per reply, max 3 team changes per turn, max 3 `KNOWLEDGE` per turn.
- [ ] 1.3 Tables in `models_engine.py`:
- `task_pipeline_state` (task_id PK/FK, pipeline, kind, risk, stage, cycle, max_cycles, branch, last_diff_hash, same_diff_count, retry_count, waiver_reason, parent_task_id, depends_on JSON, version int for optimistic updates, updated_at)
- `employee_profiles` (employee_id PK/FK, ai_model_id, effort, roles CSV, hired_by `user|manager`, busy_task_id)
- `engine_turns` (id, task_id, run_id, employee_id, role, mode, stage, prompt_chars, reply_excerpt ≤ 4000, tags JSON, tool_calls int, ok, error_kind, started_at, finished_at)
- `gate_results` (id, task_id, gate `tests|diff_guard|no_progress|green|lint`, ok, detail ≤ 8000, created_at)
Map the coarse `tasks.status` from the stage: new / running / waiting_approval / done / failed / cancelled. Existing APIs keep working.
- [ ] 1.4 `engine/state.py`: `advance(task_id, expected_from, to)` with an optimistic check (`version`). It fails cleanly when another worker moved the task first. A failed or cancelled **dependency** moves the dependents to `pending_user_decision`, never leaving them stuck (agybot bug M1).
- [ ] 1.5 Tests `tests/test_engine_protocol.py`, `tests/test_engine_pipeline.py`: every pipeline walks start→completed on the happy path; every illegal transition is refused; concurrent advance (two sessions) → exactly one wins; the dependency failure rule; tag parsing edge cases (nested brackets in a body, unknown tags, the per-role matrix, limits).
---
## Phase 2 — Agent turn runner (one employee, one turn, real tools in the sandbox)
- [ ] 2.1 `engine/prompt.py`, a prompt builder with a **stable prefix** (cache-friendly), in this order:
1. platform rules (short, yours)
2. role protocol (allowed tags + format examples)
3. toolkit description
4. app profile (from `AppProfile`: language, framework, commands; never secrets)
5. `MemoryPort.build_context(org, role, query=task text, budget_chars=2500)`
6. task brief (≤ 1,200 chars) + stage-specific context (the planner's spec for the developer; the **diff only** for the reviewer; test output for the tester)
7. the conversation so far in this turn (tool results)
Hard cap on total characters; truncate oldest tool results first.
- [ ] 2.2 `engine/tools.py`: executes tool tags **inside the sandbox** through `SandboxProviderPort`, rooted at `WorktreeRef.sandbox_path`.
- Path safety: normalise; reject absolute paths outside the root, `..` escapes and anything under `.git`.
- `RUN` has a timeout (default 120s, max 600s) and output capped (last 8,000 chars). The command is passed to the sandbox as-is (the sandbox is the security boundary), but `git push`, `git remote` and `git config` are refused with a message ("the platform handles git").
- `EDIT_FILE` applies search/replace blocks and fails with a clear message when OLD is not found exactly once.
- Each tool call writes a `tool_calls` row (existing table) with status `succeeded`/`failed`, so the existing approvals UI finally sees real executions.
- [ ] 2.3 `engine/runner.py`, `run_turn(task, employee, role, mode) -> TurnResult`. Loop:
- model call → parse tags → execute tools → feed results back → until a terminal protocol tag appears, or `ENGINE_MAX_TOOL_CALLS_PER_TURN`, or `ENGINE_TURN_TIMEOUT_SECONDS`.
- Model = the employee's `ai_model_id`, which must be granted (`ModelGrantPort.is_granted`; if not, fail with `error_kind=auth` and tell the manager).
- Error kinds `rate_limit`, `unavailable`, `network`, `timeout` are **transient** (retry later, up to 2 retries); `auth`, `model`, `protocol` are not.
- Writes `engine_turns`, `run_steps` and usage events, and publishes `run.log` / `run.tool` / `run.status` (batched, PLAN-v2 §5).
- Cancellation: `cancel_run` must stop the loop between steps.
- [ ] 2.4 Tests `tests/test_engine_runner.py` with the scripted fake model + `FakeRepoWorkspace` + a fake sandbox (dict filesystem + scripted command results; put it in `tests/engine_fakes.py`). Cover: the write → run → report loop; path escape refused; `git push` refused; the tool-call cap; timeout; transient vs fatal errors; usage events counted per model call; an ungranted model refused; no secret from settings ever appears in a prompt (assert on the fake model's recorded prompts).
---
## Phase 3 — Manager AI (orders → tasks → team)
- [ ] 3.1 Manager chat: rewrite `post_user_message` so the manager replies **and** may emit `CREATE_TASK` (title, kind, priority, depends_on, optional pipeline), `PAUSE_TASK` / `RESUME_TASK` / `CANCEL_TASK`, and `HIRE` / `UPDATE_EMPLOYEE` / `FIRE`. Tasks created this way get a `task_pipeline_state`, stage `pending_manager_review`, and are linked to the thread. The manager's prompt must ask for **atomic tasks that do not touch the same files**. Count chat usage.
- [ ] 3.2 Team management: `HIRE role= tier= name=` picks a granted model matching the tier/capabilities (`ModelGrantPort.granted_models`). Respect max employees (setting + later org policy), max 3 changes per turn, and "the last manager cannot be fired". Publish `team.changed`. Users can still hire and fire from the UI (existing endpoints) but only choose **role + tier**, never a model.
- [ ] 3.3 Triage turn (manager, mode triage): decide pipeline + assignee (`ASSIGN employee= pipeline=`), or `REJECT`, or `ASK_USER` (→ `pending_user_decision`, message posted in the thread). Default employee selection: least busy active employee with the needed role; `ASSIGN employee="name"` uses fuzzy name matching inside the org only.
- [ ] 3.4 Final turn (manager, mode final): sees the plan, a diff summary, gate results and the test output → `COMPLETE` (→ `merge_request`) or `REVISE` (→ back to implementation, cycle+1). The error turn (mode error) handles failed gates and errors → `RESOLVE_ERROR` (retry with a hint) or `CANCEL`.
- [ ] 3.5 `KNOWLEDGE` tags → `MemoryPort.record(kind=fact|rule, source=manager)`.
- [ ] 3.6 `WorkforceBootstrapPort.bootstrap_team(org, project)`: creates one manager + one planner + one developer + one tester on the strongest/balanced/fast granted tiers (whatever is granted; if nothing is granted, create the employees "inactive" and post a system message "منتظر تخصیص مدل توسط مالک سامانه"). Idempotent. Wire it in `container_engine.py`.
- [ ] 3.7 Tests `tests/test_engine_manager.py`: an order → ≤ 3 tasks with deps; HIRE limits; FIRE the last manager refused; ASK_USER path; idempotent bootstrap; cross-org isolation (the manager of org A can never assign an employee of org B, even by exact name).
---
## Phase 4 — Pipeline execution, gates, commit, merge, push
- [ ] 4.1 `engine/scheduler.py`, `tick()`. Each tick:
- finds tasks whose stage needs a turn and whose deps are completed, and a free employee with the role (one task per employee at a time)
- honours org automation (if Agent 1's policy says `automation_enabled=false`, tasks wait in `pending_manager_review`; read it through `ModelGrantPort` when the CHANGE REQUEST lands, until then assume enabled)
- runs turns concurrently with a per-org cap (3) and a global cap (setting)
`workers/ai_runtime.py` calls `tick()` in its loop every `ENGINE_TICK_SECONDS` (keep the one-pass mode for cron). Safe with two worker processes thanks to the optimistic `advance`.
- [ ] 4.2 Worktree lifecycle: on the first developer/planner turn, `RepoWorkspacePort.create_task_worktree(project, task, base="dev")`; the branch is `task/<task_id>`. `analysis_only` gets none. The worktree is removed after completion, cancellation or failure.
- [ ] 4.3 Gates after `WORK_REPORT` (each writes a `gate_results` row and publishes `task.gate`):
- **diff guard**: `RepoWorkspacePort.diff`. Fail on changes under `.git`, CI files (`.github/`, `.gitlab-ci.yml`, `.gitea/`), symlinks pointing outside the repo, or files > 1 MB. An empty diff for a code task also fails.
- **no-progress**: hash the diff; 2 identical hashes in a row → `error_review`.
- **tests**: run the app profile's `test_command` in the sandbox (timeout 600s), store the tail of the output. Red → back to the developer with the failure tail (one escalation to a stronger granted tier on the 2nd red), counting cycles. Green → tester stage.
- **green gate**: the tester's `TEST_PASS` is accepted only if the tests gate is green, OR `TEST_PASS waive="reason"` with a non-empty reason (stored in `waiver_reason`, shown in UI).
- `max_cycles` reached → `error_review`.
- [ ] 4.4 Commit + merge + push on `merge_request`:
- `commit` (message: `task/<id>: <title>`, author = employee display name + platform email), then `sync_with_base`, then re-run the tests if base changed (a semantic conflict is only caught by retests).
- Then `merge_into(target="dev")` and `push(branch="dev")`.
- A conflict → back to the developer with the conflicting files (max 3 times, then `error_review`).
- On success: stage `completed`, `DeliveryHooks.on_branch_pushed(org, project, "dev", sha, source="engine")`, `MemoryPort.on_task_finished(TaskOutcome)` (never let its failure fail the task).
- [ ] 4.5 Budget: before each turn, check the org credit wallet (existing billing service public functions; read, do not edit) and the per-task cap. Out of budget → `paused` with a Persian reason.
- [ ] 4.6 Tests `tests/test_engine_flow.py`, the end-to-end engine with all fakes. The scripted model plays manager → planner → developer → tester → manager; the fake sandbox makes tests red once and then green. Assert the stage sequence, gate rows, cycles, the commit/merge/push calls on `FakeRepoWorkspace`, `on_branch_pushed` called once with the right sha, `on_task_finished` called once, and usage events. Plus the failure paths: no-progress, max cycles, merge conflict ×3, cancel mid-turn, a dependency failure.
---
## Phase 5 — UI (orders, live task view, team)
- [ ] 5.1 `lib/api-engine.ts`: typed client functions for everything new (task detail with pipeline state, turns, gates, team with tier, hire/fire with role+tier).
- [ ] 5.2 `/manager`: the chat shows task cards created by the manager inline (title, stage badge, link), live via `org:` + `thread:` channels; an "در انتظار تصمیم شما" banner for `pending_user_decision` with a reply box.
- [ ] 5.3 `/tasks`: a board grouped by stage (columns: بررسی مدیر، تحلیل، پیاده‌سازی، بازبینی، تست، ادغام، انجام‌شده، نیازمند تصمیم) with a live update; filters; pause/resume/cancel (with confirm).
- [ ] 5.4 `/tasks/[id]`:
- a pipeline stepper (stages of its pipeline, current one highlighted)
- the turn timeline (who, role, when, outcome)
- a **live log** panel (subscribe to `run:<id>`, auto-scroll, pause-scroll)
- gate results (green/red with expandable output)
- a diff viewer (read-only, from the latest diff)
- the waiver reason when present
- a link to the pipeline run in `/app` once pushed (just a link with the sha; that page is Agent 3's)
- [ ] 5.5 `/workforce`: employees with role, tier, status, current task; hire (role + tier) and fire with confirm. **No model names anywhere** for non-owners (test it).
- [ ] 5.6 `/dashboard` + `/traces`: show active tasks, a stage distribution and recent completions; keep the existing widgets working.
- [ ] 5.7 Web tests `src/test/engine-*.test.tsx`: board grouping, live log append and batching, no model name rendered (render with a fixture that contains a model name and assert it is absent).
---
## Final report (after Phase 5)
`docs/plan/reports/agent-2-final.md`: summary, the engine architecture (one diagram in text), the tag protocol
reference table, every endpoint added or changed, tables, settings used, CHANGE REQUESTs still open (expected:
org policy in `ModelGrantPort`, the worker hook for memory expiry), manual QA steps, known limitations.
+177
View File
@@ -0,0 +1,177 @@
# 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)
- **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`,
`app/application/services/sandbox_service.py`, `project_service.py`, `app/domain/platform/rules.py`,
`docs/current-infra-notes.md`, `tests/test_project_repo.py`, `tests/test_domain_isolation.py`.
- **Reference (concepts only, NEVER import, never edit):** `/projects/agybot/caddy_manager.py`, `port_registry.py`,
`ssh_manager.py`, `ftp_manager.py`, `git_manager.py`.
- **Your files:** only the "Agent 3" column of PLAN-v2 §6 (including ALL of `app/infrastructure/adapters/`), your
block in `config.py` / `.env.example`, and the migration `alembic/versions/a3b4c5d6e7f3_delivery_infra.py`
(`down_revision = "a0f0e1d2c3b4"`). New tables go in `app/infrastructure/db/models_delivery.py`. **`db/models.py`
is locked**, so extra data about existing sandbox tables goes into new side tables.
- **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`: tests use pytest's
`tmp_path` (allowed) and the worktree's `./_scratch/`.
- **After each phase:** run all checks (PLAN-v2 §7.4), write `docs/plan/reports/agent-3-phase-<K>.md`, then STOP and wait.
You implement: `RepoWorkspacePort`, `DeliveryHooks`, `DeployTargetTester`, plus a docker `SandboxProviderPort`
adapter. Wire them in `container_delivery.py` and `registry.py` behind settings (mock remains the default
for tests).
**Security rules specific to you**
- Nothing runs on the host except: `git` (via the workspace adapter, hardened per D6), the docker/podman CLI, and Caddy reload (existing adapter).
- Every subprocess: `asyncio.create_subprocess_exec` (never `shell=True`), `start_new_session=True`, kill the process group on timeout, drain stdout and stderr concurrently (avoid the 64 KB pipe deadlock), and pass explicit `env` (never inherit secrets).
- Credentials never appear in argv, URLs, logs, exceptions, realtime events or DB columns other than `*_encrypted`. Use `GIT_ASKPASS` with a 0700 helper script that reads a 0600 temp file in the worktree's `./_scratch`-like private dir under `SANDBOX_WORKSPACES_ROOT`, deleted in `finally`.
- Ports and hosts must pass `app/domain/platform/rules.py` (protected ports, single-label hosts, localhost targets).
---
## Phase 1 — Docker sandbox adapter
- [ ] 1.1 `adapters/docker_sandbox.py`, `DockerSandboxAdapter` implementing `SandboxProviderPort` with the CLI in `SANDBOX_DOCKER_BIN` (docker or podman, the same flags):
- `create_workspace`: creates a host dir `SANDBOX_WORKSPACES_ROOT/<org_id>/<project_id>/work` (0700, owned by the platform user).
- `start_session`: `docker run -d` the **workspace container**:
- image from the app profile language: `node:20-bookworm`, `python:3.13-bookworm`, `php:8.3-cli`, `golang:1.23`, fallback `debian:bookworm`; make the mapping a dict in your config block / domain
- `--user 1000:1000 --cap-drop ALL --security-opt no-new-privileges --pids-limit 512 --cpus $SANDBOX_DEFAULT_CPUS --memory ${MB}m`
- `--read-only` with `--tmpfs /tmp` and `--tmpfs /home/app`
- bind-mount **only the work dir** (never the git dir) to `/workspace`
- label `smart-org.org=<id>`, `smart-org.project=<id>`, `smart-org.role=workspace`
- network: the default bridge (package installs need egress), with no published ports
- `run_command`: `docker exec -w <cwd> <ctr> sh -lc <cmd>`, with timeout, output cap, exit code, and an optional async `on_output(chunk)` callback for streaming (add it as an adapter-level extra method; the port stays unchanged).
- `read_file` / `write_file`: through the host bind mount with path safety (`resolve()` stays inside the work dir; reject symlinks that escape).
- `pause` / `resume` / `destroy`: stop/start/rm. `snapshot`: `docker commit` (tagged per org) or raise a clear "not supported" error, your choice, documented.
- `get_preview_url`: delegated to the domain layer (Phase 4).
- [ ] 1.2 `registry.py`: `SANDBOX_PROVIDER=docker` → this adapter. The mock stays the default.
- [ ] 1.3 The `sandbox_gateway` worker: also reap containers with the `smart-org.*` label that have no live session row (orphans), audited.
- [ ] 1.4 Hide infra from non-owners:
- `sandbox.py` command/file endpoints become **owner-only** (or admin+ of the org with a setting off by default; prefer owner-only).
- Keep the read-only status endpoints for org members.
- Update `components/sandbox-panel.tsx` to show only status to non-owners.
- Update existing tests that assumed member access, and explain each change in your report.
- [ ] 1.5 Tests `tests/test_docker_sandbox.py`: unit tests with a **fake process runner** (inject the subprocess function) that assert the exact argv (hardening flags present, no secrets, mount of the work dir only), timeout kill, output cap, path escape refused. Integration tests marked `@pytest.mark.docker`, skipped unless `SANDBOX_DOCKER_IT=1`: real start → exec `echo` → write/read file → destroy.
---
## Phase 2 — Git workspaces (`RepoWorkspacePort`, the platform does git, D6)
- [ ] 2.1 `adapters/git_workspace.py`, `LocalGitWorkspaceAdapter`. Layout per project under `SANDBOX_WORKSPACES_ROOT/<org>/<project>/`:
- `git/`: a bare-ish gitdir, **never mounted**
- `work/`: the main working tree for the dev branch, mounted at `/workspace`
- `work/.worktrees/<task_id>/`: task worktrees, visible in the container at `/workspace/.worktrees/<task_id>`
- `WorktreeRef.sandbox_path` = that in-container path
Note: the `.worktrees` dir must be excluded via `info/exclude` in the gitdir, not the user's `.gitignore`.
- [ ] 2.2 Every git call is `git --git-dir=<gitdir> --work-tree=<path> -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false -c protocol.file.allow=never -c safe.directory=*`, with env `GIT_CONFIG_NOSYSTEM=1`, `HOME=<private dir>`, `GIT_TERMINAL_PROMPT=0`. Never trust the `.git` *file* inside a worktree; always pass explicit dirs.
- Status: `status --porcelain=v1 -uall -z`.
- One `asyncio.Lock` per project id held for every mutating operation (agybot M14). Remove a stale `index.lock` only if older than 30s.
- [ ] 2.3 Remotes:
- **platform** git: the URL and credentials come from the existing Gitea adapter / `project_git_credentials` (decrypt via the existing crypto helper).
- **external** git: `project_external_git` (token → `GIT_ASKPASS`; ssh_key → temp key file 0600 + `GIT_SSH_COMMAND="ssh -i <key> -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=<per-project known_hosts>"`; store `known_hosts` on the first `test_remote` = TOFU, in the existing column).
- `test_remote`: `git ls-remote --heads` with a 20s timeout; returns ok, default branch, branch list, and a friendly error (auth failed / not found / unreachable) **without echoing the URL credentials or stderr secrets**.
- SSRF check on external hosts (resolve DNS, reject private, loopback and link-local unless `DEPLOY_ALLOW_PRIVATE_HOSTS`); put the check in `domain/platform/rules.py` (yours). It runs inside `test_remote` and before every deploy connection; Agent 1 only does format validation and relies on your check.
- [ ] 2.4 Implement every `RepoWorkspacePort` method per PLAN-v2 §5.
- `ensure_workspace` clones on first use, fetches afterwards. It creates `dev` from the default branch if missing; for platform repos also push `dev`.
- `merge_into` does a `--no-ff` merge in the main work tree under the lock and returns the conflict file list on failure, after `merge --abort`. Never leave a half-merge (agybot H3).
- `push` returns ok/rejected (non-fast-forward → fetch + report, never force).
- [ ] 2.5 Wire it in `container_delivery.py` (setting `GIT_WORKSPACE=local|fake`, fake default in tests).
- [ ] 2.6 Tests `tests/test_git_workspace.py` with **real git** and a local bare repo in `tmp_path` as "remote" (no network):
- the full flow ensure → worktree → write → diff (new dir appears thanks to `-uall`) → commit → sync → merge → push → head
- a conflict path leaves a clean tree
- hooks are NOT executed (plant a `post-commit` hook that writes a file and assert it is absent)
- a `.git` file tampered to point elsewhere is ignored
- credentials are never in argv (capture the runner calls)
- concurrent merges are serialised
---
## Phase 3 — Live CI/CD + demo server
- [ ] 3.1 Tables (`models_delivery.py`):
- `pipeline_runs` (id, org, project, trigger `engine|poll|manual`, branch, sha, status `queued|running|succeeded|failed|cancelled|superseded`, started/finished, error_summary)
- `pipeline_steps` (id, run_id, index, name, status, started/finished, exit_code, log_tail ≤ 64 KB)
- `releases` (id, org, project, run_id, sha, artifact_path, created_at, is_demo_live)
- `app_runtimes` (project_id PK, demo_container_id, host_port, demo_host, status, current_release_id, updated_at)
- [ ] 3.2 `delivery_service.py` implementing `DeliveryHooks`:
- `on_app_created`: allocate a demo port in `DEMO_PORT_START..END` (reuse the existing preview port rules), claim the demo host `<org-slug>.<PREVIEW_ROOT_DOMAIN>` through the existing `DomainRouterPort`, create `app_runtimes`, `ensure_workspace`, and queue a first pipeline run if the repo has a `dev` head.
- `on_branch_pushed`: queue a run (one active run per project; a newer queued run supersedes an older queued one).
- [ ] 3.3 `workers/delivery.py`: loop (`--loop`) that executes queued runs and polls remotes every `DELIVERY_POLL_SECONDS` (`head` vs the last run sha) for pushes made outside the engine (users pushing to their own git).
- Steps, each in a **fresh build container** (same image mapping and hardening, work dir = a clean checkout of the sha in a per-run dir):
1. `checkout`
2. `setup` (setup_command)
3. `build` (build_command, skipped if empty)
4. `test` (test_command, skipped if empty)
5. `package` (tar.gz of the run dir excluding `.git` and `node_modules` → `ARTIFACTS_ROOT/<org>/<project>/<sha>.tar.gz`)
6. `deploy_demo`: start the new demo container with `start_command`, port mapping `127.0.0.1:<host_port>:<app_port>`, the same hardening, label `role=demo`
7. `healthcheck`: HTTP GET `http://127.0.0.1:<host_port><healthcheck_path>` with 20 retries × 3s; any 2xx/3xx = ok
8. `route`: `configure_reverse_proxy` for the demo host → `127.0.0.1:<host_port>`
- **Blue/green:** start the new container on a temporary second port, healthcheck it, then switch the Caddy route, then stop the old one. If any step fails, the old demo keeps running untouched and the run is `failed` with a Persian `error_summary`.
- [ ] 3.4 Live logs: every step streams output via the `on_output` callback → batched `pipeline.log` events on `org:<id>` (≤ 1 event / 500 ms, ≤ 200 lines), plus `pipeline.step` and `pipeline.status`; `release.live` when switched. Store the step `log_tail` (last 64 KB). Pass all output through `app/core/redact.py` before publishing or storing.
- [ ] 3.5 Router `delivery.py` (org member read, admin+ actions): list runs, run detail with steps and tails, `POST /delivery/runs` (manual re-run of the dev head, admin+), cancel the queued/running run, list releases, rollback the demo to a previous release (admin+, confirm, audit), and the runtime status (demo URL, live release, health).
- [ ] 3.6 Tests `tests/test_delivery.py` with the fake runner + `FakeRepoWorkspace` + mock domain router:
- the happy path step sequence and events
- a failed build keeps the old demo
- healthcheck failure → no route switch
- supersede logic
- poll detects an external push
- logs redacted (plant a secret in the build output)
- tenant isolation on every endpoint
- an optional `@pytest.mark.docker` end-to-end with a tiny static app (`python -m http.server`)
---
## Phase 4 — Domains (Caddy) owned by the platform owner
- [ ] 4.1 `admin_domains.py` (owner only):
- list all routes (host → org/app → target port → status)
- attach an extra host to an org's app: a single label under the root via the existing rules, OR a full custom FQDN when `ALLOW_CUSTOM_DOMAINS=true` (strict FQDN regex, no wildcard, no protected hosts such as `git`, `mcp`, `panel`, `api`, root)
- detach (confirm + audit)
- re-sync all routes from the DB to Caddy (idempotent)
Users cannot add domains (the existing member-facing preview-domain endpoints in `projects.py` become owner-only, and the tests are updated with reasons).
- [ ] 4.2 The Caddy adapter keeps validate → revert → reload. Add a `routes_fingerprint` check so re-sync does nothing when nothing changed.
- [ ] 4.3 The demo host is created automatically in `on_app_created` (Phase 3). Record the MCP gateway host suggestion (`mcp.<root>` → `MCP_GATEWAY_PORT`) as a protected system route that the owner can enable from this page.
- [ ] 4.4 Tests `tests/test_admin_domains.py`: non-owner forbidden; protected hosts refused; invalid FQDNs refused; detach audited; re-sync idempotent (mock router call count).
---
## Phase 5 — Production deploy over SSH / SFTP / FTP / FTPS (always human-triggered)
- [ ] 5.1 `adapters/deploy_ssh.py` (asyncssh), `DeployTargetTester` + `SshDeployer`:
- Test: connect (15s timeout) → host-key TOFU (store the fingerprint on the first success; afterwards a mismatch = hard fail "کلید میزبان تغییر کرده"), check `remote_path` exists and is writable.
- Deploy: upload the release artifact → `remote_path/releases/<ts>-<sha7>/` → extract → run `post_deploy_command` with `cwd` there (timeout 600s, streamed output) → atomically switch the `remote_path/current` symlink → keep the last 5 releases.
- Rollback = re-point `current` to the previous release + rerun post_deploy.
- [ ] 5.2 `adapters/deploy_ftp.py` (aioftp; FTPS via explicit TLS), `DeployTargetTester` + `FtpDeployer`:
- Test: login, cwd `remote_path`, write and delete a probe file.
- Deploy: upload the build output (the build dir is set by the framework catalog default or `dist/`, `build/`, `public/`, else the whole artifact) into `remote_path` after downloading a **backup listing** of the files it overwrites into `ARTIFACTS_ROOT/.../ftp-backup-<ts>.tar.gz`.
- Rollback = re-upload that backup. Document the limits of FTP (no atomic switch).
- [ ] 5.3 SSRF check for every target host (the Phase 2.3 helper). A combined `DeployTargetTester` dispatching on `kind`, wired in `container_delivery.py`.
- [ ] 5.4 `deploy_service.py` + endpoints in `delivery.py`:
- `POST /delivery/production/deploy` with `release_id` (default: the live demo release). Org **owner/admin only**; requires a `confirm: "<org-slug>"` field; one production deploy per project at a time.
- Status: `deploy.status` events and a `production_deploys` table (id, release, target, status, started/finished, log_tail, rolled_back_from).
- `POST /delivery/production/rollback`.
- Every action audited. Credentials decrypted only inside the adapter call.
- [ ] 5.5 Tests `tests/test_deploy.py`: fake SSH/FTP clients injected. Cover: the release/symlink sequence, keep-5 pruning, rollback, host-key mismatch refused, SSRF refused, a member (non-admin) refused, the confirm slug mismatch refused, no password in logs/events/DB columns other than encrypted ones. Optional integration (`DEPLOY_IT=1`) against a local `sshd`/`pure-ftpd` container.
---
## Phase 6 — UI: the "Application" page (replaces `/projects`)
- [ ] 6.1 `(app)/app/page.tsx` (+ components under `(app)/app/_components/`):
- **Header:** app name, type/language/framework badges, demo URL (open button), live release sha + time, health dot.
- **Pipeline runs:** list with status; clicking one opens the run view: a step list with status and duration, and a **live log console** (subscribe `org:<id>`, filter by run id, ANSI colours stripped, auto-scroll with pause, and a "copy" button).
- **Releases:** history; "rollback demo" (admin+, confirm).
- **Production:** target summary (kind, host, path, last test result; no secrets), the "استقرار در production" button (admin+, modal requiring the org slug), a deploy live log, production history + rollback.
- **Git:** repo link (platform: Gitea URL; external: the URL without credentials), `dev` head sha, last poll time. No branch/PR/port controls for users.
- [ ] 6.2 Remove `(app)/projects` (redirect `/projects*` → `/app`) and drop the member-facing port/branch/PR/command controls. Owner-only debug tools move to `/admin/sandbox`.
- [ ] 6.3 `/admin/sandbox` (owner): running containers per org (workspace/demo/build), CPU/memory limits per org (existing `sandbox_resource_limits`), stop/restart a container (confirm + audit), and an image mapping view. `/admin/domains` (owner): the Phase 4 UI.
- [ ] 6.4 `lib/api-delivery.ts`: typed client for all of the above.
- [ ] 6.5 Web tests `src/test/delivery-*.test.tsx`: the log console batching and auto-scroll, the confirm-slug modal, no secret fields rendered, non-admin sees no deploy button.
---
## Final report (after Phase 6)
`docs/plan/reports/agent-3-final.md`: summary, host requirements (docker/podman, Caddy, dirs and permissions,
the sudo/systemd needs of the Caddy reload), every setting, every endpoint added or changed (method + path + role),
tables, CHANGE REQUESTs still open, manual QA script (wizard app → push → watch live pipeline → open demo URL →
deploy to a local SSH test server → rollback), known limitations.