feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final

- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console
- Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages
- Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review
- Pause/resume/cancel/answer actions in engine API and UI
- Employee cap (7 per org default), review pipeline guard (active reviewer required)
- Scheduler: wide window prevents org starvation; archived employees excluded from cap
- Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox
- Order route fix: manager built with pipeline states + employee profiles
- Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy
- Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed)
- api-engine.ts typed client; api-credit.ts client
- Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables
- 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green

Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
This commit is contained in:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
+487
View File
@@ -0,0 +1,487 @@
final check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/ruff check . && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app
# Smart Org v2 — finish plan (integration and closing work)
Base: branch `dev`, HEAD `36564e3`. Baseline checked on 2026-10-14: `pytest` 746 passed / 1 skipped,
`alembic upgrade head` + `alembic check` clean on SQLite, single head `b9c0d1e2f3a4`. None of the tasks
below needs a migration. If one turns out to need one, use `down_revision = "b9c0d1e2f3a4"`.
What the code already has (checked, so NOT planned again):
- `container_delivery.py` builds the real `LocalGitWorkspaceAdapter` when `GIT_WORKSPACE=local`; the fake
stays the default (that default is correct for tests and dev). `build_delivery_hooks` returns the real
`DeliveryService`, and `build_deploy_target_tester` returns the real `DispatchingDeployTargetTester`.
- `container_org.py` builds the real `MemoryService` and `ModelGrantService`, and
`container_engine.py` builds the real bootstrap and scheduler.
- The wizard (`app_setup.py::_build_service`) passes the real `build_workforce_bootstrap` and
`build_delivery_hooks`. `OrgSetupService._call_hooks` calls `bootstrap_team` and `on_app_created`
after its commit.
- After merge and push, `Scheduler._merge_step` calls `DeliveryHooks.on_branch_pushed(source="engine")`,
and `DeliveryService.on_branch_pushed` queues a `pipeline_run` for `dev`.
- No duplicate repository files exist: every repository class name is unique. The only duplicated wiring
is the hand-built `ModelGrantService` in `org_setup_service.py`, which T11 folds in.
Integration bugs found while checking (fixed by g1):
- **Double execution.** Engine tasks are created with coarse status `new`. In the same pass,
`AiRuntimeWorker._drain_runs` → `TaskRepository.list_runnable` hands them to the legacy one-shot
`start_run`. That run marks them `done` before `Scheduler.tick()` sees them. In the real worker, an
order from the manager never goes through the pipeline. → T2
- **Every new org is paused.** The wizard (and `organizations.py`, and `BillingService.get_wallet` via
`ensure`) creates a wallet with `balance=0.0`. `Scheduler._budget_reason` pauses on `balance <= 0`, and
nothing in the code ever debits a wallet from usage. So every org made by the wizard has every task
paused before its first turn. → T3
- **No sandbox for a new org.** The engine only reuses an *active* sandbox session, and nothing creates
one: sandbox mutations are platform-owner-only, and `on_app_created` does not start one. For a fresh
app, every tool turn fails and the tests gate is always red. → T6
- **The owner's org policy is ignored by the engine** (`automation_enabled`, `max_employees`,
`monthly_credit_budget` live only on `ModelGrantService`). → T1, T4, T5
Web checks run from `/projects/smart-org/apps/web` with the hoisted `/projects/smart-org/node_modules`
(this works: vitest + tsc run there). `@agy/contracts` in `node_modules` is a **copy**, not a link. After
editing `packages/contracts/src/index.ts`, copy it to `node_modules/@agy/contracts/src/index.ts` before
running tsc (T15 does that in its check).
---
## T1 — `ModelGrantPort.get_org_policy` (Agent 1 CHANGE REQUEST 1)
status: done
group: prepare
after: none
files: apps/api/app/application/ports/model_grants.py:15-50, apps/api/tests/engine_fakes.py:184-219
change:
1. In `ports/model_grants.py` add a read-only `OrgPolicyView(Protocol)` with `@property` members:
`max_employees -> int`, `automation_enabled -> bool`, `mcp_enabled -> bool`,
`monthly_credit_budget -> float | None`. Using properties lets `ModelGrantService.PolicyView` (a plain
`@dataclass(slots=True)`) and a frozen dataclass both satisfy it under mypy.
2. Add a frozen dataclass `DefaultOrgPolicy(max_employees: int = 20, automation_enabled: bool = True,
mcp_enabled: bool = True, monthly_credit_budget: float | None = None)`. Do not import settings in the
port; the engine applies `settings.engine_max_employees` itself.
3. Add `async def get_org_policy(self, organization_id: str) -> OrgPolicyView: ...` to the
`ModelGrantPort` Protocol. Keep the argument **positional**, because that matches the existing
`ModelGrantService.get_org_policy(organization_id)` at `services/model_grant_service.py:378`.
4. Implement it on `AllActiveModelsGrants` by returning `DefaultOrgPolicy()`. Update the module
docstring: the contract was extended at integration.
5. In `tests/engine_fakes.py::FakeGrants`, add a `policy` kwarg/attribute (default `None`) and
`async def get_org_policy(self, organization_id)`. When `policy` is None, return
`DefaultOrgPolicy(max_employees=settings.engine_max_employees)`, read at call time so the
`monkeypatch` in `test_the_employee_cap_is_enforced` still works.
6. Do not change `ModelGrantService`. `isinstance(service, ModelGrantPort)` must stay true
(`tests/test_foundation.py:147`).
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_foundation.py tests/test_model_grants.py tests/test_engine_manager.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/ports/model_grants.py app/application/services/model_grant_service.py
## T2 — the legacy run drain must never pick up engine tasks
status: done
group: g1
after: none
files: apps/api/app/infrastructure/repositories/workforce.py:10,32,444-457, apps/api/tests/test_integration_runtime.py (new)
change:
1. In `TaskRepository.list_runnable`, add
`~exists(select(TaskPipelineStateRow.task_id).where(TaskPipelineStateRow.task_id == TaskRow.id))`
to the `where`. Import `exists` from sqlalchemy and `TaskPipelineStateRow` from
`app.infrastructure.db.models_engine`. Extend the docstring: tasks that own a pipeline state belong to
`Scheduler.tick()`, and running them here would double-bill and mark them `done` behind the engine's
back.
2. New test file `tests/test_integration_runtime.py` (fixtures `session_factory`, `seeded` from
conftest). Make one org with two `TaskRow`s in status `new`, and give one of them a
`TaskPipelineState` (`PipelineStateRepository.create`, stage `PENDING_MANAGER_REVIEW`; see the
`_task` helper in `tests/test_engine_flow.py:162`). Assert that `list_runnable()` returns only the
plain task.
3. Before editing, run `git log -1 -- apps/api/app/infrastructure/repositories/workforce.py`. If the live
Agent 2 session has changed the file, rebase your edit onto its version.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_runtime.py tests/test_workers.py tests/test_workforce.py
## T3 — an empty (0.0) wallet must not pause the engine; only an overdrawn one does
status: done
group: g1
after: none
files: apps/api/app/application/engine/scheduler.py:592-604, apps/api/tests/test_engine_flow.py:88-104,535-562
change:
1. Decision, recorded in the docstring: usage is post-paid (the ledger feeds invoices) and wallets are
never debited by usage. A zero balance is therefore every org's starting state, not "out of money".
In `_budget_reason`, change `wallet.balance <= 0` to `wallet.balance < 0` and update the comment.
The owner's real spend lever is `monthly_credit_budget` (T4).
2. In `tests/test_engine_flow.py`, set `_EmptyWallet.balance = -1.0`, rename it `_OverdrawnWallet` and
update its docstring. Rename the test to `test_an_overdrawn_wallet_pauses_the_task_before_a_turn_is_spent`.
Add `test_a_zero_wallet_does_not_pause_a_fresh_org`: a 0.0 wallet, stage `PENDING_MANAGER_REVIEW`, one
tick, and the stage is no longer `PAUSED`. Explain the intentional behaviour change in the test
docstring (PLAN-v2 §7.4 allows this).
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_engine_flow.py
## T4 — scheduler honours the org policy: automation switch + monthly credit budget
status: done
group: g1
after: T1, T3
files: apps/api/app/application/engine/scheduler.py:162-201,592-604, apps/api/app/infrastructure/repositories/engine.py:491-507, apps/api/tests/test_integration_policy.py (new)
change:
1. `TaskSpendRepository`: add `async def spend_for_org_since(self, organization_id: str, since: datetime) -> float`,
which returns the sum of `UsageEventRow.total_price` for that org with `created_at >= since`.
It is read-only, in the same style as `spend_for_task`.
2. `Scheduler.tick`: build a per-tick cache `policies: dict[str, OrgPolicyView]` from
`await self.manager.grants.get_org_policy(org_id)`. Drop turn **and** merge states whose org has
`automation_enabled is False`: `report.skipped += 1` plus a Persian note
("اتوماسیون این سازمان خاموش است"). Do not move the stage. D13 says a human-off switch must stop
everything up to the merge.
3. `_budget_reason`: when the policy's `monthly_credit_budget is not None` and `self.spend` is set,
compare `spend_for_org_since(org, first day of the current UTC month)` with the budget. When spend
is >= the budget, return "سقف اعتبار ماهانهٔ سازمان پر شده است؛ کار متوقف شد.". Fetch the policy once
per task, or pass it in from the tick cache.
4. New `tests/test_integration_policy.py`. Reuse `world`/`_team`/`_task`/`_scheduler` by importing them
from `tests.test_engine_flow`, or copy the minimum. Test that: automation off → the task does not move
and `scripted.calls == 0`; a budget of 0.0 with any ledger spend → `PAUSED`; no policy → unchanged
behaviour. Pass the policy through `FakeGrants(policy=DefaultOrgPolicy(...))` from T1.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_policy.py tests/test_engine_flow.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/scheduler.py app/infrastructure/repositories/engine.py
## T5 — hiring respects the owner's per-org `max_employees`
status: done
group: g1
after: T1
files: apps/api/app/application/engine/manager.py:290-310, apps/api/tests/test_integration_hire_cap.py (new)
change:
1. In `ManagerTurns.hire`, compute
`cap = min(settings.engine_max_employees, (await self.grants.get_org_policy(organization_id)).max_employees)`
and use `cap` in the check and in the Persian message, in place of `settings.engine_max_employees`.
2. New test `tests/test_integration_hire_cap.py`, modelled on `tests/test_engine_manager.py:356`
(`test_the_employee_cap_is_enforced`). Use `FakeGrants(policy=DefaultOrgPolicy(max_employees=2))`:
the third hire raises `ConflictError`. A second case checks that the global setting still wins when it
is lower.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_hire_cap.py tests/test_engine_manager.py
## T6 — the engine provisions the app's workspace sandbox session when none is active
status: done
group: g1
after: none
files: apps/api/app/application/container_engine.py:87-128, apps/api/tests/test_integration_sandbox.py (new)
change:
1. In `_EngineSandboxSessions.for_task`, when the loop finds no active session, call a new
`_provision(task)` instead of returning `""`. `_provision` does the following:
- Build `build_sandbox_service(self.session)` with a lazy `from app.application.container import ...`.
- Reuse the first non-`DESTROYED` workspace of the project, or create one. For a new one, take the
template from `SandboxTemplateRepository(self.session).list()`, preferring
`str(t.provider) == settings.sandbox_provider`, otherwise the first one. If there are no templates,
return `""`.
- Call `service.start_session(organization_id=task.organization_id, workspace_id=...)` and return
`external_session_id or ""`.
- On `AppError`, call `record_error(kind="engine.sandbox_provision_failed", organization_id=...,
metadata={"task_id", "project_id"})` and return `""`. Never swallow silently (rule 6).
2. Update the class docstring. It currently says "the engine does not create sandboxes". The integration
decision is that the engine creates the project's single workspace session on demand, because a fresh
org has no way to start one (sandbox mutations are owner-only).
3. New `tests/test_integration_sandbox.py`. Use the default mock sandbox provider and seeded templates
(confirm `seeded` creates `SandboxTemplateRow`s; otherwise insert one). Test that: a project with no
workspace gets one workspace + one RUNNING session and a non-empty id; a second call reuses it (no
second session); with no templates, it returns `""` and records an error.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_sandbox.py tests/test_workers.py tests/test_engine_flow.py
## T7 — end-to-end smoke: wizard → team → manager order → engine task → push → pipeline run queued
status: done
group: g1
after: T2, T3, T6
files: apps/api/tests/test_v2_e2e_smoke.py (new)
change:
1. One test that drives the v2 seams with the **real** builders. Fakes are allowed only for the model
(`tests.engine_fakes.ScriptedModel`), git (the default `GIT_WORKSPACE=fake` singleton
`app.application.container_delivery._fake_workspace`) and the mock sandbox/domain router (the
defaults).
- monkeypatch `app.application.container.get_model_provider` → `lambda: scripted`. The engine, memory
and bootstrap all resolve it lazily from `container`.
- As the platform owner, grant default models first (see `tests/test_model_grants.py` for the
`PUT /api/admin/default-model-grants/{model}` helper). Grant at least one model with `"reasoning"`
and one cheap one, so `bootstrap_team` can hire (read `ManagerTurns.bootstrap_team` for the tiers it
needs).
- Create a manager and complete the wizard over HTTP, reusing `_manager`/`_complete` from
`tests/test_org_wizard.py`. Do **not** monkeypatch `_call_hooks`.
- Assert: employees exist for the org (manager + team), `app_runtimes` has a row with
`demo_host == f"{slug}.{settings.preview_root_domain}"`, and the org has `org_model_grants` rows.
- Send a manager order through the same path as
`tests/test_engine_manager.py::test_an_order_becomes_tasks_with_pipeline_state`, with the scripted
reply holding one task tag. Assert that one `task_pipeline_state` exists.
- Set `_fake_workspace.diffs[task_id] = DiffResult(changed_files=["app/main.py"], text="--- a\n+++ b\n@@\n+x\n")`.
Queue the replies from `tests/test_engine_flow.py:286-294`, then call
`build_scheduler(session).tick()` with a commit after each, up to about 10 times, until the stage is
`COMPLETED`. Do not assert an exact model call count: the reflection (memory) also consumes a call,
and the default `WORK_REPORT` reply is fine.
- Assert: task status `done`, and `PipelineRunRepository.list_for_project` holds a run with
`trigger="engine"`, `branch="dev"` and the pushed sha.
2. Mark it with a module docstring in the style of `tests/test_e2e_smoke.py`. No network, docker or real
git.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_v2_e2e_smoke.py
## T8 — memory reflection runs only on a model granted to the org
status: done
group: g2
after: none
files: apps/api/app/application/services/memory_service.py:93-110,372-404, apps/api/app/application/container_org.py:45-63, apps/api/tests/test_memory_grants.py (new)
change:
1. `MemoryService.__init__`: add the optional kwarg `grants: ModelGrantPort | None = None` and store it.
2. `_reflect`: when `self.grants` is set, call `granted_models(organization_id=...)`. If nothing is
granted, call `record_error(kind="memory.reflection_no_grant", ...)` and return `""`. Otherwise pick
the first granted model with tier `fast`, then `balanced`, then any.
`model = await self.models.get(chosen.ai_model_id)`. If it is missing or inactive, record the error
and return `""`. When `grants` is None, keep the current `pick_for_mode(CHEAP)` path, so existing
direct constructions in `tests/test_memory.py` keep working.
3. `container_org.build_memory_service` passes `grants=build_model_grants(session)`.
4. Add tests to a new file `apps/api/tests/test_memory_grants.py`: an org with no grant → no provider
call and an error recorded; an org with a granted model → the provider receives that model.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_memory_grants.py tests/test_memory.py
## T9 — a caller for `expire_memories` (Agent 1 CHANGE REQUEST 2)
status: done
group: g2
after: none
files: apps/api/app/workers/billing.py:30-100, apps/api/app/workers/__main__.py:57-72, apps/api/tests/test_memory_expiry_worker.py (new)
change:
1. `BillingWorker`: add `async def expire_memories(self) -> WorkerOutcome`. Inside `worker_session()`,
call `MemoryRepository(session).expire(None)`, which is what `MemoryService.expire_memories`
delegates to and needs no model provider. Set `processed = <count>` and add a note.
2. `__main__._billing_pass.one_pass`: run `roll_up(...)`, then `expire_memories()`, and return one merged
`WorkerOutcome` (sum processed/skipped/failed, concatenate notes). Update the docstring of
`billing.py`. The README row is T18's job, not this task's.
3. Add `apps/api/tests/test_memory_expiry_worker.py`. Create an expired unpinned lesson and a pinned one
(see `tests/test_memory.py:440-460`), run `BillingWorker().expire_memories()`, and assert that only the
unpinned one is soft-deleted. If `worker_session` needs the test DB, follow how `tests/test_workers.py`
patches it.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_memory_expiry_worker.py tests/test_workers.py
## T10 — MCP `pipeline_status` reads Agent 3's delivery tables (Agent 1 CHANGE REQUEST 3)
status: done
group: g2
after: none
files: apps/api/app/interfaces/mcp/gateway.py:152-161,562-566, apps/api/tests/test_mcp.py:276-279
change:
1. `_pipeline_status`: resolve the org's single app via
`AppProfileRepository(session).get_for_org(identity.organization_id)`. If there is none, return
`{"available": False, "message": "اپلیکیشنی برای این سازمان یافت نشد."}`. Otherwise read
`AppRuntimeRepository.get_for_org`, the last 5 runs of
`PipelineRunRepository.list_for_project(organization_id=..., project_id=..., limit=5)`, and the
newest `ProductionDeployRepository.list_for_project` entry. Return `available: True`, plus
`demo_url`, `runtime_status`, and `runs: [{id, status, trigger, branch, sha[:12], error_summary,
created_at iso}]`, plus `last_production_deploy: {status, created_at} | None`. Never return ports,
container ids, hosts of deploy targets or log tails (rule 7).
2. Remove "در این نسخه در دسترس نیست" from the ToolSpec description.
3. In `tests/test_mcp.py` at about line 276, change the assertion: an org without an app still returns
`available is False`, and that test org may have no project, so keep it. Add a case that creates a
project + `app_runtimes` row + one `pipeline_runs` row and asserts `available is True` and one run,
with no `host_port` key anywhere in the payload.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_mcp.py
## T11 — one import surface + wizard hooks record their failures
status: done
group: g2
after: none
files: apps/api/app/application/container.py:18-26,168-182, apps/api/app/application/services/org_setup_service.py:383-425
change:
1. `container.py`: also import and re-export `build_memory_service`, `build_model_grant_service` (from
`container_org`) and `build_scheduler` (from `container_engine`), and add them to `__all__`
(Agent 1 CHANGE REQUEST 6).
2. `org_setup_service._copy_default_grants`: replace the hand-built `ModelGrantService(...)` with a lazy
`from app.application.container_org import build_model_grant_service` and
`build_model_grant_service(self.session)`.
3. `_call_hooks`: replace the two `logger.warning(...)` swallows with
`await record_error(kind="wizard.bootstrap_failed" | "wizard.on_app_created_failed", message=type(exc).__name__,
exc=exc, organization_id=organization_id, metadata={"project_id": project_id})`, using
`from app.infrastructure.error_log import record_error`. Then `await self.session.rollback()` so a
failed hook cannot leave the request session unusable for the next hook (rule 6). Do not put the
exception text into the message if it could carry a secret.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_foundation.py tests/test_org_wizard.py tests/test_model_grants.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/container.py app/application/services/org_setup_service.py
## T12 — post-login redirect to the wizard when `needs_onboarding` (Agent 1 CHANGE REQUEST 5, TODO 1.9)
status: done
group: g3
after: none
files: apps/web/src/app/login/page.tsx:28-44, apps/web/src/test/org-login-redirect.test.tsx (new)
change:
1. After `login(...)` succeeds, and in the "already logged in" effect, call `fetchNeedsOnboarding()`
from `@/lib/api-org`. If it returns true, use `router.replace("/onboarding")`; otherwise keep
`nextDestination("/dashboard")`. If the call fails, fall back to the current behaviour.
`AppShell.tsx` and `/dashboard` stay untouched (the dashboard is Agent 2's phase 5).
2. Test with vitest: mock `@/lib/session` (`login` resolves), `next/navigation` (`replace` spy) and
`@/lib/api-org` (`fetchNeedsOnboarding` → true, then false). Assert `/onboarding` vs `/dashboard`.
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-login-redirect.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T13 — settings: sharing section (pending invites, transfer ownership, leave) (Agent 1 TODO 1.10)
status: done
group: g3
after: none
files: apps/web/src/app/(app)/settings/_components/sharing-section.tsx (new), apps/web/src/app/(app)/settings/page.tsx:347-479, apps/web/src/test/org-settings-sharing.test.tsx (new)
change:
1. New component `SharingSection({ org, members, canManageMembers, onChanged })`:
- pending invites list (`orgApi.listOrgInvites`, filter `accepted_at == null`) with a revoke button
(`orgApi.revokeOrgInvite`, `ConfirmDialog`);
- owner only: "انتقال مالکیت". Pick an admin member, then a `Modal` that requires typing the org slug
before calling `orgApi.transferOrgOwnership(orgId, {new_owner_user_id, confirm: slug})`;
- "ترک سازمان" with a `ConfirmDialog`, calling `orgApi.leaveOrganization`, then `refresh()` from
`useSession` and a redirect to `/onboarding` if no org is left.
Persian/RTL, `@agy/ui` components only, errors through `ErrorState`, and a server 4xx shown as its
message.
2. Render it at the end of the `members` tab in `settings/page.tsx`, after the invite card at about
line 478.
3. Test: the invite list renders and revoke calls the API; the transfer button stays disabled until the
slug is typed; a non-owner sees no transfer section.
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-settings-sharing.test.tsx src/test/org-sharing.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T14 — Application page gates admin actions by org role (Agent 3 CHANGE REQUEST 3)
status: done
group: g3
after: none
files: apps/web/src/app/(app)/app/page.tsx:99-103, apps/web/src/test/delivery-app.test.tsx:82,282-308
change:
1. Replace `const canManage = Boolean(user);` with
`activeOrganization?.role === "owner" || activeOrganization?.role === "admin"`, which matches the
server's org admin/owner rule for re-run, rollback and production. Keep the comment that the server
is the boundary.
2. In `delivery-app.test.tsx`, set `ORG = { id: "org-1", slug: "acme", role: "admin" }`. Make the
"hides the deploy button…" case use `role: "member"`. All existing cases must pass.
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/delivery-app.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T15 — `memory.changed` in `@agy/contracts` (Agent 1 CHANGE REQUEST 4)
status: done
group: g3
after: none
files: packages/contracts/src/index.ts:816-838, apps/web/src/app/(app)/memory/page.tsx:139
change:
1. Add `| "memory.changed"` (with a `// v2 org platform` comment) at the end of the `WsEventType` union.
Do **not** add engine types (`task.step`, `task.gate`, `run.log`, `run.tool`, `team.changed`). Those
belong to Agent 2 phase 5, which may be editing this union, so check `git diff` first.
2. In `memory/page.tsx:139`, drop the `(event.type as string)` cast and compare `event.type ===
MEMORY_CHANGED_EVENT`. If TS complains about the literal, type `MEMORY_CHANGED_EVENT` in
`lib/api-org.ts:310` as `WsEventType`, but only if it is needed.
check: cd /projects/smart-org && cp packages/contracts/src/index.ts node_modules/@agy/contracts/src/index.ts && cd apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-memory.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T16 — tick Agent 1's TODO file
status: done
group: g3
after: T12, T13
files: docs/plan/TODO-agent-1-org-platform.md:25-120
change: Tick (`- [x]`) every box whose deliverable exists in code. Check each one with the endpoint and
file lists in `docs/plan/reports/agent-1-final.md` and with `ls`/`grep`. 1.9 and 1.10 are now done
(T12, T13). For items finished at integration, append a short note, e.g. `(expire hook: T9;
pipeline_status: T10)`. Leave unticked anything still missing and add `— open:` with the reason.
check: cd /projects/smart-org && grep -c "^- \[x\]" docs/plan/TODO-agent-1-org-platform.md && grep -n "^- \[ \]" docs/plan/TODO-agent-1-org-platform.md
## T17 — tick the foundation and Agent 3 TODO files
status: done
group: g4
after: none
files: docs/plan/TODO-0-foundation.md:9-60, docs/plan/TODO-agent-3-delivery-infra.md:34-169
change: Tick every box that is really done. Verify with `ls`/`grep` against
`docs/plan/reports/agent-3-final.md` §1/§4/§5 and the foundation commit (the stub routers in
`rpc_app.py`, `services/*/README.md`, `docs/plan/reports/.gitkeep`, the worktrees listed by
`git worktree list`). The foundation baseline counts are historical, so tick them with a note "(baseline
recorded at foundation time)". Leave anything not found unticked with `— open:`.
check: cd /projects/smart-org && grep -c "^- \[ \]" docs/plan/TODO-0-foundation.md docs/plan/TODO-agent-3-delivery-infra.md
## T18 — README: v2 runtime, real-mode env, workers
status: done
group: g4
after: none
files: README.md:233-293
change:
1. §8 table: `SANDBOX_PROVIDER` gains `docker`. Add rows for `GIT_WORKSPACE` (`fake` | `local`),
`ALLOW_CUSTOM_DOMAINS`, `DEPLOY_ALLOW_PRIVATE_HOSTS` and `MCP_GATEWAY_ENABLED`. Add one "real mode"
line: `SANDBOX_PROVIDER=docker GIT_WORKSPACE=local DOMAIN_ROUTER=caddy GIT_PROVIDER=gitea`, plus the
host requirements from `docs/plan/reports/agent-3-final.md` §2 (docker/podman, git ≥ 2.20, the caddy
import line, caddy reload permission).
2. §9: say "five processes" instead of three. Add `delivery` (`pnpm worker:delivery`: runs queued
pipeline runs and polls remotes), `ai-runtime` (now also one `Scheduler.tick()` per pass,
`ENGINE_TICK_SECONDS`), the memory-expiry step in the billing pass (T9), and the MCP gateway
(`pnpm mcp:dev`, `services/mcp-gateway/README.md`). Persian, same tone.
check: cd /projects/smart-org && grep -n "GIT_WORKSPACE\|worker:delivery\|mcp:dev\|docker" README.md
## T19 — PLAN-v2: integration status section
status: done
group: g4
after: none
files: docs/plan/PLAN-v2.md:270-281
change: After §8, add `## 9. Status (2026-10-14)`. It lists: §8.1–8.3 done (merges, single alembic
head `b9c0d1e2f3a4`, `alembic check` clean); §8.4 done through `todo-finish-plan.md` T1–T11, with the
three integration bugs fixed (double run, zero-wallet pause, no sandbox) and the e2e smoke test
`tests/test_v2_e2e_smoke.py`; the production-deploy leg of the smoke stays manual (agent-3-final §6
steps 7–8, `DEPLOY_IT=1`); the contract extension `ModelGrantPort.get_org_policy` (T1); and what is left
to Agent 2 phase 5 (the list at the end of this file). Tick each line only if the matching task in this
file is `status: done` when you write it.
check: cd /projects/smart-org && grep -n "## 9. Status" docs/plan/PLAN-v2.md
## T20 — manager CREATE_TASK falls back to the org's single project
status: done
group: prepare
after: none
files: apps/api/app/application/engine/manager.py (CREATE_TASK ~lines 533-548), apps/api/tests/test_integration_thread_project.py (new)
change: in CREATE_TASK use `project_id = thread.project_id or await self._project_id_for(organization_id)` for BOTH the Task and the pipeline state (`_project_id_for` exists ~line 1066; `hire` uses it ~line 322). Why: the real UI (apps/web/src/app/(app)/manager/page.tsx:166) creates threads without project_id so tasks got no worktree/sandbox. Test: create a thread through HTTP `POST /api/organizations/{org}/threads` without project_id (see tests/test_engine_manager.py for how orders are sent), post an order with a scripted task tag, assert the created task and its pipeline state carry the org's project id.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_thread_project.py tests/test_engine_manager.py
## T21 — pass has_active_reviewer to resolve_pipeline
status: done
group: prepare
after: T20
files: apps/api/app/application/engine/manager.py (calls ~line 549 and ~720), apps/api/tests/test_integration_review_fallback.py (new)
change: compute `has_active_reviewer = any(e.role_key is RoleKey.REVIEWER and e.is_available for e in await self.employees.list_for_org(org_id))` (adapt to the real attribute names) and pass it as `has_active_reviewer=` in both calls (resolve_pipeline in domain/workforce/pipeline.py ~line 403 already falls back without a reviewer). Do NOT add a reviewer to the bootstrap team. Test: an org with the bootstrap team (no reviewer) and a refactor-kind task never lands on a pipeline with a review stage; with a reviewer it does.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_review_fallback.py tests/test_engine_manager.py tests/test_engine_pipeline.py
## T22 — the scheduler tick must not starve behind skipped tasks
status: done
group: prepare
after: none
files: apps/api/app/workers/ai_runtime.py (~line 128), apps/api/tests/test_integration_runtime.py
change: call `scheduler.tick(limit=500)` instead of `max(limit, 10)` (list_at_stages caps at 500; _select still limits real work via engine_max_concurrent_turns/engine_max_turns_per_org). Add a test: 12 switched-off or unassignable states for org A must not stop org B's task from being picked in one tick (use FakeGrants(policy=DefaultOrgPolicy(automation_enabled=False)) style from tests/test_integration_policy.py, or the simplest honest setup).
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_runtime.py tests/test_workers.py tests/test_integration_policy.py
## T23 — fired employees must free a hire slot
status: done
group: prepare
after: none
files: apps/api/app/infrastructure/repositories/workforce.py (EmployeeRepository.count_for_org ~203-207), apps/api/tests/test_integration_hire_cap.py
change: exclude ARCHIVED employees (`EmployeeRow.status != str(EmployeeStatus.ARCHIVED)`; confirm the real enum/column names). Add a test: after firing one employee at the cap, a hire succeeds. Leave the default cap value untouched (product decision, not ours).
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_hire_cap.py tests/test_engine_manager.py tests/test_workforce.py
## T24 — tenant isolation for project_id used by the engine
status: done
group: prepare
after: T20
files: apps/api/app/application/services/workforce_service.py (create_thread ~324-339, create_task ~410-437), apps/api/app/application/container_engine.py (_EngineSandboxSessions.for_task and _provision ~117-127), apps/api/tests/test_integration_tenant_project.py (new)
change: in create_thread/create_task, when project_id is given and is not the caller org's project, raise NotFoundError (404; use the project of the org via the existing app profile / project repository). In for_task and _provision filter workspaces by `w.organization_id == task.organization_id`. Test: org A cannot create a thread or task with org B's project id (404), and for_task never returns org B's session for an org A task.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_integration_tenant_project.py tests/test_integration_sandbox.py tests/test_workforce.py tests/test_engine_flow.py
## T25 — login redirect must keep an explicit `next` (pending invites)
status: done
group: prepare
after: none
files: apps/web/src/app/login/page.tsx, apps/web/src/test/org-login-redirect.test.tsx
change: in landingAfterSignIn check `next` first (`const next = nextDestination(""); if (next) return next;` — adapt to the real helper) and only then the needs_onboarding check. Add a test case: with `?next=/accept-invite?token=x` and needs_onboarding true it still goes to the next URL. Web check command style above (org-login-redirect.test.tsx).
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-login-redirect.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T26 — make the e2e smoke test closer to reality
status: done
group: prepare
after: T20, T21
files: apps/api/tests/test_v2_e2e_smoke.py
change: (a) create the thread through HTTP without project_id instead of ThreadRow(project_id=...); (b) drive the engine through `AiRuntimeWorker().drain()` (conftest already patches worker_base.SessionFactory) instead of build_scheduler(...).tick(), still looping until COMPLETED; (c) assert a SandboxSessionRow exists for the org's project; (d) keep REVIEW out of the replies only if the default pipeline still needs it.
check: cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_v2_e2e_smoke.py
## T27 — docs truth
status: done
group: prepare
after: T20-T26 are done in the todo file
files: docs/plan/PLAN-v2.md (§9 status section), docs/plan/TODO-agent-3-delivery-infra.md (item 6.5)
change: in PLAN-v2 §9 mark §8.4 done through T1-T27 (tick T6 and T7; mention the review fixes: project fallback, reviewer fallback, tick starvation, archived headcount, tenant check, login next) — only write "done" for tasks whose status in the todo file is `done` at that moment. In TODO-agent-3 item 6.5 remove the claim that no non-admin-deploy-button test exists (T14 added it); only log batching/auto-scroll remain open.
check: cd /projects/smart-org && grep -n "T27\|## 9. Status" docs/plan/PLAN-v2.md
---
## Left to Agent 2 (do not touch — the live phase-5 session owns these)
- `apps/web/src/lib/api-engine.ts`, `(app)/manager/**`, `(app)/tasks/**` (board + `/tasks/[id]`: live log,
gates, diff), `(app)/workforce/**`, `(app)/dashboard/**`, `(app)/traces/**`, `src/test/engine-*.test.tsx`,
the engine event types in `@agy/contracts` (`task.step`, `task.gate`, `run.log`, `run.tool`, `team.changed`),
ticking `docs/plan/TODO-agent-2-ai-workforce.md`, and `docs/plan/reports/agent-2-final.md`.
- Decisions to hand to Agent 2 / the user (not planned here):
- Tasks created directly (HTTP `POST /tasks`, MCP `tasks_create`) get **no** pipeline state and still
run through the legacy one-shot `start_run`. Agent 1 TODO 5.3 says MCP `tasks_create` should "go to
the manager as a user order". Decide whether direct tasks enter the engine (create a
`TaskPipelineState` at `PENDING_MANAGER_REVIEW`) and whether `WorkforceService.start_run` should refuse
a task that owns a pipeline state (the "اجرا" button).
- A `paused` task (budget or user decision) has no resume path in the API yet. The board will need one.
## Not planned (noted only)
- `DeployTargetSpec` field additions (`post_deploy_command`, ids): Agent 1 phase-1 CR. Nothing is broken.
- `infra/docker/docker-compose.yml` has no worker or MCP services. The delivery worker needs the host
docker socket, so running workers on the host (README §9) stays the documented path.
- MCP rate limiter and `mcp_gateway_enabled` are process-local (Agent 1 known limitations).
- Disk pruning of `runs/`, `artifacts/`, `ftp-backup-*` (Agent 3 known limitations).