Files
smart-org/todo-v2-final.md
T
mjalmoudy94 d532929189 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
2026-10-14 23:19:05 +03:30

905 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 && cd ../web && node /projects/smart-org/node_modules/vitest/vitest.mjs run && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
# Smart Org v2 — last work (prepaid credit, direct tasks in the engine, Agent 2 phase 5 UI, cleanup)
Base: branch `dev`, HEAD `fef5a9d`. `todo-finish-plan.md` (T1–T27) is done and must NOT be edited.
Single alembic head today: `b9c0d1e2f3a4`. The one new migration in this file (T1) uses
`down_revision = "b9c0d1e2f3a4"`; check `ls apps/api/alembic/versions` first, and if a newer head exists, chain
off it instead.
Binding product decisions (from the owner): D-A cap 7, D-B prepaid billing, D-C build phase 5 UI here,
D-D direct tasks enter the engine + pause/resume/cancel, D-E housekeeping. Rules for every task: tenant
isolation (every query filtered by `organization_id`, a foreign id is a 404), no model/provider names for
non-owners, no secret in a response/log/event, Persian/RTL UI with `@agy/ui`, no silent `except Exception`,
never weaken an existing test (update it only where a decision below changes the behaviour, and say so in its
docstring). Do NOT commit.
What the code has today (checked, so it is not planned again, only used):
- Engine events already published: `run.log`, `run.tool` (`engine/events.py::RunFeed`, on `run:`, `task:` and
`org:`), `task.gate` (`task:` + `org:`), `team.changed` (`org:`), `task.created` (manager CREATE_TASK).
**`task.step` is not emitted anywhere** (T4 adds it). Stage moves have one choke point:
`app/application/engine/state.py::advance` (used by `Scheduler._move` and the manager).
- There are **no** engine read endpoints: `routers/workforce.py` has only the legacy task/run/employee
routes (no stage, turns, gates, diff, tier, role+tier hire). T17–T19 add a new router `routers/engine.py`.
- The diff is never stored (the worktree is gone after merge) and the pushed sha is not stored on the task
(T1–T3 add `last_diff` and `merged_sha` to `task_pipeline_state`).
- Billing: `UsageLedgerRepository.record` (`repositories/audit.py:115`) is the single place every usage row
is written (model calls via `record_model_usage`, sandbox, memory reflection). Nothing debits a wallet.
`CreditWalletRepository.adjust` is a Python read-modify-write (not atomic). There is no credit history
table. **Free-credit holes:** `POST /organizations/{org}/billing/add-credit` lets any org admin with
`billing.manage` add positive credit, and `POST .../payments/{id}/settle` lets the same admin settle a
pending payment as succeeded (= free credit). Both must become owner-only under prepaid (T12).
- `Scheduler._budget_reason` (`engine/scheduler.py:622-649`) pauses only on `balance < 0` and treats a
missing wallet as "no budget" (T7 reverts). `ManagerTurns.chat` never checks credit (T8).
- `WorkforceService.create_task` (`workforce_service.py:435`) makes no pipeline state, so HTTP `POST /tasks`
and MCP `tasks_create` (`interfaces/mcp/gateway.py:451`, calls `create_task`) run through the legacy
one-shot `start_run` (T14). `PAUSED → PENDING_MANAGER_REVIEW` is a legal transition, but no API exists (T16).
- Web: `lib/api-engine.ts` is an empty stub; `/manager`, `/tasks`, `/tasks/[id]`, `/workforce`,
`/workforce/[id]`, `/dashboard`, `/traces` are the pre-v2 pages (legacy task/run views, routing-policy
select, no stage/tier). `/billing` shows add-credit + checkout to org admins. `/admin/organizations` and
`/admin/model-grants` exist; there is no owner credit page.
- D-E verified: `tests/test_integration_thread_project.py:69-70` and `tests/test_v2_e2e_smoke.py:134-136` say
the HTTP message route builds its manager without pipeline states (stale since
`tests/test_integration_order_route.py`); `apps/web/src/lib/api-org.ts:303-310` says the event is not in
`@agy/contracts` (it is, since T15 of the old plan). `docs/plan/reports/agent-3-final.md`: line 25 and
§6 step 9 (lines 160-161) claim an MCP system route the owner can enable (only a read-only suggestion
exists: `GET /api/admin/domains/suggested`, and the label `mcp` is refused); §5 line 128 lists a
`system_routes` table (no such table in `models_delivery.py`); there is no sandbox restart claim in the
final report itself — the restart gap is only in `TODO-agent-3` 6.3 / phase-6 report, so T42 adds one
explicit "no restart" line to §8 instead of deleting a claim.
Shared test commands (abbreviated below as PYTEST / MYPY / RUFF / WEB):
- PYTEST = `cd /projects/smart-org/apps/api && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider`
- MYPY = `PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy`
- RUFF = `PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/ruff check`
- WEB = `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`
- TSC = `node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json`
The `check:` lines below are written out in full.
---
## T1 — migration + ORM: `credit_transactions`, `task_pipeline_state.last_diff/merged_sha`, policy default 7
status: done
group: prepare
after: none
files: apps/api/app/infrastructure/db/models_billing.py (new), apps/api/app/infrastructure/db/models.py:1026-1028, apps/api/app/infrastructure/db/models_engine.py:51-93, apps/api/alembic/versions/c4d5e6f7a8b9_prepaid_credit_and_engine_ui.py (new)
change:
1. New `models_billing.py` (use `pk()`/`created()` from `app.infrastructure.db.columns`, never import
`db/models.py`): `CreditTransactionRow`, table `credit_transactions`: `id` pk; `organization_id`
FK `organizations.id` ondelete CASCADE, not null, indexed; `kind` String(16) not null
(`owner_credit | starting_credit | usage | adjustment`); `amount` Float not null (signed: credit > 0,
debit < 0); `balance_after` Float not null; `reason` String(500) default ""; `actor_user_id` String(32)
nullable; `usage_event_id` String(32) nullable, **unique** (idempotency of a usage debit);
`created_at` created(). `Index("ix_credit_tx_org_created", "organization_id", "created_at")`.
2. `models.py`: add `from app.infrastructure.db import models_billing as _models_billing # noqa: E402,F401`
next to the three existing lines (1026-1028).
3. `models_engine.py::TaskPipelineStateRow`: add `last_diff: Text, default "", nullable=False` (latest
accepted diff, capped by the writer) and `merged_sha: String(64), default "", nullable=False`.
4. Migration `c4d5e6f7a8b9`, `down_revision = "b9c0d1e2f3a4"` (verify it is still the single head):
create `credit_transactions` (+ unique on `usage_event_id`, the index), add the two columns with
`server_default=""`, and `alter_column("org_policies", "max_employees", server_default="7")` (was "5",
D-A). Downgrade reverses all three (server_default back to "5"). Use `batch_alter_table` for SQLite.
check: cd /projects/smart-org/apps/api && rm -f _alembic_check.db && export DATABASE_URL=sqlite+aiosqlite:///./_alembic_check.db PYTHONPATH=$PWD && A=/home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/alembic && $A upgrade head && $A check && $A downgrade b9c0d1e2f3a4 && $A upgrade head && $A heads && rm -f _alembic_check.db && unset DATABASE_URL && /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/pytest -q -p no:cacheprovider tests/test_foundation.py tests/test_engine_pipeline.py
## T2 — domain + repositories: new state fields, org turn list, paused counts
status: done
group: prepare
after: T1
files: apps/api/app/domain/workforce/pipeline.py:425-450, apps/api/app/infrastructure/repositories/engine.py:64-108,128-140,396-412, apps/api/tests/test_engine_repo_ui.py (new)
change:
1. `TaskPipelineState` dataclass: add `last_diff: str = ""` and `merged_sha: str = ""` (after `waiver_reason`).
2. `repositories/engine.py`: map both in `_state`; add both to `_PATCHABLE`; set them in `create` if it lists
columns explicitly.
3. `EngineTurnRepository.list_for_org(organization_id, *, limit=50)`: join `TaskRow` on
`EngineTurnRow.task_id == TaskRow.id`, filter `TaskRow.organization_id`, newest `started_at` first,
`limit` capped at 200.
4. `PipelineStateRepository.count_at_by_org(stage) -> dict[str, int]` (group by organization_id) and
`stage_counts(organization_id) -> dict[str, int]` (group by stage for one org).
5. Tests: patch + read back `last_diff`/`merged_sha`; `list_for_org` never returns another org's turns;
`stage_counts` and `count_at_by_org` on two orgs.
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_repo_ui.py tests/test_engine_pipeline.py tests/test_engine_flow.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/infrastructure/repositories/engine.py app/domain/workforce/pipeline.py
## T3 — the scheduler stores the latest diff and the merged sha
status: done
group: prepare
after: T2
files: apps/api/app/application/engine/scheduler.py:399-447,479-545, apps/api/tests/test_engine_ui_fields.py (new)
change:
1. `_after_implementation`: in the existing `_patch(state, last_diff_hash=..., same_diff_count=...)` call
also pass `last_diff=diff.text[:20000]` (one write, one version bump). Only after the diff guard passed.
2. `_merge_step`: right before `await self._move(state, task, Stage.COMPLETED)`, call
`await self._patch(state, merged_sha=final_sha)`.
3. Test (import `world`, `_team`, `_task`, `_scheduler` and the scripted replies from
`tests.test_engine_flow`, like `tests/test_integration_policy.py` does): run the happy path to COMPLETED
and assert `state.last_diff` contains the fake diff text and `state.merged_sha` equals the sha the fake
workspace pushed.
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_ui_fields.py tests/test_engine_flow.py tests/test_integration_policy.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/scheduler.py
## T4 — publish `task.step` on every stage move
status: done
group: prepare
after: none
files: apps/api/app/application/engine/events.py:186-207, apps/api/app/application/engine/state.py:38-64, apps/api/tests/test_engine_step_event.py (new)
change:
1. `events.py`: add `async def emit_task_step(*, task_id, organization_id, from_stage, to_stage, pipeline)`
publishing `RealtimeEvent(type="task.step", channels=(task_channel(task_id), org_channel(organization_id)),
organization_id=..., data={"task_id", "from": from_stage, "to": to_stage, "pipeline": pipeline,
"status": coarse_status(to_stage).value})`. Add it to `__all__`. No model/employee names in the payload.
2. `state.advance`: after a successful `states.advance(...)`, call `emit_task_step` with the returned state's
`organization_id` and `pipeline`. Publishing never raises (`publish` swallows), so the move is unaffected.
3. Test: install a recording publisher with `set_publisher` (restore the old one in a `finally`; see how
`tests/test_realtime.py` captures events), advance one state, assert exactly one `task.step` event on
`task:<id>` + `org:<id>` with the right `from`/`to`; a refused (illegal) move publishes nothing.
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_step_event.py tests/test_engine_pipeline.py tests/test_engine_manager.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/events.py app/application/engine/state.py
## T5 — credit ledger repository (atomic credit/debit, history) + a test helper to fund an org
status: done
group: prepare
after: T1
files: apps/api/app/domain/billing/models.py:62-85, apps/api/app/infrastructure/repositories/credit.py (new), apps/api/tests/billing_helpers.py (new), apps/api/tests/test_credit_ledger.py (new)
change:
1. Domain: `CreditKind(StrEnum)`: `owner_credit`, `starting_credit`, `usage`, `adjustment`; dataclass
`CreditTransaction(organization_id, kind, amount, balance_after, reason="", actor_user_id=None,
usage_event_id=None, id, created_at)`.
2. `CreditLedgerRepository(session)`:
- `_ensure_wallet(org)`: create a 0.0 `CreditWalletRow` when missing (same as `CreditWalletRepository.ensure`).
- `apply(*, organization_id, amount, kind, reason="", actor_user_id=None, usage_event_id=None)
-> CreditTransaction`: **atomic SQL update** `update(CreditWalletRow).where(organization_id == org)
.values(balance=CreditWalletRow.balance + amount, updated_at=utcnow())` (never read-modify-write in
Python), then read the new balance and insert the transaction row with `balance_after`; flush.
- `debit_for_usage(*, organization_id, usage_event_id, amount) -> CreditTransaction | None`: returns None
when `amount <= 0` or a row with this `usage_event_id` already exists (idempotent); otherwise
`apply(amount=-amount, kind=usage, usage_event_id=...)`. Catch `IntegrityError` only around a nested
savepoint (`session.begin_nested()`) so a duplicate race returns None instead of poisoning the session.
- `history(organization_id, *, kinds: tuple[CreditKind, ...] | None = None, limit=50)` newest first,
limit capped at 200, always filtered by org.
- `last_credit_at_by_org() -> dict[str, datetime]` (max created_at of non-usage rows, group by org).
3. `tests/billing_helpers.py`: `async def fund_org(session_factory, organization_id, amount=100.0)` that opens
a session, calls `CreditLedgerRepository.apply(kind=adjustment, reason="test funding")` and commits.
Later tasks import it to fund orgs whose tests need engine work under prepaid.
4. Tests: credit then debit gives the right `balance_after`; the same `usage_event_id` twice debits once;
org B's wallet never moves when org A is debited; a missing wallet is created at 0 then credited.
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_credit_ledger.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/infrastructure/repositories/credit.py app/domain/billing/models.py
## T6 — every usage event debits the wallet (single place: `UsageLedgerRepository.record`)
status: done
group: prepare
after: T5
files: apps/api/app/infrastructure/repositories/audit.py:109-133, apps/api/app/application/services/workforce_service.py:103-153, apps/api/tests/test_credit_debit.py (new)
change:
1. `UsageLedgerRepository.record`: give the `UsageEventRow` an explicit id (`new_id()`), flush, then in the
**same session** (same transaction = atomic with the usage row) call
`CreditLedgerRepository(self.session).debit_for_usage(organization_id=usage.organization_id,
usage_event_id=row.id, amount=total)`. Return `float | None` (the new balance, None when nothing was
debited). Update the class docstring: prepaid — the ledger is still the source of truth and every row
debits the wallet exactly once. Keep the `UsageLedgerPort` signature compatible (returning a value from a
`-> None` protocol method is fine for callers that ignore it; update the port annotation in
`ports/audit.py` only if mypy demands it).
2. `record_model_usage`: when `record` returns a balance, also `await emit_wallet_changed(
organization_id=..., balance=...)` so the sidebar badge and the billing page follow the debit live.
3. Tests: a model usage of price P on an org with balance B leaves B-P and one `usage` transaction whose
`usage_event_id` is the usage row id; a zero-priced row creates no transaction; a sandbox usage (via
`SandboxService` path or a direct `record`) debits too; another org is untouched; a rolled-back session
leaves neither the usage row nor the debit.
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_credit_debit.py tests/test_credit_ledger.py tests/test_workforce.py tests/test_engine_runner.py tests/test_e2e_smoke.py tests/test_workers.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/infrastructure/repositories/audit.py app/application/services/workforce_service.py
## T7 — prepaid pause: a zero, negative or missing wallet pauses engine work (D-B)
status: done
group: prepare
after: T3, T5
files: apps/api/app/application/engine/scheduler.py:89,618-649, apps/api/tests/test_engine_flow.py:88-112,536-601, apps/api/tests/test_v2_e2e_smoke.py
change:
1. `_budget_reason`: `wallet = await self.wallets.get_for_org(org)`; return `BUDGET_PAUSED_MESSAGE` when
`wallet is None or wallet.balance <= 0`. Rewrite the docstring: billing is **prepaid**; the platform owner
credits the wallet, every usage row debits it (T6), so zero means "no credit left". Set
`BUDGET_PAUSED_MESSAGE = "اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد."`.
Keep the per-task cap and the monthly budget checks unchanged.
2. `tests/test_engine_flow.py`: keep `_OverdrawnWallet` + its test; rename
`test_a_zero_wallet_does_not_pause_a_fresh_org` → `test_a_zero_wallet_pauses_the_task_before_a_turn_is_spent`
asserting `PAUSED`, `WAITING_APPROVAL`, `scripted.calls == 0` (docstring: prepaid decision D-B reverses the
post-paid behaviour of the old T3); add `test_a_missing_wallet_pauses_the_task` (`_Wallets(None)`) and
`test_a_funded_wallet_does_not_pause` (a `_FundedWallet` with `balance = 5.0`, one tick, stage not PAUSED).
Update the module docstring bullet at line 17.
3. `tests/test_v2_e2e_smoke.py`: right after the wizard completes, `await fund_org(session_factory, org_id,
1000.0)` (from `tests.billing_helpers`) with a comment "prepaid: the owner credits a new org before work".
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 tests/test_v2_e2e_smoke.py tests/test_integration_policy.py tests/test_integration_runtime.py tests/test_error_log.py tests/test_workers.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/scheduler.py
## T8 — the manager chat does not call a model on a zero-credit org
status: done
group: prepare
after: T5, T6
files: apps/api/app/application/engine/manager.py:208-240,383-420, apps/api/app/application/container_engine.py:197-240, apps/api/app/application/services/workforce_service.py:392-428, apps/api/tests/test_credit_chat_gate.py (new)
change:
1. `ManagerTurns.__init__`: optional kwarg `wallets: CreditWalletRepository | None = None`, stored.
2. `chat()`: after storing the user message and before any model call, when `self.wallets` is set and the
org's wallet is missing or `balance <= 0`, store an assistant message
`CHAT_NO_CREDIT_MESSAGE = "اعتبار سازمان صفر است؛ مدیر پس از افزودن اعتبار توسط مالک سامانه پاسخ می‌دهد."`
and return `(user_message, assistant_message)` without calling the provider and without executing tags.
Export the constant.
3. Pass `wallets=CreditWalletRepository(session)` in `container_engine._build_manager` and in
`WorkforceService._manager_turns` (session = `self.tasks.session`). The bootstrap builder (line ~59) needs
nothing (it never chats).
4. Test: an org at 0.0 → the reply is the note and `ScriptedModel.calls == 0`; after `fund_org(...)` the
model is called once. Existing direct `ManagerTurns(...)` constructions (no `wallets`) stay unaffected.
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_credit_chat_gate.py tests/test_engine_manager.py tests/test_integration_review_fallback.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/manager.py app/application/container_engine.py app/application/services/workforce_service.py
## T9 — fund the chat tests for prepaid + fix the two stale test comments (D-E)
status: done
group: prepare
after: T7, T8
files: apps/api/tests/test_integration_order_route.py, apps/api/tests/test_integration_thread_project.py:60-80, apps/api/tests/test_e2e_smoke.py, apps/api/tests/test_v2_e2e_smoke.py:130-140
change:
1. In each test that chats with the manager on an org created over HTTP or by the wizard (wallet 0.0), call
`fund_org(session_factory, org_id)` before the order, with a one-line comment (prepaid, D-B). Do not
change any assertion. If `test_e2e_smoke.py` has no `session_factory` fixture in that test, add it to the
test signature.
2. Stale comments: `test_integration_thread_project.py:69-70` and `test_v2_e2e_smoke.py:134-136` claim the HTTP
message route builds its manager without pipeline states. Rewrite them: the HTTP route builds states too
(`WorkforceService._manager_turns`, covered by `tests/test_integration_order_route.py`); these tests call
`build_scheduler(session).manager.chat` directly only to read the reply/state in the same session.
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_order_route.py tests/test_integration_thread_project.py tests/test_e2e_smoke.py tests/test_v2_e2e_smoke.py && ! grep -n "without pipeline states" tests/test_integration_thread_project.py tests/test_v2_e2e_smoke.py
## T10 — `CreditService`: owner credit, history, balances
status: done
group: prepare
after: T2, T5
files: apps/api/app/application/services/credit_service.py (new), apps/api/tests/test_credit_service.py (new)
change:
1. `CreditService(session)` with `ledger = CreditLedgerRepository`, `wallets = CreditWalletRepository`,
`orgs = OrganizationRepository`, `states = PipelineStateRepository`.
- `MAX_OWNER_CREDIT = 100_000.0`.
- `owner_credit(*, organization_id, amount, reason, actor_user_id) -> CreditTransaction`: org must exist
(`NotFoundError("سازمان یافت نشد.")`); `0 < amount <= MAX_OWNER_CREDIT` else `ValidationError`;
`reason.strip()` 3..500 chars else `ValidationError("دلیل افزودن اعتبار لازم است.")`;
`ledger.apply(kind=owner_credit, ...)`; then `emit_wallet_changed(organization_id, balance_after)`.
- `history(organization_id, limit=50)`: non-usage kinds only (owner_credit, starting_credit, adjustment).
- `balances() -> list[OrgBalance]` (dataclass: organization_id, name, slug, balance, currency,
paused_tasks, last_credit_at): every org (`list_all`), its wallet (0.0 when missing),
`states.count_at_by_org(Stage.PAUSED)`, `ledger.last_credit_at_by_org()`.
- `grant_starting_credit(organization_id)`: when `settings.billing_starting_credit > 0` (added by T13; read
with `getattr(settings, "billing_starting_credit", 0.0)` so this task does not depend on T13) apply
`kind=starting_credit, reason="اعتبار آغازین"`; otherwise no-op.
2. Tests: credit → balance + one row; amount 0 / negative / above max / blank reason refused; unknown org 404;
history excludes usage rows and other orgs; balances lists two orgs with their own numbers and paused counts.
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_credit_service.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/services/credit_service.py
## T11 — owner-only credit endpoints `/api/admin/credits`, `/api/admin/orgs/{org}/credit`
status: done
group: prepare
after: T10
files: apps/api/app/interfaces/http/routers/admin_credits.py (new), apps/api/app/interfaces/realtime/rpc_app.py:23-42,96-103, apps/api/tests/test_admin_credits.py (new)
change:
1. Router `APIRouter(prefix="/admin", tags=["admin-credits"], dependencies=[Depends(require_platform_owner)])`
(same pattern as `admin_grants.py`):
- `GET /credits` → `list[OrgBalanceOut]` (organization_id, name, slug, balance, currency, paused_tasks,
last_credit_at).
- `POST /orgs/{org_id}/credit` body `{amount: float, reason: str}` → `CreditTransactionOut` (id, kind,
amount, balance_after, reason, created_at). Audit with `AuditAction.BILLING_CREDIT`,
`organization_id=org_id`, `actor_user_id=owner.id`, `target_type="credit_wallet"`, metadata
`{amount, reason, balance}` and `client_meta(request)`.
- `GET /orgs/{org_id}/credit-history?limit=50` → `list[CreditTransactionOut]`.
No model/provider field, no secret.
2. Register it in `rpc_app.py` (import + `include_router(admin_credits.router, prefix="/api")` in the v2 block).
3. Tests (fixture `platform_owner_token` from conftest; a manager-created org): owner credit → 200, wallet
balance, transaction row, an audit row with the reason, a `billing.wallet` event (recording publisher);
history lists it; list shows the org with its balance; a manager / org owner (non platform owner) gets 403
on all three; an unknown org id 404; amount ≤ 0 and empty reason → 4xx with a Persian message.
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_admin_credits.py tests/test_foundation.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/admin_credits.py app/interfaces/realtime/rpc_app.py
## T12 — close the free-credit holes in the tenant billing router
status: done
group: prepare
after: T5
files: apps/api/app/interfaces/http/routers/billing.py:1-30,208-300, apps/api/tests/test_billing_prepaid.py (new)
change:
1. `POST /billing/add-credit`: any amount now requires `context.user.is_platform_owner`, otherwise
`PermissionDeniedError("اعتبار سازمان را فقط مالک سامانه افزایش می‌دهد.")`. For the owner, write through
`CreditLedgerRepository.apply(kind=adjustment, reason="billing/add-credit", actor_user_id=...)` instead of
`BillingService.add_credit` so it appears in the credit history, keep the existing audit, and emit
`emit_wallet_changed`. Keep refusing a result below zero for a negative amount.
2. `POST /billing/payments/{id}/settle`: platform owner only (same message). `POST /checkout` stays as is
(it only creates a pending payment). Update the module docstring: prepaid, the owner credits wallets.
3. Tests: org owner/admin with `billing.manage` → 403 on add-credit (positive) and settle; platform owner →
200 and one `adjustment` transaction; `GET /billing/summary` still works for members with `billing.view`.
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_billing_prepaid.py tests/test_tenant_isolation.py tests/test_e2e_smoke.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/billing.py
## T13 — optional starting credit for new orgs (`BILLING_STARTING_CREDIT`, default 0)
status: done
group: prepare
after: T10
files: apps/api/app/core/config.py:125-200, .env.example:90-145, apps/api/app/application/services/org_setup_service.py:205-230, apps/api/app/interfaces/http/routers/organizations.py:140-150, apps/api/tests/test_billing_starting_credit.py (new)
change:
1. `config.py`: add a new marked block `# >>> integration (billing)` … `# <<< integration` after the
agent-3 block with `billing_starting_credit: float = 0.0` (comment: credit given to every new org at
creation, prepaid; 0 = the owner credits by hand). Same block and line `BILLING_STARTING_CREDIT=0` in
`.env.example`.
2. Wizard (`org_setup_service.py`, after the `CreditWalletRow(...)` add at ~line 223) and
`organizations.py` (after line 147): `await self.session.flush()` / `await session.flush()` then
`await CreditService(session).grant_starting_credit(org.id)` (lazy import to avoid cycles).
3. Tests: with the setting at 0 a new org (HTTP and wizard) has balance 0 and no transaction; with
`monkeypatch.setattr(settings, "billing_starting_credit", 3.0)` both paths give 3.0 and one
`starting_credit` row.
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_billing_starting_credit.py tests/test_org_wizard.py tests/test_tenant_isolation.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/core/config.py app/application/services/org_setup_service.py app/interfaces/http/routers/organizations.py
## T14 — direct tasks enter the engine; the legacy "اجرا" refuses engine tasks (D-D)
status: done
group: prepare
after: T8
files: apps/api/app/application/services/workforce_service.py:435-470,500-520, apps/api/tests/legacy_tasks.py (new), apps/api/tests/test_workforce.py, apps/api/tests/test_direct_tasks_engine.py (new)
change:
1. `create_task`: after `self.tasks.create(...)`, create a `TaskPipelineState` with
`PipelineStateRepository(self.tasks.session)`: `stage=PENDING_MANAGER_REVIEW`, `kind=TaskKind.FEATURE`,
`risk=Risk.MEDIUM`, `pipeline=resolve_pipeline(manager_choice=None, task_kind=TaskKind.FEATURE,
has_active_reviewer=False)` (the manager's triage picks the real pipeline), `project_id` = the given one or
the org's single project (mirror `ManagerTurns._project_id_for`, manager.py:1071; store it on the task too).
This is the "user order to the manager": the scheduler's triage turn takes it from there. The docstring
says so. MCP `tasks_create` calls this method, so it is covered without touching the gateway.
2. `start_run`: when `PipelineStateRepository(session).get(task_id)` is not None, raise
`ConflictError("این کار را موتور خودکار اجرا می‌کند؛ برای توقف، ادامه یا لغو از دکمه‌های همین کار استفاده کنید.")`.
3. `tests/legacy_tasks.py`: `async def legacy_task(session_factory, organization_id, created_by_user_id, *,
title="کار قدیمی", project_id=None, assigned_employee_id=None) -> str` inserting a plain `TaskRow` (no
state): pre-v2 tasks still exist and still run through the legacy path.
4. `tests/test_workforce.py`: every test whose purpose is the legacy run (`POST /tasks/{id}/runs`, lines
~229-318) creates its task with `legacy_task(...)` instead of HTTP; keep every assertion.
5. New `tests/test_direct_tasks_engine.py`: HTTP `POST /tasks` creates a state at `pending_manager_review`
with the org's project; `POST /tasks/{id}/runs` on it → 409; MCP `tasks_create` (see `tests/test_mcp.py`
`_call` helper) also creates a state; a legacy task still runs.
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_direct_tasks_engine.py tests/test_workforce.py tests/test_integration_runtime.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/services/workforce_service.py
## T15 — update the remaining tests that relied on direct tasks running the legacy way
status: done
group: prepare
after: T9, T14
files: apps/api/tests/test_workers.py, apps/api/tests/test_e2e_smoke.py, apps/api/tests/test_seed.py, apps/api/tests/test_error_log.py, apps/api/tests/test_pagination.py, apps/api/tests/test_realtime.py, apps/api/tests/test_integration_tenant_project.py, apps/api/tests/test_mcp.py
change:
1. Run the full API suite first and list the failures (paste the list in your report; it must not be empty
if any of these files posts `/tasks` then expects a legacy run/drain).
2. For each failing test: if its purpose is the legacy run/drain/metering, create the task with
`tests.legacy_tasks.legacy_task(...)`; if its purpose is task creation, keep HTTP and assert the new
pipeline state instead of a legacy run. Never delete an assertion; explain each change in the test's
docstring (decision D-D).
3. `tests/test_mcp.py`: in the existing `tasks_create` test add an assertion that the created task has a
`TaskPipelineState` at `pending_manager_review`.
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
## T16 — `Scheduler.user_action`: pause, resume, cancel, answer
status: done
group: prepare
after: T7
files: apps/api/app/application/engine/scheduler.py:586-650, apps/api/app/infrastructure/repositories/workforce.py:483-512, apps/api/tests/test_engine_control.py (new)
change:
1. `TaskRepository.append_description(task_id, text) -> Task | None` (narrow write, like `set_status`).
2. `Scheduler.user_action(*, organization_id, task_id, action: Literal["pause","resume","cancel","answer"],
user_id: str, answer: str = "") -> TaskPipelineState`:
- `task = await self.tasks.get_for_org(organization_id, task_id)` else `NotFoundError("کار یافت نشد.")`;
`state = await self.states.get(task_id)` else `ConflictError("این کار در موتور خودکار نیست.")`;
a terminal stage → `ConflictError("این کار بسته شده است.")`.
- pause: `_move(state, task, Stage.PAUSED)` (illegal transitions surface as the existing `ConflictError`).
- resume: only from `PAUSED` (else ConflictError "فقط کار متوقف‌شده را می‌توان ادامه داد."); if
`await self._budget_reason(task)` is not None raise `ConflictError(reason)` (no credit → no resume);
then `_move(..., Stage.PENDING_MANAGER_REVIEW)`.
- cancel: `_move(..., Stage.CANCELLED)` (already releases the worktree and parks dependents).
- answer: only from `PENDING_USER_DECISION`; `answer.strip()` 1..4000 chars; append
`"\n\nپاسخ کاربر: " + answer` to the description; if `task.thread_id`, store a USER message in the thread
via `self.manager.messages`; then `_move(..., Stage.PENDING_MANAGER_REVIEW)` (triage reads the answer).
- After every move `emit_task_status(task_id, organization_id, coarse_status(target).value)`.
Return the re-read state.
3. Tests (reuse `tests.test_engine_flow` helpers; pass a funded `_Wallets`): pause → PAUSED; resume →
PENDING_MANAGER_REVIEW; resume with a zero wallet → ConflictError and still PAUSED; cancel → CANCELLED
and the fake workspace's `remove_worktree` was called when a worktree existed; answer from
PENDING_USER_DECISION moves to triage and the description contains the answer; org B id → NotFoundError.
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_control.py tests/test_engine_flow.py tests/test_workforce.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/application/engine/scheduler.py app/infrastructure/repositories/workforce.py
## T17 — engine read API: status, task board, task detail, recent turns
status: done
group: prepare
after: T2, T3, T11
files: apps/api/app/interfaces/http/routers/engine.py (new), apps/api/app/interfaces/realtime/rpc_app.py, apps/api/tests/test_engine_api.py (new)
change:
1. `router = APIRouter(prefix="/organizations/{org_id}/engine", tags=["engine"])`; every route resolves
`get_org_context` (any member may read) and filters by `context.org_id`. Register in `rpc_app.py`.
2. `GET /status` → `EngineStatusOut {balance, currency, credit_ok (wallet exists and balance > 0),
automation_enabled, max_employees, team_size (non-archived employees), stage_counts: dict[str,int]}`
(policy from `build_model_grants(session).get_org_policy`).
3. `GET /tasks?stage=&limit=100` → `list[EngineTaskOut] {id, title, status, stage|null, pipeline|null,
kind|null, cycle, max_cycles, thread_id, assigned_employee_id, assignee_name, created_at, updated_at}`;
tasks via `TaskRepository.list_for_org`, states via `PipelineStateRepository.list_for_org` (dict by
task id); legacy tasks have `stage=null`.
4. `GET /tasks/{task_id}` → `EngineTaskDetailOut` = `EngineTaskOut` + `description`, `stages: list[str]`
(the stage list of its pipeline from `PIPELINES`), `waiver_reason`, `branch`, `depends_on`, `merged_sha`,
`diff: str` (`state.last_diff`), `turns: [{id, run_id, role, mode, stage, employee_name, ok, error_kind,
tool_calls, reply_excerpt (first 1000 chars), started_at, finished_at}]`, `gates: [{id, gate, ok, detail,
created_at}]`, `runs: [{id, status, started_at, finished_at}]`. Foreign task → 404.
5. `GET /turns?limit=50` → recent turns of the org (`EngineTurnRepository.list_for_org`) with `task_title`.
6. No field can hold a model id/name, provider, account, container or port (state it in the module
docstring, like `workforce.py`). Tests: org B member gets 404 on every path; a member (viewer) can read;
serialised JSON of every endpoint contains none of the seeded `AiModelRow.name`/`provider` values nor
`ai_model_id`; detail returns stages, gates, turns, diff and sha for a task built with the repositories.
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_api.py tests/test_tenant_isolation.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/engine.py app/interfaces/realtime/rpc_app.py
## T18 — engine action API: pause / resume / cancel / answer
status: done
group: prepare
after: T16, T17
files: apps/api/app/interfaces/http/routers/engine.py, apps/api/app/domain/audit/models.py:58-68, apps/api/tests/test_engine_api_actions.py (new)
change:
1. `AuditAction.TASK_CONTROL = "task.control"`.
2. `POST /tasks/{task_id}/pause|resume|cancel` and `POST /tasks/{task_id}/answer {content: str (1..4000)}`:
`context.require(CAN_RUN_TASKS)`, `await build_scheduler(session).user_action(...)` (lazy import from
`app.application.container`), audit `TASK_CONTROL` with metadata `{action}` (never the answer text), return
the refreshed `EngineTaskOut`.
3. Tests: member with CAN_RUN_TASKS pauses then resumes (org funded with `fund_org`); resume on a zero wallet →
409 with the Persian reason; cancel → stage cancelled + audit row; answer only from
pending_user_decision (else 409); viewer → 403; org B → 404.
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_api_actions.py tests/test_engine_api.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/engine.py app/domain/audit/models.py
## T19 — engine team API: team with tier, hire (role + tier), fire
status: done
group: prepare
after: T18
files: apps/api/app/interfaces/http/routers/engine.py, apps/api/tests/test_engine_api_team.py (new)
change:
1. `GET /team` → `TeamOut {employees: [{id, name, role_key, status, tier ("fast"|"balanced"|"strong"|
"unknown"), busy_task_id, busy_task_title, hired_by}], tiers: [{tier, label}] (distinct tiers of the org's
granted models, labels «سریع/متعادل/قوی»), max_employees, count}`. Tier = the tier of the profile's
`ai_model_id` in `grants.granted_models(org)`; not granted → `unknown`. Archived employees excluded.
2. `POST /team/hire {role: RoleKey (HIRABLE_ROLES), tier, name?}` (`CAN_MANAGE_EMPLOYEES`) →
`build_scheduler(session).manager.hire(organization_id, role, tier, name, project_id=None)`; audit
`EMPLOYEE_CREATE`; returns the team member row. `POST /team/{employee_id}/fire` → `manager.fire(...)`;
audit `EMPLOYEE_UPDATE` with `{action: "fire"}`. Hire/fire errors keep their Persian messages (409/404).
3. Tests: hire with a granted tier → 201, the response has `tier` and no model id/name anywhere; a tier that
is not granted → 409; the cap equals `ModelGrantService.get_org_policy(org).max_employees` (do not hard-code
5 or 7); firing the last manager → 409; member without CAN_MANAGE_EMPLOYEES → 403; org B → 404.
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_api_team.py tests/test_engine_api.py tests/test_engine_manager.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/mypy app/interfaces/http/routers/engine.py && PYTHONPATH=$PWD /home/mamad/work/smart-org-wt/agent1/apps/api/.venv/bin/ruff check app tests
## T20 — `@agy/contracts`: engine event types + payloads
status: done
group: prepare
after: T4
files: packages/contracts/src/index.ts:816-842
change:
1. Append to `WsEventType` (comment `// v2 AI workforce engine`): `"task.step" | "task.gate" | "run.log" |
"run.tool" | "team.changed"`.
2. Add payload interfaces mirroring the backend `data` key-for-key: `TaskStepEventData {task_id; from; to;
pipeline; status}`, `TaskGateEventData {task_id; gate; ok; detail}`, `RunLogEventData {run_id; task_id;
lines: string[]}`, `RunToolEventData {run_id; task_id; tool; ok; summary}`, `TeamChangedEventData
{change; employee_id; organization_id}`.
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/typescript/bin/tsc --noEmit -p tsconfig.json && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-memory.test.tsx
## T21 — `lib/api-engine.ts`: typed client for the engine API
status: done
group: prepare
after: T17, T18, T19, T20
files: apps/web/src/lib/api-engine.ts, apps/web/src/test/engine-api.test.ts (new)
change:
1. Exported types mirroring T17–T19 exactly: `EngineStage` (string union of the 13 stages), `EngineStatus`,
`EngineTask`, `EngineTaskDetail`, `EngineTurn`, `EngineGate`, `EngineRun`, `TeamMember`, `TierOption`,
`TeamView`, `HireRequest`. No field named model/provider.
2. `engineApi` (keep the `satisfies` line): `status(orgId)`, `listTasks(orgId, stage?)`, `getTask(orgId, id)`,
`listTurns(orgId, limit?)`, `pauseTask`, `resumeTask`, `cancelTask` (POST, no body), `answerTask(orgId, id,
content)`, `getTeam(orgId)`, `hire(orgId, body)`, `fire(orgId, employeeId)`; all through `apiRequest`.
3. Test: `vi.mock("@/lib/api", ...)` capturing `apiRequest` calls; assert path + method + body for each function.
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/engine-api.test.ts && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T22 — `lib/engine-labels.ts`: stage labels, board columns, tier labels
status: done
group: prepare
after: none
files: apps/web/src/lib/engine-labels.ts (new), apps/web/src/test/engine-labels.test.ts (new)
change:
1. `STAGE_LABELS: Record<string, string>` (Persian for all 13 stages), `stageTone(stage)` → Badge tone
(success completed, danger failed/cancelled, warning paused/pending_user_decision/error_review, info else).
2. `BOARD_COLUMNS` in this order with labels: بررسی مدیر (pending_manager_review), تحلیل (pending_analysis),
پیاده‌سازی (pending_implementation), بازبینی (pending_review, pending_final_review), تست (pending_test),
ادغام (merge_request), انجام‌شده (completed, cancelled, failed), نیازمند تصمیم (pending_user_decision,
error_review, paused). `columnFor(stage: string | null, status: string)`: legacy tasks (stage null) map by
coarse status (new→بررسی مدیر, running→پیاده‌سازی, waiting_approval→نیازمند تصمیم, done/failed/cancelled→
انجام‌شده). `groupByColumn<T extends {stage; status}>(tasks)`.
3. `TIER_LABELS` (fast سریع, balanced متعادل, strong قوی, unknown «نامشخص»), `ROLE_LABELS` (manager مدیر,
planner برنامه‌ریز, developer برنامه‌نویس, reviewer بازبین, tester آزمونگر).
4. Test: every stage lands in exactly one column; legacy mapping; grouping keeps order.
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/engine-labels.test.ts && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T23 — shared `TaskActions` (pause / resume / cancel with confirm)
status: done
group: prepare
after: T21, T22
files: apps/web/src/components/engine/task-actions.tsx (new), apps/web/src/test/engine-task-actions.test.tsx (new)
change:
1. `TaskActions({ orgId, task: {id, stage}, canRun, onChanged, size? })`: renders «توقف» (non-terminal,
not paused), «ادامه» (paused), «لغو» (non-terminal); each opens `ConfirmDialog` (Persian text, «لغو» marked
destructive) and calls `engineApi.pauseTask/resumeTask/cancelTask`; a server error shows its Persian
message inline (`ApiError.message`); renders nothing when `canRun` is false or `stage` is null.
2. Test: buttons per stage; confirm calls the API and `onChanged`; a 409 message is shown; nothing for a
legacy task.
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/engine-task-actions.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T24 — shared `CreditNotice` + `useEngineStatus`
status: done
group: prepare
after: T21
files: apps/web/src/components/engine/credit-notice.tsx (new), apps/web/src/test/engine-credit-notice.test.tsx (new)
change:
1. `useEngineStatus(orgId)`: loads `engineApi.status`, reloads on `billing.wallet`, `task.step` and
`team.changed` on `org:<id>` (`useLiveChannel`), returns `{status, loading, error, reload}`.
2. `CreditNotice({ status })`: when `status && !status.credit_ok` a warning `Card`:
«اعتبار سازمان به پایان رسیده است» + «کار کارمندان هوش مصنوعی تا افزودن اعتبار توسط مالک سامانه متوقف است.
پس از افزودن اعتبار، کارهای متوقف‌شده را از صفحهٔ وظایف ادامه دهید.» and the balance; otherwise nothing.
3. Test: renders the message at 0 and nothing when `credit_ok`; the hook reloads on a `billing.wallet` event
(mock `@/lib/realtime` like `src/test/org-mcp.test.tsx` does).
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/engine-credit-notice.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T25 — default per-org employee cap = 7 (D-A)
status: done
group: g1
after: none
files: apps/api/app/application/services/model_grant_service.py:49, apps/api/app/infrastructure/db/models_org.py:146, apps/api/app/infrastructure/repositories/org_grants.py:42, apps/api/tests/test_model_grants.py:332,395, apps/web/src/app/(app)/admin/model-grants/page.tsx:70
change:
1. `DEFAULT_MAX_EMPLOYEES = 7`; `OrgPolicyRow.max_employees` `default=7`; `OrgPolicyRecord.max_employees = 7`.
`settings.engine_max_employees` (20) stays the upper bound (`min(DEFAULT, limit)` is unchanged).
The migration server default is T1's job (already "7").
2. `tests/test_model_grants.py:332` and `:395` assert 7 (docstring note: D-A). Add one assertion in an
existing policy test that 4 (the bootstrap team) ≤ the default cap.
3. Web: `React.useState("5")` → `"7"` at `admin/model-grants/page.tsx:70`.
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_model_grants.py tests/test_integration_hire_cap.py tests/test_engine_manager.py tests/test_org_wizard.py && cd ../web && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && grep -rn "DEFAULT_MAX_EMPLOYEES = 7" ../api/app/application/services/model_grant_service.py
## T26 — `lib/api-credit.ts`: owner credit client
status: done
group: g2
after: T11
files: apps/web/src/lib/api-credit.ts (new), apps/web/src/test/billing-api-credit.test.ts (new)
change:
1. Types `OrgBalance`, `CreditTransaction` (mirror T11) and `creditApi = { balances(), credit(orgId, {amount,
reason}), history(orgId, limit?) }` via `apiRequest` on `/api/admin/credits`, `/api/admin/orgs/{id}/credit`,
`/api/admin/orgs/{id}/credit-history`.
2. Test: paths, methods and bodies (mock `@/lib/api`).
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/billing-api-credit.test.ts && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T27 — owner page `/admin/credits` + admin tab
status: done
group: g2
after: T26
files: apps/web/src/app/(app)/admin/credits/page.tsx (new), apps/web/src/lib/nav.ts:31-46, apps/web/src/test/billing-admin-credits.test.tsx (new)
change:
1. Page (the admin layout already blocks non-owners): `Table` of orgs (نام، شناسه، اعتبار، کارهای متوقف،
آخرین افزایش), balance ≤ 0 shown with a danger `Badge` «بدون اعتبار». Per row «افزودن اعتبار» opens a `Modal`
with amount (number > 0) + reason (required, 3+ chars) and a confirm step; on success refresh the row and
show a toast. «تاریخچه» opens a `Drawer` with `creditApi.history` (date fa-IR, amount, reason, balance after).
Live: reload on `billing.wallet` from `admin` channel if available; otherwise reload after each action only.
2. `nav.ts` `ADMIN_ITEMS`: add `{ href: "/admin/credits", label: "اعتبار سازمان‌ها", hint: "شارژ پیش‌پرداخت" }`
after the organizations entry.
3. Test: rows render with balances; submit is disabled until amount and reason are valid; submit calls
`creditApi.credit` with both; history drawer lists entries.
check: cd /projects/smart-org/apps/web && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/billing-admin-credits.test.tsx src/test/command-palette.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T28 — members: billing page is read-only + credit notice; wizard success says work starts after credit
status: done
group: g2
after: T24, T27
files: apps/web/src/app/(app)/billing/page.tsx:150-270, apps/web/src/app/onboarding/page.tsx:362-380, apps/web/src/test/billing-prepaid.test.tsx (new)
change:
1. Billing page: the «افزایش اعتبار» card (add-credit + checkout, lines ~226-270) renders only for
`user.is_platform_owner`; everyone else sees a `CardHint` «اعتبار سازمان به صورت پیش‌پرداخت توسط مالک سامانه
افزوده می‌شود. هر مصرف از همین اعتبار کم می‌شود.». Render `CreditNotice` (status from `useEngineStatus`) at
the top. Keep all existing widgets and the live wallet handling.
2. Onboarding success screen: below the existing hint, when `engineApi.status(result.organization.id)` says
`!credit_ok` (or the call fails), show «کار کارمندان پس از افزودن اعتبار توسط مالک سامانه آغاز می‌شود.».
3. Test: a member sees no add-credit button and sees the hint; the owner sees the form; the notice shows at
balance 0. Keep `src/test/org-wizard.test.tsx` green (mock `engineApi.status` there only if it breaks).
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/billing-prepaid.test.tsx src/test/org-wizard.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T29 — manager components: inline task cards + pending-decision banner
status: done
group: g3
after: T21, T22, T24
files: apps/web/src/app/(app)/manager/_components/task-cards.tsx (new), apps/web/src/app/(app)/manager/_components/decision-banner.tsx (new), apps/web/src/test/engine-manager.test.tsx (new)
change:
1. `ThreadTaskCards({ tasks })`: compact cards (title, stage `Badge` with `STAGE_LABELS`/`stageTone`, link
`/tasks/{id}`) for the tasks of the active thread.
2. `DecisionBanner({ orgId, tasks, onAnswered })`: for tasks at `pending_user_decision` a warning card
«در انتظار تصمیم شما» with the task title, a textarea and «ارسال پاسخ» → `engineApi.answerTask`; errors
inline in Persian; hidden when there is none.
3. Test: cards show the Persian stage; the banner appears only for pending decisions and posts the answer.
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/engine-manager.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T30 — wire the manager page (cards inline, banner, credit notice, live stages)
status: done
group: g3
after: T29
files: apps/web/src/app/(app)/manager/page.tsx, apps/web/src/test/engine-manager-page.test.tsx (new)
change:
1. Load `engineApi.listTasks(orgId)` next to the existing loads; render `CreditNotice` + `DecisionBanner` at
the top and `ThreadTaskCards` (tasks with `thread_id === activeThreadId`) under the messages.
2. Live: on `task.created` reload the engine task list; on `task.step` patch the task's `stage`/`status` in
place (data is `TaskStepEventData`). Keep `message.created` handling as is.
3. The «کار جدید» form keeps `api.createTask`; its hint says the task goes to the AI manager first.
4. Test (mock api, api-engine, realtime, session): a `task.step` event moves a card's badge; the banner shows
for a pending decision; the notice shows at zero credit.
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/engine-manager-page.test.tsx src/test/engine-manager.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T31 — task board component (columns by stage, filters, live)
status: done
group: g4
after: T20, T22, T23
files: apps/web/src/app/(app)/tasks/_components/task-board.tsx (new), apps/web/src/test/engine-board.test.tsx (new)
change:
1. `TaskBoard({ orgId, tasks, employees, canRun, onChanged })`: columns from `BOARD_COLUMNS` (horizontal scroll
on mobile, RTL), each card: title link, stage badge, assignee name, cycle `n/max`, `TaskActions`.
Filters above: text search on title, assignee select, «فقط کارهای فعال» checkbox.
2. Live: subscribe `org:<id>`; `task.step` moves the card to `columnFor(to)`; `task.created` calls `onChanged`.
3. Test: grouping into the 8 columns (a fixture with every stage + one legacy task); a `task.step` event
moves a card; the filter hides non-matching cards.
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/engine-board.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T32 — wire `/tasks` to the board
status: done
group: g4
after: T31
files: apps/web/src/app/(app)/tasks/page.tsx
change:
1. Replace the list/status filter with `TaskBoard` fed by `engineApi.listTasks(orgId)` + `api.listEmployees`.
Keep the create form (`api.createTask`), with the hint «کار جدید ابتدا به مدیر AI سپرده می‌شود.», and
reload after create. `CreditNotice` at the top.
2. Run the board test and add one case to `src/test/engine-board.test.tsx` only if the page needs it.
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/engine-board.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T33 — task detail: pipeline stepper + turn timeline
status: done
group: g5
after: T21, T22
files: apps/web/src/app/(app)/tasks/[id]/_components/pipeline-stepper.tsx (new), apps/web/src/app/(app)/tasks/[id]/_components/turn-timeline.tsx (new), apps/web/src/test/engine-task-detail.test.tsx (new)
change:
1. `PipelineStepper({ stages, current })`: the pipeline's stages in order with Persian labels; done stages
check-marked, current highlighted; a side state (paused/pending_user_decision/error_review/cancelled/
failed) shown as a badge beside the stepper.
2. `TurnTimeline({ turns })`: `Timeline` items «{ROLE_LABELS[role]} — {employee_name}», stage label, time
fa-IR, outcome (موفق / خطا: error_kind), tool call count; the reply excerpt in a collapsible block.
3. Test: current stage highlighted; timeline renders roles in Persian; a fixture turn carrying an unexpected
`model: "gpt-secret-x"` field never renders that text.
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/engine-task-detail.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T34 — task detail: live log panel
status: done
group: g5
after: T20
files: apps/web/src/app/(app)/tasks/[id]/_components/live-log.tsx (new), apps/web/src/test/engine-live-log.test.tsx (new)
change:
1. `LiveLog({ taskId })`: subscribes `task:<taskId>` via `useLiveChannel`; a `run.log` event appends all its
`lines` in ONE state update; a `run.tool` event appends one line `«ابزار {tool}: موفق|ناموفق — summary»`;
keeps the last 2000 lines; monospace, `dir="ltr"` for the log body.
2. Auto-scroll to bottom on new lines unless paused: a «توقف پیمایش / ادامهٔ پیمایش» toggle, and scrolling up
by hand pauses it too. A «پاک کردن» button.
3. Test: one event with 3 lines renders 3 rows (batched: one render per event); a tool event renders; with
scroll paused the container's `scrollTop` is not changed; the 2000-line cap.
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/engine-live-log.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T35 — task detail: gates panel + diff viewer + waiver + pushed sha link
status: done
group: g5
after: T21
files: apps/web/src/app/(app)/tasks/[id]/_components/gates-panel.tsx (new), apps/web/src/app/(app)/tasks/[id]/_components/diff-viewer.tsx (new), apps/web/src/test/engine-gates-diff.test.tsx (new)
change:
1. `GatesPanel({ gates, waiverReason })`: one row per gate (Persian names: tests تست‌ها، diff_guard نگهبان
تغییرات، no_progress بدون پیشرفت، green دروازهٔ سبز، lint لینت), green/red badge, time, expandable `<pre>`
detail; when `waiverReason` a warning box «صرف‌نظر از تست با دلیل: …».
2. `DiffViewer({ diff, mergedSha })`: read-only, `dir="ltr"`, lines starting `+` green, `-` red, `@@` muted;
empty → «هنوز تغییری ثبت نشده است.». When `mergedSha`: «ادغام و ارسال شد: {sha.slice(0,12)}» with a link
to `/app` («مشاهدهٔ خط لوله در صفحهٔ اپلیکیشن»).
3. Test: red gate expands to its detail; waiver shown; diff coloring classes; sha link.
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/engine-gates-diff.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T36 — wire `/tasks/[id]` (engine view for engine tasks, legacy view kept)
status: done
group: g5
after: T23, T33, T34, T35
files: apps/web/src/app/(app)/tasks/[id]/page.tsx, apps/web/src/test/engine-task-page.test.tsx (new)
change:
1. Load `engineApi.getTask(orgId, taskId)`. When `stage` is not null render: header with stage badge +
`TaskActions`, an answer box when `pending_user_decision` (`engineApi.answerTask`), `PipelineStepper`,
`LiveLog`, `GatesPanel`, `DiffViewer`, `TurnTimeline`. **Hide the legacy «اجرا» button** for engine tasks.
2. Legacy tasks (stage null) keep the current runs/approvals view unchanged.
3. Live: `task.step` and `task.gate` on `task:<id>` reload the detail (debounced 500 ms).
4. Test: an engine task shows the stepper and no «اجرا»; a legacy task still shows «اجرا»; a `task.gate`
event triggers a reload.
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/engine-task-page.test.tsx src/test/engine-task-detail.test.tsx src/test/engine-live-log.test.tsx src/test/engine-gates-diff.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T37 — workforce components: team panel + hire dialog (role + tier only)
status: done
group: g6
after: T21, T22
files: apps/web/src/app/(app)/workforce/_components/team-panel.tsx (new), apps/web/src/app/(app)/workforce/_components/hire-dialog.tsx (new), apps/web/src/test/engine-workforce.test.tsx (new)
change:
1. `TeamPanel({ orgId, team, canManage, onChanged })`: rows with name (link `/workforce/{id}`), role label,
tier label, status badge, current task link (`busy_task_title`), «حذف از تیم» with `ConfirmDialog` →
`engineApi.fire`; header «{count} از {max_employees} نفر».
2. `HireDialog({ orgId, tiers, onHired })`: role select (manager/planner/developer/reviewer/tester), tier select
from `team.tiers`, optional name → `engineApi.hire`; disabled when at the cap or no tier is granted
(«مالک سامانه هنوز مدلی برای این سازمان تخصیص نداده است.»).
3. Test: render with a fixture whose employee carries extra fields `ai_model_id: "m-123"` and
`model_name: "gpt-secret-x"` and assert neither string is in `document.body.textContent`; hire posts
role + tier; fire asks for confirm first.
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/engine-workforce.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T38 — wire `/workforce` to the team panel
status: done
group: g6
after: T37
files: apps/web/src/app/(app)/workforce/page.tsx
change:
1. Replace the template/routing-based create form and list with `TeamPanel` + `HireDialog` fed by
`engineApi.getTeam(orgId)`; `canManage` = `CAN_MANAGE_EMPLOYEES`. Reload on `team.changed` (`org:<id>`).
2. Remove imports that become unused (`api.listAgentTemplates`, `api.createEmployee`) from this page only.
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/engine-workforce.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T39 — `/workforce/[id]`: tier instead of routing policy for non-owners
status: done
group: g6
after: T21
files: apps/web/src/app/(app)/workforce/[id]/page.tsx:55-140,240-260, apps/web/src/test/engine-workforce-detail.test.tsx (new)
change:
1. Show the employee's tier label (from `engineApi.getTeam`) and role label. The routing-policy select (and
the `api.listRoutingChoices` call) renders only for `user.is_platform_owner`; for everyone else the update
payload omits `routing_policy_id`.
2. Test: a non-owner sees the tier and no routing select and no routing choice names; the owner sees the select.
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/engine-workforce-detail.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T40 — dashboard engine widgets
status: done
group: g7
after: T21, T22, T24
files: apps/web/src/app/(app)/dashboard/_components/engine-widgets.tsx (new), apps/web/src/app/(app)/dashboard/page.tsx:100-125, apps/web/src/test/engine-dashboard.test.tsx (new)
change:
1. `EngineWidgets({ orgId })`: `CreditNotice` + balance `Stat`; «کارهای فعال» (up to 5 non-terminal engine
tasks, stage badge, link); «توزیع مراحل» (one row per `BOARD_COLUMNS` column with a count bar from
`status.stage_counts`); «آخرین کارهای انجام‌شده» (5 newest completed). Live via `useEngineStatus` + reload
of `engineApi.listTasks` on `task.step`.
2. Render it at the top of the dashboard page; keep every existing widget.
3. Test: counts per column, active list, completed list, notice at zero credit.
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/engine-dashboard.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T41 — traces: recent engine turns widget
status: done
group: g7
after: T21, T22
files: apps/web/src/app/(app)/traces/_components/engine-turns.tsx (new), apps/web/src/app/(app)/traces/page.tsx:100-200, apps/web/src/test/engine-traces.test.tsx (new)
change:
1. `EngineTurns({ orgId })`: `engineApi.listTurns(orgId, 30)` as a `Table` (کار link, نقش, مرحله, نتیجه
badge, ابزارها, مدت), reload on `task.step` (`org:<id>`).
2. Add it as a section of the traces page; keep the existing run/audit widgets working.
3. Test: rows render with Persian role/stage; a failed turn shows its error kind; no model text.
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/engine-traces.test.tsx && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json
## T42 — D-E: stale web comment + agent-3 final report overstatements
status: done
group: g8
after: none
files: apps/web/src/lib/api-org.ts:299-311, docs/plan/reports/agent-3-final.md:25,101,125-129,159-161,193-216
change:
1. `api-org.ts`: rewrite the comment above `MEMORY_CHANGED_EVENT`: the type is part of `WsEventType` in
`@agy/contracts`; the constant only names it. Type it `as const satisfies WsEventType` if that compiles,
otherwise leave the value untouched.
2. `agent-3-final.md` (verify each against code first: `admin_domains.py::suggested_routes`,
`models_delivery.py` tables, `sandbox.py` admin router):
- line 25 and line 101: say the MCP gateway host is a read-only **suggestion**, not a route the owner can
enable (the label `mcp` is protected).
- §5: remove `system_routes` from the table list (the tables are `pipeline_runs`, `pipeline_steps`,
`releases`, `app_runtimes`, `production_deploys`; MCP lives as a suggestion computed from settings).
- §6 step 9: replace "Enable the MCP system route; `mcp.<root>` must answer" with "check that
`GET /api/admin/domains/suggested` shows `mcp.<root>` as a suggestion (enabled=false)".
- §8: add "`/admin/sandbox` lists and stops containers; there is no restart action (TODO 6.3 open)".
check: cd /projects/smart-org && ! grep -n "system_routes\|Enable the MCP" docs/plan/reports/agent-3-final.md && grep -n "restart" docs/plan/reports/agent-3-final.md && cd apps/web && node /projects/smart-org/node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && node /projects/smart-org/node_modules/vitest/vitest.mjs run src/test/org-memory.test.tsx
## T43 — Agent 2 final report + tick Agent 2's TODO
status: done
group: g8
after: none
files: docs/plan/reports/agent-2-final.md (new), docs/plan/TODO-agent-2-ai-workforce.md:41-155
change:
1. Run this task after the other groups (it describes what exists; verify everything with `ls`/`grep`, never
from this plan). `agent-2-final.md` per the TODO's "Final report" section: summary; a text diagram
(order → manager chat → task + state → tick: triage → planner → developer → gates → tester → final →
merge/push → on_branch_pushed → pipeline run); the tag protocol table (role/mode → tags, from
`engine/protocol.py`); every endpoint added or changed (`routers/engine.py` T17–T19, `admin_credits.py`
T11, the billing lockdown T12, `POST /tasks` creating a state and `start_run` 409 T14); tables
(`task_pipeline_state` incl. `last_diff`/`merged_sha`, `employee_profiles`, `engine_turns`, `gate_results`,
`credit_transactions`); settings (`ENGINE_*`, `BILLING_STARTING_CREDIT`); realtime events (`task.step`,
`task.gate`, `run.log`, `run.tool`, `team.changed`); CHANGE REQUESTs: none open (`get_org_policy` landed
as old T1, memory expiry as old T9); manual QA (owner credits an org → order → board moves live → task page
log/gates/diff → pause/resume/cancel → zero credit pauses and resume is refused → hire/fire by tier with no
model names); known limitations (turns sequential per tick, the diff is capped at 20,000 chars, budget-paused
tasks are resumed by hand after credit is added, invoices from the billing worker are informational under
prepaid).
2. TODO-agent-2: tick each box whose deliverable exists in code (phases 1–4 and phase 5 items 5.1–5.7 when
their files and tests exist); anything missing stays `- [ ]` with `— open: <reason>`.
check: cd /projects/smart-org && test -s docs/plan/reports/agent-2-final.md && grep -c "^- \[x\]" docs/plan/TODO-agent-2-ai-workforce.md && grep -n "^- \[ \]" docs/plan/TODO-agent-2-ai-workforce.md; grep -n "task.step\|admin/orgs\|credit_transactions" docs/plan/reports/agent-2-final.md
## T44 — PLAN-v2 §9, README (prepaid, new env, engine UI)
status: done
group: g8
after: T43
files: docs/plan/PLAN-v2.md:282-305, README.md:12-48,233-314
change:
1. PLAN-v2 §9: replace the line "Zero-wallet pause fixed (T3): only an overdrawn wallet pauses a task" with
the prepaid decision (D-B: owner credits, usage debits once per usage event, zero/negative/missing wallet
pauses turns and the manager chat). Replace the "Left to Agent 2 phase 5" block with a ticked list pointing
at `todo-v2-final.md` (UI, contracts, direct tasks through the engine, pause/resume/cancel, cap 7,
owner credit console). Tick a line only if its task here is `status: done` at that moment.
2. README: §1 one paragraph «اعتبار پیش‌پرداخت» (the owner credits each org at `/admin/credits`; work stops at
zero); §8 table row `BILLING_STARTING_CREDIT` (default 0); §9 note that the ai-runtime pass pauses tasks of
orgs without credit. Persian, same tone.
check: cd /projects/smart-org && grep -n "پیش‌پرداخت\|BILLING_STARTING_CREDIT" README.md && grep -n "todo-v2-final\|prepaid" docs/plan/PLAN-v2.md
---
## final
Every task above edits one to four small files (plus its own new test), so no further split is needed:
**final**.
| id | group | title |
|---|---|---|
| T1 | prepare | migration + ORM: `credit_transactions`, `last_diff`/`merged_sha`, policy default 7 |
| T2 | prepare | domain + repositories: state fields, org turns list, paused/stage counts |
| T3 | prepare | scheduler stores the latest diff and the merged sha |
| T4 | prepare | publish `task.step` on every stage move |
| T5 | prepare | credit ledger repository (atomic credit/debit, history) + `fund_org` test helper |
| T6 | prepare | every usage event debits the wallet once (`UsageLedgerRepository.record`) |
| T7 | prepare | prepaid pause: zero/negative/missing wallet pauses engine turns |
| T8 | prepare | manager chat refuses to call a model at zero credit |
| T9 | prepare | fund the chat tests + fix the two stale test comments |
| T10 | prepare | `CreditService`: owner credit, history, balances, starting credit |
| T11 | prepare | owner-only `/api/admin/credits` + `/api/admin/orgs/{org}/credit` (+history) |
| T12 | prepare | close the free-credit holes in `/billing/add-credit` and `/settle` |
| T13 | prepare | optional `BILLING_STARTING_CREDIT` for new orgs |
| T14 | prepare | direct tasks (HTTP + MCP) get a pipeline state; legacy `start_run` refuses them |
| T15 | prepare | update the remaining tests that relied on the legacy direct-task path |
| T16 | prepare | `Scheduler.user_action`: pause/resume/cancel/answer |
| T17 | prepare | engine read API: status, board, detail, turns |
| T18 | prepare | engine action API: pause/resume/cancel/answer (audited) |
| T19 | prepare | engine team API: tier view, hire by role+tier, fire |
| T20 | prepare | contracts: engine event types + payloads |
| T21 | prepare | `lib/api-engine.ts` typed client |
| T22 | prepare | `lib/engine-labels.ts` (stages, columns, tiers, roles) |
| T23 | prepare | shared `TaskActions` with confirm |
| T24 | prepare | shared `CreditNotice` + `useEngineStatus` |
| T25 | g1 | default employee cap 7 (backend, tests, admin page default) |
| T26 | g2 | `lib/api-credit.ts` owner credit client |
| T27 | g2 | owner page `/admin/credits` + admin tab |
| T28 | g2 | billing page read-only for members + notice; wizard success note |
| T29 | g3 | manager components: inline task cards + decision banner |
| T30 | g3 | wire `/manager` |
| T31 | g4 | task board component |
| T32 | g4 | wire `/tasks` |
| T33 | g5 | pipeline stepper + turn timeline |
| T34 | g5 | live log panel |
| T35 | g5 | gates panel + diff viewer + waiver + sha link |
| T36 | g5 | wire `/tasks/[id]` |
| T37 | g6 | team panel + hire dialog (no model names) |
| T38 | g6 | wire `/workforce` |
| T39 | g6 | `/workforce/[id]`: tier for non-owners, routing only for the owner |
| T40 | g7 | dashboard engine widgets |
| T41 | g7 | traces engine turns widget |
| T42 | g8 | D-E: api-org comment + agent-3 report fixes |
| T43 | g8 | Agent 2 final report + tick TODO-agent-2 (run after the other groups) |
| T44 | g8 | PLAN-v2 §9 + README |
Assumptions made (decide before running if any is wrong):
1. **Prepaid is strict:** a missing wallet counts as zero, so it pauses (only test fakes pass `wallets=None`;
every real org gets a wallet at creation). Debits may take a wallet below zero (a turn that started with
credit finishes); the next turn is then paused.
2. **Budget-paused tasks are resumed by hand** (the «ادامه» button, refused while credit is still zero). The
engine cannot tell a budget pause from a user pause (no reason column), so adding credit does not resume
tasks automatically. The manager chat is also gated at zero credit (it costs model calls).
3. **Direct tasks** become engine tasks at `pending_manager_review` with kind `feature`; the manager's triage
picks the real pipeline and assignee. Pre-v2 tasks without a state keep the legacy run path, and tests of
that path use a direct-row helper.
4. Owner credit history, the owner's `add-credit`, starting credit and usage debits all live in one new table
`credit_transactions`; the platform owner is the only one who can add credit (org admins lose
`add-credit`/`settle`; `checkout` still only creates a pending payment). Invoices from the billing worker
stay informational.
5. The starting credit is an env setting (`BILLING_STARTING_CREDIT`, default 0), not an owner-editable UI value.
6. The diff shown on the task page is the last accepted diff, stored on the state and capped at 20,000 chars;
the pushed sha is stored as `merged_sha` and linked to `/app` (Agent 3's page) without a run id.
7. Org members (all roles) can read `GET /engine/status`, which includes the wallet balance, so they can see why
work stopped. Mutations follow the existing capabilities (`CAN_RUN_TASKS`, `CAN_MANAGE_EMPLOYEES`).