The AI workforce engine's driver. One tick moves every ready task one step:
a turn, a gate, or a platform merge. The AI never runs git.
- scheduler.py: tick() over TURN_STAGES/MERGE_STAGES; selection caps
(per-org 3, global 8); optimistic advance so two workers cannot both win;
worktree created for code pipelines, released on completion/cancel/failure
(kept during error_review); budget pause before any turn is spent; merge
conflict back-off; transient-failure retries then the manager's error turn.
- gates.py: diff guard (empty/.git/CI/symlink/oversize), no-progress hash,
tests in the sandbox, green gate that only trusts a green tests row or an
explicit waiver. Every verdict is a gate_results row + a task.gate event.
Fixed forbidden_paths: lstrip("./") stripped all leading dots, hiding
.git and .github from the guard.
- container_engine.build_scheduler + worker drives the engine tick.
- ManagerOutcome.error_kind; TaskSpendRepository.spend_for_task.
- New settings engine_max_concurrent_turns, engine_max_turns_per_org,
engine_tests_timeout_seconds, engine_max_merge_conflicts,
engine_max_task_credits (+ .env.example).
- tests: test_engine_gates.py (13), test_engine_flow.py (11).
Checks: pytest 746 passed/1 skipped, ruff clean, mypy clean (160 files).
Phase 3 of the AI-workforce engine (TODO-agent-2):
- engine/manager.py: ManagerTurns.chat (CREATE_TASK/PAUSE/RESUME/CANCEL,
HIRE/UPDATE_EMPLOYEE/FIRE, KNOWLEDGE), triage (ASSIGN/REJECT/ASK_USER),
final_review (COMPLETE/REVISE +cycle), error_review (RESOLVE_ERROR/CANCEL),
bootstrap_team; post_user_message now delegates to it.
- One billing path: call_model/BillingScope in runner.py is the single model
entry point (fixes the agybot chat-billing bug).
- Team changes capped at 3/turn, employee cap, last manager cannot be fired,
hires pick a granted model of the requested tier strictly.
- container_engine.py wires the real WorkforceBootstrapPort.
- Fixes: REVISE is a body tag (prompt and parser now agree); ASSIGN
employee="" means pick-for-me; the runner's protocol-error retry is capped by
engine_max_protocol_retries (was unbounded until the turn timeout).
- 20 new manager tests + regression tests; report in
docs/plan/reports/agent-2-phase-3.md.
Phase 2 of TODO-agent-2: one employee, one turn, real tools in the sandbox.
- engine/prompt.py: the stable, cache-friendly prefix in the plan's order
(rules → role protocol → toolkit → app profile → memory), a task brief
capped at 1200 chars, role-specific stage context (spec for the developer,
the diff only for the reviewer, test output for the tester) and a hard
prompt cap that drops the oldest tool results first.
- engine/tools.py: READ/WRITE/EDIT/LIST_DIR/SEARCH/RUN executed inside the
sandbox through SandboxProviderPort, rooted at the worktree's sandbox path.
Path escapes, .. and .git are refused; git push/remote/config are refused
with "the platform does git"; RUN clamps its timeout (120s default, 600s
max) and keeps the tail of its output; every execution writes a tool_calls
row so the approvals UI shows real work.
- engine/runner.py: the loop — model call, parse tags, execute tool tags,
feed results back — until a terminal tag, the tool cap, the turn timeout or
a cancellation. The employee's model is checked against ModelGrantPort
before the first call (ungranted → error_kind=auth, no spend), errors are
classified transient vs fatal, and every model call writes usage events
through the one shared record_model_usage path (extracted from
WorkforceService so manager chat counts too). Writes engine_turns, run_steps
and tool_calls.
- engine/events.py: RunFeed batches run.log (≤1 event / 500 ms / stream, ≤200
lines) and emits run.tool and task.gate through the generic envelope.
- tests: engine_fakes.py (ScriptedModel, FakeSandbox, FakeGrants,
RecordingMemory) plus 54 new tests in test_engine_runner,
test_engine_prompt and test_engine_tools.
Suite: 648 passed, ruff clean, mypy clean.
Phase 1 of TODO-agent-2: pure logic plus the tables it writes to.
- domain/workforce/pipeline.py: Stage/PipelineKey/Outcome, the four pipelines,
TRANSITIONS, next_stage, STAGE_ROLE (role+mode), resolve_pipeline with the
no-reviewer fallback, task-kind defaults, stage -> coarse tasks.status, and
the TaskPipelineState record (with the optimistic version).
- application/engine/protocol.py: the [[TAG attr="v"]]body[[/TAG]] parser and
its self-closing form, the per-role/mode allow-list, literal bodies (a
bracket inside a body is not a tag), typed readers (ASSIGN/CREATE_TASK/
team changes/KNOWLEDGE/EDIT_FILE blocks) and the per-reply limits
(3 tasks, 3 team changes, 3 knowledge items).
- application/engine/state.py: advance() with the optimistic compare-and-set
and park_dependents() so a failed dependency never deadlocks its children.
- db/models_engine.py + migration a2b3c4d5e6f2: task_pipeline_state,
employee_profiles, engine_turns, gate_results as side tables (tasks and
employees stay locked). Linear head; alembic check reports no drift.
- repositories/engine.py + 58 new tests (test_engine_protocol,
test_engine_pipeline) covering every pipeline's happy path, illegal
transitions, exactly-one-winner concurrent advance, dependency parking and
all parser edge cases.
Suite: 594 passed, ruff clean, mypy clean.
The user-visible payoff of the whole delivery workstream.
- (app)/app: four tabs — pipeline (runs + the eight steps + a live console),
releases (with demo rollback), production (target summary, confirmed deploy,
history), Git. The header answers "is it live": demo URL, live release,
health state.
- Live console: batches onto an animation frame, stops following the tail the
moment the user scrolls away from the bottom, strips ANSI colour codes and
carriage-return redraws. A console that yanks the view back is worse than none.
- Production panel: the target is shown as the operator recognises their server
plus has_secret/ثبت شده. No field anywhere could render a credential.
- contracts: the v2 delivery types and seven new WsEventType members.
- /admin/sandbox (owner): per-role container counts, running containers, per-org
limits, image mapping, stop with the id typed back (audited). A provider without
the capability reports an empty list rather than an error.
- /admin/domains (owner): every route with its owning app, attach/detach (host
typed back), idempotent re-sync.
- /projects removed entirely, with a permanent redirect to /app.
Tests: 502 API (+3), 78 web (+11); ruff, mypy, tsc and lint clean.
Report: docs/plan/reports/agent-3-phase-6.md
The one path that touches a customer's server, and the one the platform cannot
undo. It is human-triggered, confirmed and audited; there is deliberately no
code path from a pipeline run into it (D13).
- deploy_ssh.py: SshDeployer. Release lands in releases/<ts>-<sha7>/, the
post-deploy command runs with cwd there, and only then does `current` move —
atomically (relative symlink + mv -T). A failed hook or extraction never moves
it, so the previous release keeps serving. Host key is pinned on first success
(TOFU); a mismatch is a hard failure. Prunes to the newest five.
- deploy_ftp.py: FtpDeployer. FTP has no atomic switch and no rollback except a
backup, so a backup is mandatory: every file about to be overwritten is
archived under ARTIFACTS_ROOT before anything is uploaded. The artifact is
extracted, then the build dir (dist/build/public/out) is chosen — a static
deploy must not publish the repository. Archive members are sanitised: absolute
paths, `..` and escaping links are refused.
- deploy_target.py: the tester that dispatches on `kind`.
- deploy_service.py + production_deploys (migration a3b4c5d6e7f4) + 4 routes.
One deploy per project at a time; the org slug must be typed back; the target
summary reports has_secret, never the secret.
Tests: 499 API (+26) with fake SSH/FTP transports; ruff, mypy, alembic clean.
Report: docs/plan/reports/agent-3-phase-5.md
Domains are infrastructure, and in v2 infrastructure is the platform owner's
alone (D9). A tenant gets one automatic demo host and nothing else.
- admin_domains.py (owner-only): list every route with its owning app, attach an
extra host, detach with the host typed back as confirmation (audited), re-sync
the router from the database, and read the suggested MCP gateway route.
- domain/platform/rules.py: validate_extra_host. Two shapes only — a single
label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS
is on. A wildcard is refused in both (it would match another tenant's
subdomain), and so are 30 protected platform labels (git, mcp, panel, api...).
Enforced in the router *and* again in the adapter.
- Both router adapters gained routes_fingerprint / list_all_routes /
sync_routes. Re-sync writes and reloads only when the rendered state differs:
a reload drops every in-flight connection on the host, including live demos.
Adapter-level extras, not additions to the frozen DomainRouterPort.
- The member-facing preview-domain routes are owner-only now. They are kept
because the demo host is still created through them at app-creation time.
Tests: 473 API (+58); ruff and mypy clean.
Report: docs/plan/reports/agent-3-phase-4.md
A push to `dev` now becomes a live demo, and a failed build never takes the
demo down.
- Four tables (pipeline_runs, pipeline_steps, releases, app_runtimes) and their
migration; alembic upgrade/downgrade/check all clean.
- DeliveryService implements DeliveryHooks: on_app_created provisions the demo
port + host + first build (idempotently); on_branch_pushed queues a run for
`dev` and supersedes older *queued* runs, leaving a running one alone.
- The 8-step pipeline. Blue/green is real: the replacement starts on a staging
port, is healthchecked, and only then does the route move — then the previous
container is stopped. A failure removes the replacement, releases its port and
leaves the old container serving.
- The demo port ledger is the DATABASE, not the router adapters' in-memory maps:
the API and the delivery worker are separate processes.
- Runs are claimed with a conditional UPDATE, so two workers never build the
same run.
- Live logs are batched (1 event / 500 ms, 200 lines) and redacted as a whole
tail — per-chunk redaction misses a secret split across pipe reads, and the
*stored* tail leaked until this was fixed.
- delivery worker: drain the queue, then poll remotes for pushes made outside
the engine.
- 7 routes; reads need membership, actions need projects.manage and are audited.
Tests: 415 API (+21, 1 docker-marked skipped); ruff, mypy, alembic check clean.
Report: docs/plan/reports/agent-3-phase-3.md
The platform's side of D6: git runs on the host, in a gitdir that is never
mounted into a container, so repository credentials never reach the model.
- adapters/git_workspace.py: LocalGitWorkspaceAdapter implementing
RepoWorkspacePort. Bare shared gitdir + a dev work tree mounted at
/workspace + one linked worktree per task under work/.worktrees/<id>
(excluded via the gitdir's info/exclude, not the project's .gitignore).
Explicit --git-dir/--work-tree, core.hooksPath=/dev/null, fsmonitor off,
quotePath off, GIT_CONFIG_NOSYSTEM, a private HOME per project, prompts off,
one asyncio.Lock per project. Token via GIT_ASKPASS reading a 0600 file; SSH
key as a 0600 temp file with a pinned known_hosts. Everything deleted in a
finally; only a *path* ever travels through the environment.
- The bug this phase existed to find: --git-dir=<shared> does not address a
linked worktree, so `git commit` in a task worktree committed straight to
`dev`. Operations now address <gitdir>/worktrees/<task_id> explicitly — and
never read the worktree's .git file, which a repository can rewrite.
- domain/platform/rules.py: is_blocked_address (pure) + assert_remote_host_allowed
(resolves, refuses if any address is loopback/private/link-local/metadata).
Shared by the git remote test and, later, every deploy target.
- ProjectExternalGit domain model + repository; container_delivery wires the
real adapter behind GIT_WORKSPACE=local with a resolver that joins the
project, its repo and its (decrypted) credentials.
Tests: 394 API (+48) against real git and a local bare repo; ruff and mypy clean.
Report: docs/plan/reports/agent-3-phase-2.md
The execution boundary for v2. Adds the local docker/podman sandbox adapter
and the hardened host subprocess runner every other delivery adapter will use.
- adapters/process.py: the one place the platform shells out. argv-only (no
shell), start_new_session + killpg on timeout, concurrent stdout/stderr
drains with a byte cap, explicit minimal env (never os.environ), and a
missing binary reported as spawn_failed rather than an exception.
- adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort.
--user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits,
--read-only with tmpfs scratch, one bind mount (the work tree, never the git
dir), labels, no published ports. Path-safe file access; secrets refused in
`docker exec -e`. Extras for later phases: start_demo_container (loopback
publish only), list_managed_containers, remove_container.
- registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry
survives create_workspace -> start_session.
- sandbox_gateway worker: reap_orphans() removes labelled containers no live
session points at, audited as sandbox.reap; run_pass() does both passes.
- sandbox router: every mutating route now requires the platform owner
(PLAN-v2 §7.7); read-only status routes stay open to org members.
- sandbox-panel: canManageInfra prop (fails closed) hides the create form,
the session panel and the action buttons from non-owners.
Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean.
Three existing tests now drive the sandbox as the platform owner, since that
is who may touch it.
Report: docs/plan/reports/agent-3-phase-1.md
- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
میکند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبتنام عمومی حذف شد (POST /auth/signup). حسابها فقط با POST /admin/users
(فقط مالک، با رمز موقت یکبارمصرف) یا با پذیرش لینک دعوت ساخته میشوند.
در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوتشده رمز تعیین میکند؛ اگر
وجود داشته باشد لینک هرگز جای reset رمز را نمیگیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو میشود.
- تنزل یا حذف مدیر، سازمانهایش را به مالک سامانه منتقل میکند (مالکیت + عضویت،
با audit). حساب مالک از API تغییرناپذیر است؛ جابهجایی صندلی فقط با
POST /admin/transfer-ownership و در یک تراکنش انجام میشود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
رفتار کند (پیشتر حذف کاربر، عضویتهایش را یتیم میگذاشت).
- تست: ۳۰۰ تست بکاند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
فرانتاند؛ ruff، mypy، tsc، eslint و build تمیز.
ادغام ۱۲ کامیت ریموت (لایهٔ realtime روی WebSocket، حذف REST عمومی، رفع
باگهای صفحهٔ مدلها) با ۸ کامیت محلی (ارائهدهندهٔ agy + رفع فلو دعوت).
تنها کانفلیکت واقعی در `app/main.py` بود و ماهوی بود، نه متنی:
ریموت اپ عمومی را به «فقط /health + /api/ws» کاهش داده و همهٔ هندلرهای HTTP
را به اپ داخلی `interfaces/realtime/rpc_app.py` منتقل کرده است. بنابراین
روتر `invites` من نباید در اپ عمومی mount شود.
راهحل:
- `app/main.py`: فقط `health` و `realtime_router` میمانند؛ ایمپورتهای اضافی
حذف شدند (نسخهٔ ریموت).
- `interfaces/realtime/rpc_app.py`: روتر `invites` به اپ داخلی اضافه شد تا
`GET /api/invites/{token}` و `POST /api/invites/accept` از مسیر WS-RPC
قابل دسترس باشند.
`api.ts` و `contracts/index.ts` خودکار و بدون کانفلیکت ادغام شدند: کلاینت
`previewInvite`/`acceptInvite` حالا از `getRealtimeClient().rpc()` رد میشود،
پس هیچ تغییری در فراخوانیها لازم نبود.
رفع تست شکستهٔ ریموت: `src/test/version.test.tsx` مقدار `AppShell` را بدون
`RealtimeProvider` رندر میکرد و `useRealtime` استثنا میداد. حالا اسکلت
همانطور که در `app/layout.tsx` سیمکشی شده رندر میشود و سوکت در تست stub
شده است.
اعتبارسنجی روی درخت ادغامشده:
- بکاند ۲۷۰ تست سبز (شامل ۶ تست دعوت روی معماری جدید)؛ mypy تمیز.
- فرانت ۶۱ تست سبز؛ tsc و eslint تمیز.
- تأیید شد روترهای invite روی اپ داخلی RPC حاضرند و سطح عمومی تغییر نکرده.
نکته: ۵ خطای ruff در `tests/conftest.py`، `tests/test_error_log.py` و
`tests/test_realtime.py` از خود `origin/dev` میآیند و به این ادغام ربطی
ندارند؛ دستنخورده رها شدند.
لینک دعوت هم کلیکپذیر نبود و هم کار نمیکرد؛ چهار نقص همزمان:
- `invite_url` نسبی بود (`/accept-invite?token=…`) و host نداشت → حالا از
`settings.app_url` آدرس مطلق ساخته میشود.
- صفحهٔ `/accept-invite` در اپ Next وجود نداشت → ۴۰۴.
- هیچ اندپوینت پذیرش دعوت در بکاند نبود؛ `InviteRepository.mark_accepted`
نوشته شده بود ولی هرگز صدا زده نمیشد، پس توکن مصرف نمیشد.
- در تنظیمات، لینک بهصورت متن ساده در `<p>` رندر میشد.
بکاند:
- روتر `app/interfaces/http/routers/invites.py`:
`GET /api/invites/{token}` (پیشنمایش) و `POST /api/invites/accept`.
عمداً زیر `/organizations/{org_id}` نیست: دعوتشونده هنوز عضو نیست و
`get_org_context` ردش میکند، پس خود توکن مجوز است. accept ایدمپوتنت است.
- `AuditAction.MEMBER_JOIN = "member.join"`.
- رفع باگ نهفته: `Invite.is_expired()` بین `expires_at` خواندهشده از SQLite
(naive) و `utcnow()` (aware) خطای مقایسه میداد. طبق قرارداد موجود پروژه
(`_aware` در `adapters/agy.py`)، در `repositories/identity.py` تابع `_aware`
اضافه و روی `expires_at`/`accepted_at` اعمال شد.
فرانت:
- صفحهٔ `app/accept-invite/page.tsx` با پیشنمایش سازمان/نقش و پذیرش؛ پس از
پذیرش `setActiveOrganization` میکند تا در همان سازمان لند کند.
- قراردادهای `InvitePreview`/`AcceptInviteResult` و کلاینت
`previewInvite`/`acceptInvite`.
- `login`/`signup` پارامتر `?next=` را میفهمند (فقط same-origin) تا توکن دعوت
در مسیر احراز هویت گم نشود.
- `settings`: لینک به `<a>` واقعی + دکمهٔ «کپی لینک».
تست: `tests/test_invites.py` (۶) و `src/test/accept-invite.test.tsx` (۵).
بکاند ۲۵۲ سبز + ruff/mypy تمیز؛ فرانت ۶۱ سبز + tsc/eslint تمیز.
Backend: AiModelRepository.delete() + DELETE /api/admin/models/{id} (owner-only,
audited) in the RPC-served admin router; session commits per request.
Frontend: api.adminDeleteModel() (WebSocket RPC DELETE) and a per-row حذف
action with a two-step inline confirm (تأیید حذف / انصراف) that removes the
row from state on success. tsc clean, py_compile clean.
The account and model <select>s had no value="" placeholder option, so a
controlled value of "" made the browser display the first real entry as if
it were already chosen. Clicking that entry was not a change, so onChange
never fired and the cascade (account->catalog, model->efforts) only ran after
a *second*, different selection.
Prepend a real value="" placeholder option to both dropdowns so the displayed
value matches the controlled state and the first real selection now triggers
onChange as expected.
- Newly added model is highlighted (2.5s) and the list scrolls into
view so the owner sees it land immediately after create.
- Model dropdown shows an explicit 'loading catalog…' placeholder while
the selected account's live catalogue is fetched, instead of a
misleading 'no catalog' message during the request.
The model dropdown labeled each option as "{display} ({id})", and since
agy's display name already bakes in the effort, entries rendered like
"Gemini 3.8 Flash (High) (gemini-3.8-flash-high)" — the name, its effort,
AND the raw id all in one, redundant with the separate effort field below.
Group the live catalog into families by base name (effort qualifier
stripped): "Gemini 3.8 Flash" is now one option, and the reasoning effort
is chosen in the existing effort dropdown. On create, the family + chosen
effort resolves back to the concrete agy catalog entry
(gemini-3.8-flash-medium etc.), so the saved internal_name and
reasoning_effort are byte-for-byte what a direct pick produced before.
Frontend-only; no backend/catalog change. tsc --noEmit clean.
Console evidence: on the authenticated layout, api.me() opened
wss://.../api/ws?token=<JWT> and the browser logged "WebSocket is closed
before the connection is established", then an uncaught
connection_closed rejection — a self-inflicted reconnect storm.
Root cause (two halves):
1. connectedToken was only assigned inside onopen, so during the CONNECTING
window every other caller (RealtimeProvider's 2s poll, rpc's whenOpen)
saw connectedToken !== token and called openWith again, tearing down the
socket it had just dialled.
2. Replacing a socket left its late onclose to run onSocketClosed, which nulled
this.socket and connectedToken — clobbering the successor and firing a
spurious reconnect + rejected openPromise.
Fix:
- Set connectedToken the moment we commit to a dial (in openWith), so
concurrent callers wait on the same openPromise instead of re-dialling.
- Guard onopen/onmessage/onclose with `this.socket !== socket` so a replaced
socket's late callbacks are ignored and cannot clobber its successor.
Anonymous login escaped this (both tokens null); an authenticated connection
(a real JWT) triggered it, matching the reported failure.
A login over the socket could hang the full 60s timeout when the
reverse proxy allowed the WebSocket handshake but dropped the
server->client direction without closing the socket (so onclose never
fired and the client saw a live-but-unresponsive link).
- Track last inbound frame time (any frame, incl. hello/pong) as proof
the link is bidirectional.
- ensureLive(): before sending an RPC, replace a socket that has been
silent for >30s (SOCKET_STALE_MS).
- On timeout, recycle the connection and retry once on a fresh socket
instead of hard-failing; only idempotent-damage-free flows are affected
since the first attempt is torn down first.
Reduces the observed "درخواست از طریق وبسوکت زمانبر شد" on login to a
single reconnect+retry rather than a hard 60s wait.
The platform no longer exposes a public /api/* REST surface. The original
HTTP route handlers are preserved verbatim as the single source of truth
inside an internal, never-socket-bound FastAPI app (rpc_app), and are now
reached only via RPC-over-WebSocket dispatch.
- realtime router: anonymous connections allowed only to /api/auth/login
and /api/auth/signup; all other RPCs/subscribes refused until token auth.
- RPC dispatch re-injects the bearer token and X-Request-ID, then forwards
through httpx.ASGITransport against the internal app (no network hop).
- Public app now serves only /, /health, /health/ready and /api/ws;
docs/openapi disabled.
- Frontend: apiRequest routes /api/* to ws.rpc(); /health probes keep fetch.
RealtimeClient gains rpc() with id correlation, timeout and AbortSignal.
- Tests repointed to get_rpc_app(); anonymous-auth behaviour covered.
Result: zero REST on the public surface; 264 backend tests + frontend
typecheck pass.
- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت
- بخش «ارائهدهندهها» از کنسول حذف شد؛ ورود اکانتها فقط از صفحهٔ اکانتهای agy
- صفحهٔ مدلها بازطراحی شد: ارائهدهنده → اکانت → مدل (کاتالوگ لحظهای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل
- مدلها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه میکند و فقط به مدلهای استدلالی میفرستد
- مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیفهای providerهای قدیمی
- تستها: ۲۴۶ تست بکاند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
صفحهٔ `/admin/agy-accounts` بعد از پاس motion ساخته شد، بنابراین ریشهٔ
خودش `.anim-stagger` نداشت و کل صفحه یکجا ظاهر میشد در حالی که بقیهٔ
پنل آبشاری وارد میشوند. فهرست اکانتها هم که async لود میشود stagger
نگرفته بود.
هر صفحهٔ جدیدی که بعد از این ساخته شود باید همین دو کلاس را بگیرد؛
`motion.test.tsx` واژگان را محافظت میکند اما اعمال آن روی صفحههای
تازه را نمیگیرد.
یک حباب کوچک و شیشهای با فونت تکعرض در فوتر ستون کناری، کنار دکمهٔ خروج:
- `font-mono` — پشتهٔ فونتهای سیستمی (ui-monospace، SFMono، Menlo،
Consolas) که همیشه نصباند؛ پس نسخه روی هر دستگاه یکسان رندر میشود،
بدون انتظار برای وبفونت، و رقمها جابهجا نمیشوند.
- شیشهای با translucent و blur: `bg-white/5` + `border-white/10` +
`backdrop-blur-sm`. بدون gradient و بدون shadow، مطابق §12 پلن.
- `ltr-inline` تا الگوریتم bidi در پوستهٔ RTL رشتهٔ لاتین را جابهجا نکند،
و `tabular-nums` تا عرض numeralها ثابت بماند.
نسخه از `src/lib/version.ts` میآید — بهجای import کردن کل
`package.json` که کل manifest را برای یک رشته به باندل کلاینت میکشید.
هزینهٔ این انتخاب drift است، پس `version.test.tsx` میسنجد که با
`apps/web/package.json` یکی باشد.
تستهای جدید: ۵ (نسخه و نشان نسخه در پوسته).
- Adapter اکانتمحور با چرخش round-robin و بنچشدن خودکار روی ۴۲۹/۴۰۱
- ورود ادمین از طریق URL بازگشتی گوگل (PKCE) و ذخیرهٔ رمزنگاریشدهٔ refresh token
- صفحهٔ مدیریت اکانتهای agy در پنل ادمین به همراه تست و فعال/غیرفعالسازی
- مهاجرت Alembic b1c2d3e4f5a6 برای ستونهای اکانت agy
- seed همیشه کاتالوگ agy را میسازد و فقط با AI_DEFAULT_PROVIDER=agy مسیریابی میشود
چندین سطح تعاملی (منوی سازمان، منوی کاربر، لینکهای بردکرامب، آیتمهای
پالت فرمان، دکمهٔ ساخت سازمان، لینک ردیابی) رنگ را در hover یکباره
تغییر میدادند و بقیهٔ رابط نرم بود. اکنون همه یکسان transition
میگیرند.
واژگان مشترک حرکت در globals.css: شش @keyframes و شش کلاس .anim-*
بههمراه .anim-stagger برای ورود آبشاری. همهٔ مدتزمانها پشت custom
property هستند تا یک override کل اپ را تنظیم کند.
- packages/ui: Button، Card، Stat، Loading/Empty/ErrorState، UsageBar،
Timeline، Modal، Drawer، Table، ToastStack و ArtifactViewer
- ریشهٔ هر صفحهٔ (app) با .anim-stagger وارد میشود
- AppShell: محتوا fade، منوی کنار stagger، منوهای کشویی slide-up
- prefers-reduced-motion مدتزمانها را به ۰.۰۱ms میرساند تا
وضعیت پایانی حفظ شود
- قرارداد motion در src/test/motion.test.tsx تست میشود تا
بازنویسی بعدی آن را بیصدا حذف نکند
- تستهای فرانتاند: ۳۵ ← ۴۵
دو شکاف رسیدگیشده از بازبینی PLAN.md و TODO.md:
- پالت فرمان: جستوجو و رفتن سریع بین بخشها با ⌘K/Ctrl+K، پیمایش با
کلیدهای جهتدار، Enter برای رفتن، و Escape برای بستن. گروه اختصاصی
«کنسول مالک سامانه» فقط برای مالک سامانه نمایش داده میشود.
- صفحهٔ /admin/usage: گزارش مصرف سراسری به تفکیک سازمان. اندپوینت
`GET /admin/usage` و کلاینت `adminUsage()` از قبل وجود داشتند و توسط
صفحات نمای کلی و سازمانها مصرف میشدند؛ این صفحهٔ اختصاصی آن است.
همچنین `NAV_ITEMS` به `lib/nav.ts` منتقل شد تا sidebar و پالت فرمان
هرگز از هم واگرا نشوند.
تست: ۱۰ تست جدید برای پالت فرمان. مجموع تستهای فرانتاند از ۲۵ به ۳۵
رسید. tsc، eslint و ruff تمیز.
نام نمایشی و هویت اپلیکیشن در همهٔ سطوح کاربر-پسند از «AGY Web» به
«Smart Organization» تغییر کرد:
- UI: عنوان تب مرورگر، صفحهٔ ورود، برند و لوگوی پوستهٔ اپ (AG → SO)
- بکاند: مقدار پیشفرض app_name، نام و توضیح توزیع پایتون
- مانیفستها: package.json ریشه و چهار پکیج، pyproject.toml
- مستندات: README، PLAN، TODO و دو سند docs
شناسههای داخلی عمداً دستنخورده ماندهاند: scope پکیجهای @agy/*،
کلیدهای localStorage، نامهای logger، نام فایل پایگاهٔ داده، و اطلاعات
اتصال پایگاه داده در .env.example و infra/docker/docker-compose.yml.
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.