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

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

Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
This commit is contained in:
mjalmoudy94
2026-10-14 23:19:05 +03:30
parent fef5a9deab
commit d532929189
117 changed files with 13119 additions and 640 deletions
+152
View File
@@ -0,0 +1,152 @@
/**
* The manager page's engine parts. The task cards under a conversation show each engine task's
* Persian stage (and a legacy task's status), and link to the task page. The decision banner lists
* only the tasks that wait for an answer, is hidden when there are none, posts the answer, and
* shows a refusal from the server in Persian without losing what was typed.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
answerTask: vi.fn(),
}));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-engine", () => ({ engineApi: { answerTask: mocks.answerTask } }));
import { ApiError } from "@/lib/api";
import type { EngineTask } from "@/lib/api-engine";
import { DecisionBanner } from "@/app/(app)/manager/_components/decision-banner";
import { ThreadTaskCards } from "@/app/(app)/manager/_components/task-cards";
const ORG = "org-1";
function task(overrides: Partial<EngineTask> = {}): EngineTask {
return {
id: "task-1",
title: "صفحهٔ گزارش فروش",
status: "running",
stage: "pending_test",
pipeline: "default",
kind: "feature",
cycle: 1,
max_cycles: 3,
thread_id: "thread-1",
assigned_employee_id: null,
assignee_name: null,
created_at: "2026-10-01T00:00:00Z",
updated_at: "2026-10-01T00:00:00Z",
...overrides,
};
}
beforeEach(() => {
mocks.answerTask.mockReset();
});
describe("ThreadTaskCards", () => {
it("shows each engine task's Persian stage and links the card to the task page", () => {
render(<ThreadTaskCards tasks={[task({ stage: "pending_test" })]} />);
expect(screen.getByText("در انتظار تست")).toBeInTheDocument();
expect(screen.getByText("صفحهٔ گزارش فروش").closest("a")).toHaveAttribute(
"href",
"/tasks/task-1",
);
});
it("shows a legacy task by its status, since the engine has no stage for it", () => {
const legacy = task({ id: "task-2", title: "کار قدیمی", stage: null, status: "done" });
render(<ThreadTaskCards tasks={[legacy]} />);
expect(screen.getByText("کار قدیمی")).toBeInTheDocument();
expect(screen.getByText("انجام شد")).toBeInTheDocument();
});
it("renders nothing for a conversation without tasks", () => {
const { container } = render(<ThreadTaskCards tasks={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
describe("DecisionBanner", () => {
it("is hidden when no task waits for an answer", () => {
const { container } = render(
<DecisionBanner orgId={ORG} tasks={[task({ stage: "pending_test" })]} onAnswered={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
it("lists only the tasks at a pending decision", () => {
render(
<DecisionBanner
orgId={ORG}
tasks={[
task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
task({ id: "task-2", title: "تست واحد", stage: "pending_test" }),
]}
onAnswered={vi.fn()}
/>,
);
expect(screen.getByText("در انتظار تصمیم شما")).toBeInTheDocument();
expect(screen.getByText("کدام پایگاه داده؟")).toBeInTheDocument();
expect(screen.queryByText("تست واحد")).toBeNull();
});
it("posts the trimmed answer to the task and reports it", async () => {
mocks.answerTask.mockResolvedValue({});
const onAnswered = vi.fn();
render(
<DecisionBanner
orgId={ORG}
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
onAnswered={onAnswered}
/>,
);
const send = screen.getByRole("button", { name: "ارسال پاسخ" });
expect(send).toBeDisabled();
fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: " PostgreSQL " } });
expect(send).toBeEnabled();
fireEvent.click(send);
await waitFor(() => expect(onAnswered).toHaveBeenCalledTimes(1));
expect(mocks.answerTask).toHaveBeenCalledWith(ORG, "task-1", "PostgreSQL");
});
it("shows the server's refusal in Persian and keeps the typed answer", async () => {
mocks.answerTask.mockRejectedValue(
new ApiError(409, "conflict", "این کار دیگر منتظر پاسخ نیست."),
);
const onAnswered = vi.fn();
render(
<DecisionBanner
orgId={ORG}
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
onAnswered={onAnswered}
/>,
);
fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: "PostgreSQL" } });
fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" }));
expect(await screen.findByRole("alert")).toHaveTextContent("این کار دیگر منتظر پاسخ نیست.");
expect(onAnswered).not.toHaveBeenCalled();
expect(screen.getByLabelText("پاسخ شما")).toHaveValue("PostgreSQL");
});
});