From ef2676fa1c7e78965941864f57743f9df852fa76 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 18:59:54 +0530 Subject: [PATCH 01/27] Add Teams (DB + API + UI) --- README.md | 2 +- ...2c1b9c8e12_add_teams_and_team_ownership.py | 73 +++ backend/app/api/org.py | 76 ++- .../core/__pycache__/config.cpython-312.pyc | Bin 693 -> 677 bytes .../db/__pycache__/session.cpython-312.pyc | Bin 944 -> 928 bytes backend/app/models/__init__.py | 3 +- backend/app/models/org.py | 11 + backend/app/models/projects.py | 3 + backend/app/schemas/org.py | 14 + backend/app/schemas/projects.py | 2 + frontend/src/api/generated/model/employee.ts | 1 + .../src/api/generated/model/employeeCreate.ts | 1 + .../src/api/generated/model/employeeUpdate.ts | 1 + frontend/src/api/generated/model/index.ts | 4 + .../model/listTeamsTeamsGetParams.ts | 10 + frontend/src/api/generated/model/project.ts | 1 + .../src/api/generated/model/projectCreate.ts | 1 + .../src/api/generated/model/projectUpdate.ts | 1 + frontend/src/api/generated/model/team.ts | 13 + .../src/api/generated/model/teamCreate.ts | 12 + .../src/api/generated/model/teamUpdate.ts | 12 + frontend/src/api/generated/org/org.ts | 438 ++++++++++++++++++ frontend/src/app/_components/Shell.tsx | 1 + frontend/src/app/people/page.tsx | 23 + frontend/src/app/projects/page.tsx | 17 +- frontend/src/app/teams/page.tsx | 150 ++++++ 26 files changed, 865 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py create mode 100644 frontend/src/api/generated/model/listTeamsTeamsGetParams.ts create mode 100644 frontend/src/api/generated/model/team.ts create mode 100644 frontend/src/api/generated/model/teamCreate.ts create mode 100644 frontend/src/api/generated/model/teamUpdate.ts create mode 100644 frontend/src/app/teams/page.tsx diff --git a/README.md b/README.md index 0260903..a8c59fd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ No auth (yet). The goal is simple visibility: everyone can see what exists and w Uses local Postgres: - user: `postgres` -- password: `netbox` +- password: `REDACTED` - db: `openclaw_agency` ## Environment diff --git a/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py b/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py new file mode 100644 index 0000000..0898fad --- /dev/null +++ b/backend/alembic/versions/3f2c1b9c8e12_add_teams_and_team_ownership.py @@ -0,0 +1,73 @@ +"""Add teams and team ownership + +Revision ID: 3f2c1b9c8e12 +Revises: bacd5e6a253d +Create Date: 2026-02-02 + +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3f2c1b9c8e12" +down_revision = "bacd5e6a253d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1) Teams + op.create_table( + "teams", + sa.Column("id", sa.Integer(), primary_key=True, nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("department_id", sa.Integer(), nullable=False), + sa.Column("lead_employee_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["department_id"], ["departments.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["lead_employee_id"], ["employees.id"], ondelete="SET NULL"), + sa.UniqueConstraint("department_id", "name", name="uq_teams_department_id_name"), + ) + op.create_index("ix_teams_name", "teams", ["name"], unique=False) + op.create_index("ix_teams_department_id", "teams", ["department_id"], unique=False) + + # 2) Employees belong to one (optional) team + op.add_column("employees", sa.Column("team_id", sa.Integer(), nullable=True)) + op.create_index("ix_employees_team_id", "employees", ["team_id"], unique=False) + op.create_foreign_key( + "fk_employees_team_id_teams", + "employees", + "teams", + ["team_id"], + ["id"], + ondelete="SET NULL", + ) + + # 3) Projects are owned by teams (not departments) + op.add_column("projects", sa.Column("team_id", sa.Integer(), nullable=True)) + op.create_index("ix_projects_team_id", "projects", ["team_id"], unique=False) + op.create_foreign_key( + "fk_projects_team_id_teams", + "projects", + "teams", + ["team_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_projects_team_id_teams", "projects", type_="foreignkey") + op.drop_index("ix_projects_team_id", table_name="projects") + op.drop_column("projects", "team_id") + + op.drop_constraint("fk_employees_team_id_teams", "employees", type_="foreignkey") + op.drop_index("ix_employees_team_id", table_name="employees") + op.drop_column("employees", "team_id") + + op.drop_index("ix_teams_department_id", table_name="teams") + op.drop_index("ix_teams_name", table_name="teams") + op.drop_table("teams") diff --git a/backend/app/api/org.py b/backend/app/api/org.py index 571a489..8fa8841 100644 --- a/backend/app/api/org.py +++ b/backend/app/api/org.py @@ -7,8 +7,15 @@ from sqlmodel import Session, select from app.api.utils import get_actor_employee_id, log_activity from app.db.session import get_session from app.integrations.openclaw import OpenClawClient -from app.models.org import Department, Employee -from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate +from app.models.org import Department, Team, Employee +from app.schemas.org import ( + DepartmentCreate, + DepartmentUpdate, + TeamCreate, + TeamUpdate, + EmployeeCreate, + EmployeeUpdate, +) router = APIRouter(tags=["org"]) @@ -127,6 +134,71 @@ def list_departments(session: Session = Depends(get_session)): return session.exec(select(Department).order_by(Department.name.asc())).all() +@router.get("/teams", response_model=list[Team]) +def list_teams(department_id: int | None = None, session: Session = Depends(get_session)): + q = select(Team) + if department_id is not None: + q = q.where(Team.department_id == department_id) + return session.exec(q.order_by(Team.name.asc())).all() + + +@router.post("/teams", response_model=Team) +def create_team( + payload: TeamCreate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + team = Team(**payload.model_dump()) + session.add(team) + + try: + session.flush() + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="team", + entity_id=team.id, + verb="created", + payload={"name": team.name, "department_id": team.department_id, "lead_employee_id": team.lead_employee_id}, + ) + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(status_code=409, detail="Team already exists or violates constraints") + + session.refresh(team) + return team + + +@router.patch("/teams/{team_id}", response_model=Team) +def update_team( + team_id: int, + payload: TeamUpdate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + team = session.get(Team, team_id) + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + data = payload.model_dump(exclude_unset=True) + for k, v in data.items(): + setattr(team, k, v) + + session.add(team) + try: + session.flush() + log_activity(session, actor_employee_id=actor_employee_id, entity_type="team", entity_id=team.id, verb="updated", payload=data) + session.commit() + except IntegrityError: + session.rollback() + raise HTTPException(status_code=409, detail="Team update violates constraints") + + session.refresh(team) + return team + + + @router.post("/departments", response_model=Department) def create_department( payload: DepartmentCreate, diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc index 0392db7f7f71da8271d775a250b7f7415f011803..29ad631d1dcf6e941c86e23f0552e463f5ff4b1c 100644 GIT binary patch delta 40 ucmdnWx|EgsG%qg~0}vQ2ZrI2@lab$2KO;XkRX?#fF(a`kF>mr=#!mp>FAU!R delta 56 zcmZ3=x|NmtG%qg~0}v>;)^FsV$tdrspOK%Ns-IY#n2}hNn5Q35nG9s)1{CFIr6!jY JPu|S<2>{Wl6A%CZ diff --git a/backend/app/db/__pycache__/session.cpython-312.pyc b/backend/app/db/__pycache__/session.cpython-312.pyc index 7d7d28362e8b937a41e70d7459061dea9b747208..0d1077b347bf6e8eddc53bc97a942565b7d514c7 100644 GIT binary patch delta 41 vcmdnMzJQ(kG%qg~0}vQ2ZrI43!N_l+pOK%Ns-IY#n2}hNn74TnqZ|_eZVi4w#B diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ed48d88..9bd9a65 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,11 +1,12 @@ from app.models.activity import Activity -from app.models.org import Department, Employee +from app.models.org import Department, Team, Employee from app.models.projects import Project, ProjectMember from app.models.work import Task, TaskComment __all__ = [ "Department", "Employee", + "Team", "Project", "ProjectMember", "Task", diff --git a/backend/app/models/org.py b/backend/app/models/org.py index f0d2caa..81a07c4 100644 --- a/backend/app/models/org.py +++ b/backend/app/models/org.py @@ -13,6 +13,16 @@ class Department(SQLModel, table=True): head_employee_id: int | None = Field(default=None, foreign_key="employees.id") +class Team(SQLModel, table=True): + __tablename__ = "teams" + + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True) + + department_id: int = Field(foreign_key="departments.id") + lead_employee_id: int | None = Field(default=None, foreign_key="employees.id") + + class Employee(SQLModel, table=True): __tablename__ = "employees" @@ -21,6 +31,7 @@ class Employee(SQLModel, table=True): employee_type: str # human | agent department_id: int | None = Field(default=None, foreign_key="departments.id") + team_id: int | None = Field(default=None, foreign_key="teams.id") manager_id: int | None = Field(default=None, foreign_key="employees.id") title: str | None = None diff --git a/backend/app/models/projects.py b/backend/app/models/projects.py index acfeb15..b079b9b 100644 --- a/backend/app/models/projects.py +++ b/backend/app/models/projects.py @@ -10,6 +10,9 @@ class Project(SQLModel, table=True): name: str = Field(index=True, unique=True) status: str = Field(default="active") + # Project ownership: projects are assigned to teams (not departments) + team_id: int | None = Field(default=None, foreign_key="teams.id") + class ProjectMember(SQLModel, table=True): __tablename__ = "project_members" diff --git a/backend/app/schemas/org.py b/backend/app/schemas/org.py index 102872a..0ae1d54 100644 --- a/backend/app/schemas/org.py +++ b/backend/app/schemas/org.py @@ -13,10 +13,23 @@ class DepartmentUpdate(SQLModel): head_employee_id: int | None = None +class TeamCreate(SQLModel): + name: str + department_id: int + lead_employee_id: int | None = None + + +class TeamUpdate(SQLModel): + name: str | None = None + department_id: int | None = None + lead_employee_id: int | None = None + + class EmployeeCreate(SQLModel): name: str employee_type: str department_id: int | None = None + team_id: int | None = None manager_id: int | None = None title: str | None = None status: str = "active" @@ -30,6 +43,7 @@ class EmployeeUpdate(SQLModel): name: str | None = None employee_type: str | None = None department_id: int | None = None + team_id: int | None = None manager_id: int | None = None title: str | None = None status: str | None = None diff --git a/backend/app/schemas/projects.py b/backend/app/schemas/projects.py index 98c3397..1047721 100644 --- a/backend/app/schemas/projects.py +++ b/backend/app/schemas/projects.py @@ -6,8 +6,10 @@ from sqlmodel import SQLModel class ProjectCreate(SQLModel): name: str status: str = "active" + team_id: int | None = None class ProjectUpdate(SQLModel): name: str | None = None status: str | None = None + team_id: int | None = None diff --git a/frontend/src/api/generated/model/employee.ts b/frontend/src/api/generated/model/employee.ts index 893f4eb..5e273ee 100644 --- a/frontend/src/api/generated/model/employee.ts +++ b/frontend/src/api/generated/model/employee.ts @@ -10,6 +10,7 @@ export interface Employee { name: string; employee_type: string; department_id?: number | null; + team_id?: number | null; manager_id?: number | null; title?: string | null; status?: string; diff --git a/frontend/src/api/generated/model/employeeCreate.ts b/frontend/src/api/generated/model/employeeCreate.ts index 74dcd45..4ca4c9a 100644 --- a/frontend/src/api/generated/model/employeeCreate.ts +++ b/frontend/src/api/generated/model/employeeCreate.ts @@ -9,6 +9,7 @@ export interface EmployeeCreate { name: string; employee_type: string; department_id?: number | null; + team_id?: number | null; manager_id?: number | null; title?: string | null; status?: string; diff --git a/frontend/src/api/generated/model/employeeUpdate.ts b/frontend/src/api/generated/model/employeeUpdate.ts index 4cb343d..28f0384 100644 --- a/frontend/src/api/generated/model/employeeUpdate.ts +++ b/frontend/src/api/generated/model/employeeUpdate.ts @@ -9,6 +9,7 @@ export interface EmployeeUpdate { name?: string | null; employee_type?: string | null; department_id?: number | null; + team_id?: number | null; manager_id?: number | null; title?: string | null; status?: string | null; diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index f1e08e2..f837637 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -23,6 +23,7 @@ export * from "./hTTPValidationError"; export * from "./listActivitiesActivitiesGetParams"; export * from "./listTaskCommentsTaskCommentsGetParams"; export * from "./listTasksTasksGetParams"; +export * from "./listTeamsTeamsGetParams"; export * from "./project"; export * from "./projectCreate"; export * from "./projectMember"; @@ -32,4 +33,7 @@ export * from "./taskComment"; export * from "./taskCommentCreate"; export * from "./taskCreate"; export * from "./taskUpdate"; +export * from "./team"; +export * from "./teamCreate"; +export * from "./teamUpdate"; export * from "./validationError"; diff --git a/frontend/src/api/generated/model/listTeamsTeamsGetParams.ts b/frontend/src/api/generated/model/listTeamsTeamsGetParams.ts new file mode 100644 index 0000000..54cb6eb --- /dev/null +++ b/frontend/src/api/generated/model/listTeamsTeamsGetParams.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * OpenClaw Agency API + * OpenAPI spec version: 0.3.0 + */ + +export type ListTeamsTeamsGetParams = { + department_id?: number | null; +}; diff --git a/frontend/src/api/generated/model/project.ts b/frontend/src/api/generated/model/project.ts index 5e3c8b5..d886a9a 100644 --- a/frontend/src/api/generated/model/project.ts +++ b/frontend/src/api/generated/model/project.ts @@ -9,4 +9,5 @@ export interface Project { id?: number | null; name: string; status?: string; + team_id?: number | null; } diff --git a/frontend/src/api/generated/model/projectCreate.ts b/frontend/src/api/generated/model/projectCreate.ts index 01a2db5..9432eb9 100644 --- a/frontend/src/api/generated/model/projectCreate.ts +++ b/frontend/src/api/generated/model/projectCreate.ts @@ -8,4 +8,5 @@ export interface ProjectCreate { name: string; status?: string; + team_id?: number | null; } diff --git a/frontend/src/api/generated/model/projectUpdate.ts b/frontend/src/api/generated/model/projectUpdate.ts index 84ea2e9..8e6f796 100644 --- a/frontend/src/api/generated/model/projectUpdate.ts +++ b/frontend/src/api/generated/model/projectUpdate.ts @@ -8,4 +8,5 @@ export interface ProjectUpdate { name?: string | null; status?: string | null; + team_id?: number | null; } diff --git a/frontend/src/api/generated/model/team.ts b/frontend/src/api/generated/model/team.ts new file mode 100644 index 0000000..a407a72 --- /dev/null +++ b/frontend/src/api/generated/model/team.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * OpenClaw Agency API + * OpenAPI spec version: 0.3.0 + */ + +export interface Team { + id?: number | null; + name: string; + department_id: number; + lead_employee_id?: number | null; +} diff --git a/frontend/src/api/generated/model/teamCreate.ts b/frontend/src/api/generated/model/teamCreate.ts new file mode 100644 index 0000000..bdf963a --- /dev/null +++ b/frontend/src/api/generated/model/teamCreate.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * OpenClaw Agency API + * OpenAPI spec version: 0.3.0 + */ + +export interface TeamCreate { + name: string; + department_id: number; + lead_employee_id?: number | null; +} diff --git a/frontend/src/api/generated/model/teamUpdate.ts b/frontend/src/api/generated/model/teamUpdate.ts new file mode 100644 index 0000000..1bfa271 --- /dev/null +++ b/frontend/src/api/generated/model/teamUpdate.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * OpenClaw Agency API + * OpenAPI spec version: 0.3.0 + */ + +export interface TeamUpdate { + name?: string | null; + department_id?: number | null; + lead_employee_id?: number | null; +} diff --git a/frontend/src/api/generated/org/org.ts b/frontend/src/api/generated/org/org.ts index 4c7c3f4..23f7828 100644 --- a/frontend/src/api/generated/org/org.ts +++ b/frontend/src/api/generated/org/org.ts @@ -28,6 +28,10 @@ import type { EmployeeCreate, EmployeeUpdate, HTTPValidationError, + ListTeamsTeamsGetParams, + Team, + TeamCreate, + TeamUpdate, } from ".././model"; import { customFetch } from "../../mutator"; @@ -327,6 +331,440 @@ export const useCreateDepartmentDepartmentsPost = < queryClient, ); }; +/** + * @summary List Teams + */ +export type listTeamsTeamsGetResponse200 = { + data: Team[]; + status: 200; +}; + +export type listTeamsTeamsGetResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type listTeamsTeamsGetResponseSuccess = listTeamsTeamsGetResponse200 & { + headers: Headers; +}; +export type listTeamsTeamsGetResponseError = listTeamsTeamsGetResponse422 & { + headers: Headers; +}; + +export type listTeamsTeamsGetResponse = + | listTeamsTeamsGetResponseSuccess + | listTeamsTeamsGetResponseError; + +export const getListTeamsTeamsGetUrl = (params?: ListTeamsTeamsGetParams) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/teams?${stringifiedParams}` + : `/teams`; +}; + +export const listTeamsTeamsGet = async ( + params?: ListTeamsTeamsGetParams, + options?: RequestInit, +): Promise => { + return customFetch( + getListTeamsTeamsGetUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListTeamsTeamsGetQueryKey = ( + params?: ListTeamsTeamsGetParams, +) => { + return [`/teams`, ...(params ? [params] : [])] as const; +}; + +export const getListTeamsTeamsGetQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListTeamsTeamsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getListTeamsTeamsGetQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => listTeamsTeamsGet(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListTeamsTeamsGetQueryResult = NonNullable< + Awaited> +>; +export type ListTeamsTeamsGetQueryError = HTTPValidationError; + +export function useListTeamsTeamsGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: undefined | ListTeamsTeamsGetParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListTeamsTeamsGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListTeamsTeamsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListTeamsTeamsGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListTeamsTeamsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary List Teams + */ + +export function useListTeamsTeamsGet< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListTeamsTeamsGetParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListTeamsTeamsGetQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Create Team + */ +export type createTeamTeamsPostResponse200 = { + data: Team; + status: 200; +}; + +export type createTeamTeamsPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type createTeamTeamsPostResponseSuccess = + createTeamTeamsPostResponse200 & { + headers: Headers; + }; +export type createTeamTeamsPostResponseError = + createTeamTeamsPostResponse422 & { + headers: Headers; + }; + +export type createTeamTeamsPostResponse = + | createTeamTeamsPostResponseSuccess + | createTeamTeamsPostResponseError; + +export const getCreateTeamTeamsPostUrl = () => { + return `/teams`; +}; + +export const createTeamTeamsPost = async ( + teamCreate: TeamCreate, + options?: RequestInit, +): Promise => { + return customFetch(getCreateTeamTeamsPostUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(teamCreate), + }); +}; + +export const getCreateTeamTeamsPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: TeamCreate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: TeamCreate }, + TContext +> => { + const mutationKey = ["createTeamTeamsPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: TeamCreate } + > = (props) => { + const { data } = props ?? {}; + + return createTeamTeamsPost(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateTeamTeamsPostMutationResult = NonNullable< + Awaited> +>; +export type CreateTeamTeamsPostMutationBody = TeamCreate; +export type CreateTeamTeamsPostMutationError = HTTPValidationError; + +/** + * @summary Create Team + */ +export const useCreateTeamTeamsPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: TeamCreate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: TeamCreate }, + TContext +> => { + return useMutation( + getCreateTeamTeamsPostMutationOptions(options), + queryClient, + ); +}; +/** + * @summary Update Team + */ +export type updateTeamTeamsTeamIdPatchResponse200 = { + data: Team; + status: 200; +}; + +export type updateTeamTeamsTeamIdPatchResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type updateTeamTeamsTeamIdPatchResponseSuccess = + updateTeamTeamsTeamIdPatchResponse200 & { + headers: Headers; + }; +export type updateTeamTeamsTeamIdPatchResponseError = + updateTeamTeamsTeamIdPatchResponse422 & { + headers: Headers; + }; + +export type updateTeamTeamsTeamIdPatchResponse = + | updateTeamTeamsTeamIdPatchResponseSuccess + | updateTeamTeamsTeamIdPatchResponseError; + +export const getUpdateTeamTeamsTeamIdPatchUrl = (teamId: number) => { + return `/teams/${teamId}`; +}; + +export const updateTeamTeamsTeamIdPatch = async ( + teamId: number, + teamUpdate: TeamUpdate, + options?: RequestInit, +): Promise => { + return customFetch( + getUpdateTeamTeamsTeamIdPatchUrl(teamId), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(teamUpdate), + }, + ); +}; + +export const getUpdateTeamTeamsTeamIdPatchMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { teamId: number; data: TeamUpdate }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { teamId: number; data: TeamUpdate }, + TContext +> => { + const mutationKey = ["updateTeamTeamsTeamIdPatch"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { teamId: number; data: TeamUpdate } + > = (props) => { + const { teamId, data } = props ?? {}; + + return updateTeamTeamsTeamIdPatch(teamId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateTeamTeamsTeamIdPatchMutationResult = NonNullable< + Awaited> +>; +export type UpdateTeamTeamsTeamIdPatchMutationBody = TeamUpdate; +export type UpdateTeamTeamsTeamIdPatchMutationError = HTTPValidationError; + +/** + * @summary Update Team + */ +export const useUpdateTeamTeamsTeamIdPatch = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { teamId: number; data: TeamUpdate }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { teamId: number; data: TeamUpdate }, + TContext +> => { + return useMutation( + getUpdateTeamTeamsTeamIdPatchMutationOptions(options), + queryClient, + ); +}; /** * @summary Update Department */ diff --git a/frontend/src/app/_components/Shell.tsx b/frontend/src/app/_components/Shell.tsx index 6caaa05..55b6a91 100644 --- a/frontend/src/app/_components/Shell.tsx +++ b/frontend/src/app/_components/Shell.tsx @@ -10,6 +10,7 @@ const NAV = [ { href: "/projects", label: "Projects" }, { href: "/kanban", label: "Kanban" }, { href: "/departments", label: "Departments" }, + { href: "/teams", label: "Teams" }, { href: "/people", label: "People" }, ]; diff --git a/frontend/src/app/people/page.tsx b/frontend/src/app/people/page.tsx index 96f2337..0761352 100644 --- a/frontend/src/app/people/page.tsx +++ b/frontend/src/app/people/page.tsx @@ -13,6 +13,7 @@ import { useCreateEmployeeEmployeesPost, useListDepartmentsDepartmentsGet, useListEmployeesEmployeesGet, + useListTeamsTeamsGet, } from "@/api/generated/org/org"; export default function PeoplePage() { @@ -20,12 +21,15 @@ export default function PeoplePage() { const [employeeType, setEmployeeType] = useState<"human" | "agent">("human"); const [title, setTitle] = useState(""); const [departmentId, setDepartmentId] = useState(""); + const [teamId, setTeamId] = useState(""); const [managerId, setManagerId] = useState(""); const employees = useListEmployeesEmployeesGet(); const departments = useListDepartmentsDepartmentsGet(); + const teams = useListTeamsTeamsGet({ department_id: undefined }); const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]); const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]); + const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]); const createEmployee = useCreateEmployeeEmployeesPost({ mutation: { @@ -33,6 +37,7 @@ export default function PeoplePage() { setName(""); setTitle(""); setDepartmentId(""); + setTeamId(""); setManagerId(""); employees.refetch(); }, @@ -47,6 +52,14 @@ export default function PeoplePage() { return m; }, [departmentList]); + const teamNameById = useMemo(() => { + const m = new Map(); + for (const t of teamList) { + if (t.id != null) m.set(t.id, t.name); + } + return m; + }, [teamList]); + const empNameById = useMemo(() => { const m = new Map(); for (const e of employeeList) { @@ -88,6 +101,14 @@ export default function PeoplePage() { ))} + setName(e.target.value)} autoFocus /> +
+ Owning team + +
+ + +
+ + + Create team + Define a team and attach it to a department. + + + setName(e.target.value)} /> + + + + {createTeam.error ?
{(createTeam.error as Error).message}
: null} +
+
+ + + + All teams + {sorted.length} total + + + {teams.isLoading ?
Loading…
: null} + {teams.error ?
{(teams.error as Error).message}
: null} + {!teams.isLoading && !teams.error ? ( +
    + {sorted.map((t) => ( +
  • +
    +
    {t.name}
    +
    {deptNameById.get(t.department_id) ?? `Dept#${t.department_id}`}
    +
    +
    + {t.lead_employee_id ? Lead: {empNameById.get(t.lead_employee_id) ?? `Emp#${t.lead_employee_id}`} : No lead} +
    +
  • + ))} + {sorted.length === 0 ?
  • No teams yet.
  • : null} +
+ ) : null} +
+
+
+ + ); +} From a5ec372fa46e2ee7b056b7dc7ff084558ce141cf Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 19:05:50 +0530 Subject: [PATCH 02/27] Fix CORS example + switch to psycopg v3 --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5bb061f..a8ce58a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,7 @@ fastapi uvicorn[standard] sqlmodel alembic -psycopg2-binary +psycopg[binary] python-dotenv pydantic-settings requests From 8429c02458810b548a4340fadfa91fde5a80527f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 19:18:34 +0530 Subject: [PATCH 03/27] UI provisioning + restrict task assignment to provisioned agents --- backend/app/api/work.py | 25 +++++++++++++++++++++++ frontend/src/app/people/page.tsx | 27 ++++++++++++++++++++++++- frontend/src/app/projects/[id]/page.tsx | 5 +++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/backend/app/api/work.py b/backend/app/api/work.py index d1f87aa..dc58469 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -18,6 +18,26 @@ router = APIRouter(tags=["work"]) ALLOWED_STATUSES = {"backlog", "ready", "in_progress", "review", "done", "blocked"} +def _validate_task_assignee(session: Session, assignee_employee_id: int) -> None: + """Enforce that only provisioned agents can be assigned tasks. + + Humans can be assigned regardless. + Agents must be active, notify_enabled, and have openclaw_session_key. + """ + + emp = session.get(Employee, assignee_employee_id) + if emp is None: + raise HTTPException(status_code=400, detail="Assignee employee not found") + + if emp.employee_type == "agent": + if emp.status != "active": + raise HTTPException(status_code=400, detail="Cannot assign task to inactive agent") + if not emp.notify_enabled: + raise HTTPException(status_code=400, detail="Cannot assign task to agent with notifications disabled") + if not emp.openclaw_session_key: + raise HTTPException(status_code=400, detail="Cannot assign task to unprovisioned agent") + + @router.get("/tasks", response_model=list[Task]) def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)): stmt = select(Task).order_by(Task.id.asc()) @@ -31,6 +51,9 @@ def create_task(payload: TaskCreate, background: BackgroundTasks, session: Sessi if payload.created_by_employee_id is None: payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id}) + if payload.assignee_employee_id is not None: + _validate_task_assignee(session, payload.assignee_employee_id) + # Default reviewer to the manager of the assignee (if not explicitly provided). if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None: assignee = session.get(Employee, payload.assignee_employee_id) @@ -73,6 +96,8 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, before = {"assignee_employee_id": task.assignee_employee_id, "reviewer_employee_id": task.reviewer_employee_id, "status": task.status} data = payload.model_dump(exclude_unset=True) + if "assignee_employee_id" in data and data["assignee_employee_id"] is not None: + _validate_task_assignee(session, data["assignee_employee_id"]) if "status" in data and data["status"] not in ALLOWED_STATUSES: raise HTTPException(status_code=400, detail="Invalid status") diff --git a/frontend/src/app/people/page.tsx b/frontend/src/app/people/page.tsx index 0761352..b5cbc77 100644 --- a/frontend/src/app/people/page.tsx +++ b/frontend/src/app/people/page.tsx @@ -14,9 +14,19 @@ import { useListDepartmentsDepartmentsGet, useListEmployeesEmployeesGet, useListTeamsTeamsGet, + useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost, + useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost, } from "@/api/generated/org/org"; export default function PeoplePage() { + const [actorId] = useState(() => { + if (typeof window === "undefined") return ""; + try { + return window.localStorage.getItem("actor_employee_id") ?? ""; + } catch { + return ""; + } + }); const [name, setName] = useState(""); const [employeeType, setEmployeeType] = useState<"human" | "agent">("human"); const [title, setTitle] = useState(""); @@ -31,15 +41,30 @@ export default function PeoplePage() { const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]); const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]); + const provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost(); + const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost(); + const createEmployee = useCreateEmployeeEmployeesPost({ mutation: { - onSuccess: () => { + onSuccess: async (res) => { setName(""); setTitle(""); setDepartmentId(""); setTeamId(""); setManagerId(""); + + // If an agent was created but not yet provisioned, provision immediately so it can receive tasks. + try { + const e = (res as any)?.data?.data ?? (res as any)?.data ?? null; + if (e?.employee_type === "agent" && !e.openclaw_session_key) { + await provisionEmployee.mutateAsync({ employeeId: e.id! }); + } + } catch { + // ignore; UI will show unprovisioned state + } + employees.refetch(); + teams.refetch(); }, }, }); diff --git a/frontend/src/app/projects/[id]/page.tsx b/frontend/src/app/projects/[id]/page.tsx index e4aacdc..c1fe7df 100644 --- a/frontend/src/app/projects/[id]/page.tsx +++ b/frontend/src/app/projects/[id]/page.tsx @@ -51,6 +51,7 @@ export default function ProjectDetailPage() { const employees = useListEmployeesEmployeesGet(); const employeeList = employees.data?.status === 200 ? employees.data.data : []; + const eligibleAssignees = employeeList.filter((e) => e.employee_type !== "agent" || !!e.openclaw_session_key); const members = useListProjectMembersProjectsProjectIdMembersGet(projectId); const memberList = members.data?.status === 200 ? members.data.data : []; @@ -154,7 +155,7 @@ export default function ProjectDetailPage() {
@@ -191,7 +192,7 @@ export default function ProjectDetailPage() { e.currentTarget.value = ""; }}> - {employeeList.map((e) => ( + {eligibleAssignees.map((e) => ( ))} From aec551d1043cb556124aaff132d0f477049d99e7 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 19:41:26 +0530 Subject: [PATCH 04/27] Add DB reset script + seed_data.sql --- backend/scripts/README_seed.md | 37 ++++++++ backend/scripts/reset_db.sh | 26 ++++++ backend/scripts/seed_data.sql | 160 +++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 backend/scripts/README_seed.md create mode 100755 backend/scripts/reset_db.sh create mode 100644 backend/scripts/seed_data.sql diff --git a/backend/scripts/README_seed.md b/backend/scripts/README_seed.md new file mode 100644 index 0000000..a0d36f7 --- /dev/null +++ b/backend/scripts/README_seed.md @@ -0,0 +1,37 @@ +# DB reset + seed (dev-machine) + +This repo uses Alembic migrations as schema source-of-truth. + +## Reset to the current seed + +```bash +cd backend +./scripts/reset_db.sh +``` + +Environment variables (optional): + +- `DB_NAME` (default `openclaw_agency`) +- `DB_USER` (default `postgres`) +- `DB_HOST` (default `127.0.0.1`) +- `DB_PORT` (default `5432`) +- `DB_PASSWORD` (default `postgres`) + +## Updating the seed + +The seed is a **data-only** dump (not schema). Regenerate it from the current DB state: + +```bash +cd backend +PGPASSWORD=postgres pg_dump \ + --data-only \ + --column-inserts \ + --disable-triggers \ + --no-owner \ + --no-privileges \ + -U postgres -h 127.0.0.1 -d openclaw_agency \ + > scripts/seed_data.sql + +# IMPORTANT: do not include alembic_version in the seed (migrations already set it) +# (our committed seed already has this removed) +``` diff --git a/backend/scripts/reset_db.sh b/backend/scripts/reset_db.sh new file mode 100755 index 0000000..f76d0f8 --- /dev/null +++ b/backend/scripts/reset_db.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +DB_NAME=${DB_NAME:-openclaw_agency} +DB_USER=${DB_USER:-postgres} +DB_HOST=${DB_HOST:-127.0.0.1} +DB_PORT=${DB_PORT:-5432} +DB_PASSWORD=${DB_PASSWORD:-postgres} + +cd "$(dirname "$0")/.." + +export PGPASSWORD="$DB_PASSWORD" + +# 1) wipe schema +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \ + -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' + +# 2) migrate +. .venv/bin/activate +alembic upgrade head + +# 3) seed +psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \ + -f scripts/seed_data.sql + +echo "Reset complete: $DB_USER@$DB_HOST:$DB_PORT/$DB_NAME" diff --git a/backend/scripts/seed_data.sql b/backend/scripts/seed_data.sql new file mode 100644 index 0000000..9e72c2f --- /dev/null +++ b/backend/scripts/seed_data.sql @@ -0,0 +1,160 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 16.9 (Ubuntu 16.9-0ubuntu0.24.04.1) +-- Dumped by pg_dump version 16.9 (Ubuntu 16.9-0ubuntu0.24.04.1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Data for Name: departments; Type: TABLE DATA; Schema: public; Owner: - +-- + +SET SESSION AUTHORIZATION DEFAULT; + +ALTER TABLE public.departments DISABLE TRIGGER ALL; + + + +ALTER TABLE public.departments ENABLE TRIGGER ALL; + +-- +-- Data for Name: employees; Type: TABLE DATA; Schema: public; Owner: - +-- + +ALTER TABLE public.employees DISABLE TRIGGER ALL; + + + +ALTER TABLE public.employees ENABLE TRIGGER ALL; + +-- +-- Data for Name: activities; Type: TABLE DATA; Schema: public; Owner: - +-- + +ALTER TABLE public.activities DISABLE TRIGGER ALL; + + + +ALTER TABLE public.activities ENABLE TRIGGER ALL; + +-- +-- Data for Name: teams; Type: TABLE DATA; Schema: public; Owner: - +-- + +ALTER TABLE public.teams DISABLE TRIGGER ALL; + + + +ALTER TABLE public.teams ENABLE TRIGGER ALL; + +-- +-- Data for Name: projects; Type: TABLE DATA; Schema: public; Owner: - +-- + +ALTER TABLE public.projects DISABLE TRIGGER ALL; + + + +ALTER TABLE public.projects ENABLE TRIGGER ALL; + +-- +-- Data for Name: project_members; Type: TABLE DATA; Schema: public; Owner: - +-- + +ALTER TABLE public.project_members DISABLE TRIGGER ALL; + + + +ALTER TABLE public.project_members ENABLE TRIGGER ALL; + +-- +-- Data for Name: tasks; Type: TABLE DATA; Schema: public; Owner: - +-- + +ALTER TABLE public.tasks DISABLE TRIGGER ALL; + + + +ALTER TABLE public.tasks ENABLE TRIGGER ALL; + +-- +-- Data for Name: task_comments; Type: TABLE DATA; Schema: public; Owner: - +-- + +ALTER TABLE public.task_comments DISABLE TRIGGER ALL; + + + +ALTER TABLE public.task_comments ENABLE TRIGGER ALL; + +-- +-- Name: activities_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.activities_id_seq', 1, false); + + +-- +-- Name: departments_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.departments_id_seq', 1, false); + + +-- +-- Name: employees_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.employees_id_seq', 1, true); + + +-- +-- Name: project_members_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.project_members_id_seq', 1, false); + + +-- +-- Name: projects_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.projects_id_seq', 1, false); + + +-- +-- Name: task_comments_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.task_comments_id_seq', 1, false); + + +-- +-- Name: tasks_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.tasks_id_seq', 1, false); + + +-- +-- Name: teams_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.teams_id_seq', 1, false); + + +-- +-- PostgreSQL database dump complete +-- + From 8b4b6b8000e1206002bd5950217a4995ac324b97 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 19:56:28 +0530 Subject: [PATCH 05/27] Fix reset_db.sh default DB password --- backend/scripts/reset_db.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/reset_db.sh b/backend/scripts/reset_db.sh index f76d0f8..08284d0 100755 --- a/backend/scripts/reset_db.sh +++ b/backend/scripts/reset_db.sh @@ -5,7 +5,7 @@ DB_NAME=${DB_NAME:-openclaw_agency} DB_USER=${DB_USER:-postgres} DB_HOST=${DB_HOST:-127.0.0.1} DB_PORT=${DB_PORT:-5432} -DB_PASSWORD=${DB_PASSWORD:-postgres} +DB_PASSWORD=${DB_PASSWORD:-REDACTED} cd "$(dirname "$0")/.." From 8d3153d21a5998b5291a06da021a28256051a0fa Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 19:57:36 +0530 Subject: [PATCH 06/27] Seed: keep Jarvis + Abhimanyu --- backend/scripts/seed_data.sql | 185 +++++++--------------------------- 1 file changed, 38 insertions(+), 147 deletions(-) diff --git a/backend/scripts/seed_data.sql b/backend/scripts/seed_data.sql index 9e72c2f..dffdbf2 100644 --- a/backend/scripts/seed_data.sql +++ b/backend/scripts/seed_data.sql @@ -1,160 +1,51 @@ --- --- PostgreSQL database dump --- +-- Mission Control seed data (minimal) +-- Keep this data-only seed small and deterministic. +-- NOTE: Do NOT include alembic_version here; migrations manage it. --- Dumped from database version 16.9 (Ubuntu 16.9-0ubuntu0.24.04.1) --- Dumped by pg_dump version 16.9 (Ubuntu 16.9-0ubuntu0.24.04.1) - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; SET client_min_messages = warning; SET row_security = off; --- --- Data for Name: departments; Type: TABLE DATA; Schema: public; Owner: - --- - -SET SESSION AUTHORIZATION DEFAULT; - -ALTER TABLE public.departments DISABLE TRIGGER ALL; - - - -ALTER TABLE public.departments ENABLE TRIGGER ALL; - --- --- Data for Name: employees; Type: TABLE DATA; Schema: public; Owner: - --- - +-- Disable triggers to avoid FK ordering issues during seed. ALTER TABLE public.employees DISABLE TRIGGER ALL; - - - -ALTER TABLE public.employees ENABLE TRIGGER ALL; - --- --- Data for Name: activities; Type: TABLE DATA; Schema: public; Owner: - --- - +ALTER TABLE public.departments DISABLE TRIGGER ALL; +ALTER TABLE public.teams DISABLE TRIGGER ALL; +ALTER TABLE public.projects DISABLE TRIGGER ALL; +ALTER TABLE public.tasks DISABLE TRIGGER ALL; +ALTER TABLE public.task_comments DISABLE TRIGGER ALL; +ALTER TABLE public.project_members DISABLE TRIGGER ALL; ALTER TABLE public.activities DISABLE TRIGGER ALL; +-- Employees (keep only Jarvis + Abhimanyu) +INSERT INTO public.employees (id, name, employee_type, department_id, manager_id, title, status, openclaw_session_key, notify_enabled, team_id) +VALUES + (1, 'Jarvis', 'agent', NULL, NULL, 'CTO', 'active', NULL, true, NULL), + (2, 'Abhimanyu', 'human', NULL, NULL, 'CEO', 'active', NULL, false, NULL) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + employee_type = EXCLUDED.employee_type, + department_id = EXCLUDED.department_id, + manager_id = EXCLUDED.manager_id, + title = EXCLUDED.title, + status = EXCLUDED.status, + openclaw_session_key = EXCLUDED.openclaw_session_key, + notify_enabled = EXCLUDED.notify_enabled, + team_id = EXCLUDED.team_id; +-- Fix sequences (avoid PK reuse after explicit ids) +SELECT setval('employees_id_seq', (SELECT COALESCE(max(id), 1) FROM public.employees)); +SELECT setval('departments_id_seq', (SELECT COALESCE(max(id), 1) FROM public.departments)); +SELECT setval('teams_id_seq', (SELECT COALESCE(max(id), 1) FROM public.teams)); +SELECT setval('projects_id_seq', (SELECT COALESCE(max(id), 1) FROM public.projects)); +SELECT setval('tasks_id_seq', (SELECT COALESCE(max(id), 1) FROM public.tasks)); +SELECT setval('task_comments_id_seq', (SELECT COALESCE(max(id), 1) FROM public.task_comments)); +SELECT setval('project_members_id_seq', (SELECT COALESCE(max(id), 1) FROM public.project_members)); +SELECT setval('activities_id_seq', (SELECT COALESCE(max(id), 1) FROM public.activities)); -ALTER TABLE public.activities ENABLE TRIGGER ALL; - --- --- Data for Name: teams; Type: TABLE DATA; Schema: public; Owner: - --- - -ALTER TABLE public.teams DISABLE TRIGGER ALL; - - - +ALTER TABLE public.employees ENABLE TRIGGER ALL; +ALTER TABLE public.departments ENABLE TRIGGER ALL; ALTER TABLE public.teams ENABLE TRIGGER ALL; - --- --- Data for Name: projects; Type: TABLE DATA; Schema: public; Owner: - --- - -ALTER TABLE public.projects DISABLE TRIGGER ALL; - - - ALTER TABLE public.projects ENABLE TRIGGER ALL; - --- --- Data for Name: project_members; Type: TABLE DATA; Schema: public; Owner: - --- - -ALTER TABLE public.project_members DISABLE TRIGGER ALL; - - - -ALTER TABLE public.project_members ENABLE TRIGGER ALL; - --- --- Data for Name: tasks; Type: TABLE DATA; Schema: public; Owner: - --- - -ALTER TABLE public.tasks DISABLE TRIGGER ALL; - - - ALTER TABLE public.tasks ENABLE TRIGGER ALL; - --- --- Data for Name: task_comments; Type: TABLE DATA; Schema: public; Owner: - --- - -ALTER TABLE public.task_comments DISABLE TRIGGER ALL; - - - ALTER TABLE public.task_comments ENABLE TRIGGER ALL; - --- --- Name: activities_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.activities_id_seq', 1, false); - - --- --- Name: departments_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.departments_id_seq', 1, false); - - --- --- Name: employees_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.employees_id_seq', 1, true); - - --- --- Name: project_members_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.project_members_id_seq', 1, false); - - --- --- Name: projects_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.projects_id_seq', 1, false); - - --- --- Name: task_comments_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.task_comments_id_seq', 1, false); - - --- --- Name: tasks_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.tasks_id_seq', 1, false); - - --- --- Name: teams_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.teams_id_seq', 1, false); - - --- --- PostgreSQL database dump complete --- - +ALTER TABLE public.project_members ENABLE TRIGGER ALL; +ALTER TABLE public.activities ENABLE TRIGGER ALL; From 9b0e5cef5c3fc7d587ae54c2587e0cdea3c4bf6a Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 19:59:02 +0530 Subject: [PATCH 07/27] Do not hardcode DB password in reset script --- backend/scripts/reset_db.sh | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/scripts/reset_db.sh b/backend/scripts/reset_db.sh index 08284d0..5a8e0e7 100755 --- a/backend/scripts/reset_db.sh +++ b/backend/scripts/reset_db.sh @@ -5,10 +5,39 @@ DB_NAME=${DB_NAME:-openclaw_agency} DB_USER=${DB_USER:-postgres} DB_HOST=${DB_HOST:-127.0.0.1} DB_PORT=${DB_PORT:-5432} -DB_PASSWORD=${DB_PASSWORD:-REDACTED} + +# Never hardcode passwords in git. Prefer: +# - DB_PASSWORD env var, or +# - infer from backend/.env DATABASE_URL +DB_PASSWORD=${DB_PASSWORD:-} cd "$(dirname "$0")/.." +if [[ -z "${DB_PASSWORD}" ]] && [[ -f .env ]]; then + DB_PASSWORD=$(python3 - <<'PY' +import os +from pathlib import Path +from urllib.parse import urlparse + +def parse_database_url(url: str) -> str: + # supports postgresql+psycopg://user:pass@host:port/db + u = urlparse(url) + return u.password or "" + +for line in Path('.env').read_text().splitlines(): + if line.startswith('DATABASE_URL='): + print(parse_database_url(line.split('=',1)[1].strip())) + break +PY +) +fi + +if [[ -z "${DB_PASSWORD}" ]]; then + echo "ERROR: DB_PASSWORD not set and could not infer it from backend/.env DATABASE_URL" >&2 + echo "Set DB_PASSWORD=... or create backend/.env with DATABASE_URL" >&2 + exit 2 +fi + export PGPASSWORD="$DB_PASSWORD" # 1) wipe schema From 1a64f21e7b09d9b7b31b4ef3e9aa6da363eaaa76 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 20:07:22 +0530 Subject: [PATCH 08/27] Agent prompt: require status updates + comments until done --- backend/app/api/org.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/app/api/org.py b/backend/app/api/org.py index 8fa8841..e1f693b 100644 --- a/backend/app/api/org.py +++ b/backend/app/api/org.py @@ -44,7 +44,11 @@ def _default_agent_prompt(emp: Employee) -> str: "- OpenAPI schema: GET /openapi.json\n\n" "Rules:\n" "- Use the Mission Control API only (no UI).\n" - "- When notified about tasks/comments, respond with concise, actionable updates.\n" + "- You are responsible for driving assigned work to completion.\n" + "- For every task you own: (1) read it, (2) plan next steps, (3) post progress comments, (4) update status as it moves (backlog/ready/in_progress/review/done/blocked).\n" + "- Always leave an audit trail: add a comment whenever you start work, whenever you learn something important, and whenever you change status.\n" + "- If blocked, set status=blocked and comment what you need (missing access, unclear requirements, etc.).\n" + "- When notified about tasks/comments, respond with concise, actionable updates and immediately sync the task state in Mission Control.\n" "- Do not invent facts; ask for missing context.\n" ) From 3895d74aee877f404eec00fc5cc59aa0afc87f70 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 20:08:42 +0530 Subject: [PATCH 09/27] Agent prompt: fix base URL + header formatting --- backend/app/api/org.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/api/org.py b/backend/app/api/org.py index e1f693b..fdd80a7 100644 --- a/backend/app/api/org.py +++ b/backend/app/api/org.py @@ -34,9 +34,9 @@ def _default_agent_prompt(emp: Employee) -> str: f"Your employee_id is {emp.id}.\n" f"Title: {title}. Department id: {dept}.\n\n" "Mission Control API access (no UI):\n" - "- Base URL: http://127.0.0.1:8000 (if running locally) OR http://:8000\n" - "- Auth: none. REQUIRED header on write operations: X-Actor-Employee-Id: \n" - f" For you: X-Actor-Employee-Id: {emp.id}\n\n" + "- Base URL: http://127.0.0.1:8000 (if running on the same machine as the backend) OR http://:8000\n" + "- Auth: none. REQUIRED header on ALL write operations: X-Actor-Employee-Id: \n" + f" Example for you: X-Actor-Employee-Id: {emp.id}\n\n" "Common endpoints (JSON):\n" "- GET /tasks, POST /tasks\n" "- GET /task-comments, POST /task-comments\n" From 002bd08f33c808db0adf6512f272b0bbdd7b781e Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 20:11:12 +0530 Subject: [PATCH 10/27] Agent prompt: use LAN base URL (no localhost) --- backend/app/api/org.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/backend/app/api/org.py b/backend/app/api/org.py index fdd80a7..66b84f7 100644 --- a/backend/app/api/org.py +++ b/backend/app/api/org.py @@ -20,6 +20,45 @@ from app.schemas.org import ( router = APIRouter(tags=["org"]) +def _public_api_base_url() -> str: + """Return a LAN-reachable base URL for the Mission Control API. + + + Priority: + 1) MISSION_CONTROL_BASE_URL env var (recommended) + 2) First non-loopback IPv4 from `hostname -I` + + + Never returns localhost/ because agents may run on another machine.""" + + + import os, re, subprocess + + + explicit = os.environ.get("MISSION_CONTROL_BASE_URL") + if explicit: + return explicit.rstrip("/") + + + try: + out = subprocess.check_output(["bash", "-lc", "hostname -I"], text=True).strip() + # pick first RFC1918-ish IPv4, skip docker/loopback + ips = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", out) + for ip in ips: + if ip.startswith("127."): + continue + if ip.startswith("172.17."): + continue + if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.16.") or ip.startswith("172."): + return f"http://{ip}:8000" + except Exception: + pass + + + # Fallback placeholder (should be overridden by env var) + return "http://:8000" + + def _default_agent_prompt(emp: Employee) -> str: """Generate a conservative default prompt for a newly-created agent employee. @@ -34,7 +73,7 @@ def _default_agent_prompt(emp: Employee) -> str: f"Your employee_id is {emp.id}.\n" f"Title: {title}. Department id: {dept}.\n\n" "Mission Control API access (no UI):\n" - "- Base URL: http://127.0.0.1:8000 (if running on the same machine as the backend) OR http://:8000\n" + f"- Base URL: {_public_api_base_url()}\n" "- Auth: none. REQUIRED header on ALL write operations: X-Actor-Employee-Id: \n" f" Example for you: X-Actor-Employee-Id: {emp.id}\n\n" "Common endpoints (JSON):\n" From a8f097817db6ef09d3a4ea2e4d46ac55f6ad6e9e Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 20:15:38 +0530 Subject: [PATCH 11/27] chore(backend): add black/isort/flake8 + pre-commit --- .pre-commit-config.yaml | 28 ++++ backend/.flake8 | 10 ++ backend/alembic.ini | 2 +- backend/alembic/README | 2 +- .../alembic/__pycache__/env.cpython-312.pyc | Bin 2468 -> 0 bytes backend/alembic/env.py | 6 +- ...2c1b9c8e12_add_teams_and_team_ownership.py | 2 +- .../versions/bacd5e6a253d_baseline_schema.py | 1 + backend/app/api/activities.py | 4 +- backend/app/api/org.py | 64 ++++++-- backend/app/api/projects.py | 59 ++++++-- backend/app/api/work.py | 139 +++++++++++++++--- .../core/__pycache__/config.cpython-312.pyc | Bin 677 -> 693 bytes .../db/__pycache__/session.cpython-312.pyc | Bin 928 -> 944 bytes backend/app/integrations/notify.py | 4 +- backend/app/integrations/openclaw.py | 9 +- backend/app/models/__init__.py | 2 +- backend/app/models/org.py | 2 - backend/pyproject.toml | 9 ++ backend/requirements-dev.txt | 4 + backend/scripts/lint.sh | 9 ++ frontend/public/file.svg | 2 +- frontend/public/globe.svg | 2 +- frontend/public/next.svg | 2 +- frontend/public/vercel.svg | 2 +- frontend/public/window.svg | 2 +- 26 files changed, 299 insertions(+), 67 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 backend/.flake8 delete mode 100644 backend/alembic/__pycache__/env.cpython-312.pyc create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements-dev.txt create mode 100755 backend/scripts/lint.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1c99266 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3 + files: ^backend/.*\.py$ + + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + files: ^backend/.*\.py$ + + - repo: https://github.com/PyCQA/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + files: ^backend/.*\.py$ + args: [--config=backend/.flake8] diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 0000000..e2203bb --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, W503, E501 +exclude = + .venv, + backend/.venv, + alembic, + backend/alembic, + **/__pycache__, + **/*.pyc diff --git a/backend/alembic.ini b/backend/alembic.ini index e8b4e3c..f0da7e5 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -86,7 +86,7 @@ path_separator = os # database URL. This is consumed by the user-maintained env.py script only. # other means of configuring database URLs may be customized within the env.py # file. -sqlalchemy.url = +sqlalchemy.url = [post_write_hooks] diff --git a/backend/alembic/README b/backend/alembic/README index 98e4f9c..2500aa1 100644 --- a/backend/alembic/README +++ b/backend/alembic/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/backend/alembic/__pycache__/env.cpython-312.pyc b/backend/alembic/__pycache__/env.cpython-312.pyc deleted file mode 100644 index ddf714ab5f1aac98ccd03a0e91c87da23141344e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2468 zcmah~&2Jk;6rc5ec-NneW2cQ{QrAhG8Z|W>s$MG85}_WTQA&|eeOYb1V|UGdxU=gf zDXB0L2M|c8r`}K{IDpc>0nx&ZOO~W^x^hJk?SWfRT1CoO73s)`E!7ENF)I zLPP|K7gH5SSyF5)w}}1&+NDbp=zF8V1=L_&Lyiz|Dx-!}R%7mQ>wQaRcsC8fOC$Kp z8NkrcfBYIbY#LblScZC7@5dUjfu_)#$Yz#947v(_sAPq`ik6uYzwm(Y!_yJCCyZA$d(2%t~#>SbnKGg2bMt5B2VhU zr$sQyP<~_y1GHoZ(q}7GibO9#TBdAhbrJxRq`}gr@y(MY-86RCLbhr(9q>seKuJIQ zLzp0ck&!P;?fEVj?Qj{F%WTJz-D9)cW7+Mo!rhA-nNu$$o)?}cP-LVd61FUaGhj?gvLX+T{w&F>&)j=PzZ4gEPz$QQ&km@726!(OF zON`QzuzNZ$)f38^sRgoL8PJ+1G?kvX(9-oq@a^|n>eR0hLL=e(CgYUK!c=0tcu+Zh zS$NU@Ig}+GljC4w)3oOE{SwK%Tq34lA+um}k{W*x6I4h#+esJQbn&|zo!Rs5?D@^~ zh2PQ_e|o=tsau@;R%>6{NEKXe>S=0vTg+@HbKTU~ow?g{&%@lP(0+F(h9a4clyjxr zrZl;OIDY2t>wfW!yW;rW$}h(sikF|p)17$HjTblLC!vI-nQm;f6Dzo}!d7e=s!N*L z3&Tc#_nrXk4mH{z2>0!2IPCosZ_KmogOSYJXZZ(3kY0e0C zTEz5N+ad6F(7Srvf@R{;Q7^= z$XrrsHenON!A{zs;VHbX`>!6M_2{u+L})FNJY|pc0YM*GnRZi84BBTe+4S}CTNt5P z@{*bzG&Y%fC2^{!&ye2)_r-G{ZRz+eat36yJKD=I?XV2PJVu3QXyzH3_#Ks>q3Ory zwHE?njy{o&d_DQaWLtQ`N*y-qve^x`_=rn str: Never returns localhost/ because agents may run on another machine.""" - - import os, re, subprocess - + import os + import re + import subprocess explicit = os.environ.get("MISSION_CONTROL_BASE_URL") if explicit: return explicit.rstrip("/") - try: out = subprocess.check_output(["bash", "-lc", "hostname -I"], text=True).strip() # pick first RFC1918-ish IPv4, skip docker/loopback @@ -49,12 +48,16 @@ def _public_api_base_url() -> str: continue if ip.startswith("172.17."): continue - if ip.startswith("192.168.") or ip.startswith("10.") or ip.startswith("172.16.") or ip.startswith("172."): + if ( + ip.startswith("192.168.") + or ip.startswith("10.") + or ip.startswith("172.16.") + or ip.startswith("172.") + ): return f"http://{ip}:8000" except Exception: pass - # Fallback placeholder (should be overridden by env var) return "http://:8000" @@ -202,7 +205,11 @@ def create_team( entity_type="team", entity_id=team.id, verb="created", - payload={"name": team.name, "department_id": team.department_id, "lead_employee_id": team.lead_employee_id}, + payload={ + "name": team.name, + "department_id": team.department_id, + "lead_employee_id": team.lead_employee_id, + }, ) session.commit() except IntegrityError: @@ -231,7 +238,14 @@ def update_team( session.add(team) try: session.flush() - log_activity(session, actor_employee_id=actor_employee_id, entity_type="team", entity_id=team.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="team", + entity_id=team.id, + verb="updated", + payload=data, + ) session.commit() except IntegrityError: session.rollback() @@ -241,7 +255,6 @@ def update_team( return team - @router.post("/departments", response_model=Department) def create_department( payload: DepartmentCreate, @@ -270,7 +283,9 @@ def create_department( session.commit() except IntegrityError: session.rollback() - raise HTTPException(status_code=409, detail="Department already exists or violates constraints") + raise HTTPException( + status_code=409, detail="Department already exists or violates constraints" + ) session.refresh(dept) return dept @@ -294,7 +309,14 @@ def update_department( session.add(dept) session.commit() session.refresh(dept) - log_activity(session, actor_employee_id=actor_employee_id, entity_type="department", entity_id=dept.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="department", + entity_id=dept.id, + verb="updated", + payload=data, + ) session.commit() return dept @@ -354,7 +376,14 @@ def update_employee( session.add(emp) try: session.flush() - log_activity(session, actor_employee_id=actor_employee_id, entity_type="employee", entity_id=emp.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="employee", + entity_id=emp.id, + verb="updated", + payload=data, + ) session.commit() except IntegrityError: session.rollback() @@ -401,7 +430,10 @@ def deprovision_employee_agent( try: client.tools_invoke( "sessions_send", - {"sessionKey": emp.openclaw_session_key, "message": "You are being deprovisioned. Stop all work and ignore future messages."}, + { + "sessionKey": emp.openclaw_session_key, + "message": "You are being deprovisioned. Stop all work and ignore future messages.", + }, timeout_s=5.0, ) except Exception: diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 2b788ee..2f90232 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select -from app.api.utils import log_activity, get_actor_employee_id +from app.api.utils import get_actor_employee_id, log_activity from app.db.session import get_session from app.models.projects import Project, ProjectMember from app.schemas.projects import ProjectCreate, ProjectUpdate @@ -45,15 +45,21 @@ def create_project( session.commit() except IntegrityError: session.rollback() - raise HTTPException(status_code=409, detail="Project already exists or violates constraints") + raise HTTPException( + status_code=409, detail="Project already exists or violates constraints" + ) session.refresh(proj) return proj - @router.patch("/{project_id}", response_model=Project) -def update_project(project_id: int, payload: ProjectUpdate, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def update_project( + project_id: int, + payload: ProjectUpdate, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): proj = session.get(Project, project_id) if not proj: raise HTTPException(status_code=404, detail="Project not found") @@ -65,7 +71,14 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D session.add(proj) session.commit() session.refresh(proj) - log_activity(session, actor_employee_id=actor_employee_id, entity_type="project", entity_id=proj.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="project", + entity_id=proj.id, + verb="updated", + payload=data, + ) session.commit() return proj @@ -73,16 +86,29 @@ def update_project(project_id: int, payload: ProjectUpdate, session: Session = D @router.get("/{project_id}/members", response_model=list[ProjectMember]) def list_project_members(project_id: int, session: Session = Depends(get_session)): return session.exec( - select(ProjectMember).where(ProjectMember.project_id == project_id).order_by(ProjectMember.id.asc()) + select(ProjectMember) + .where(ProjectMember.project_id == project_id) + .order_by(ProjectMember.id.asc()) ).all() @router.post("/{project_id}/members", response_model=ProjectMember) -def add_project_member(project_id: int, payload: ProjectMember, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): - existing = session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id)).first() +def add_project_member( + project_id: int, + payload: ProjectMember, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + existing = session.exec( + select(ProjectMember).where( + ProjectMember.project_id == project_id, ProjectMember.employee_id == payload.employee_id + ) + ).first() if existing: raise HTTPException(status_code=409, detail="Member already added") - member = ProjectMember(project_id=project_id, employee_id=payload.employee_id, role=payload.role) + member = ProjectMember( + project_id=project_id, employee_id=payload.employee_id, role=payload.role + ) session.add(member) session.commit() session.refresh(member) @@ -99,7 +125,12 @@ def add_project_member(project_id: int, payload: ProjectMember, session: Session @router.delete("/{project_id}/members/{member_id}") -def remove_project_member(project_id: int, member_id: int, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def remove_project_member( + project_id: int, + member_id: int, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): member = session.get(ProjectMember, member_id) if not member or member.project_id != project_id: raise HTTPException(status_code=404, detail="Project member not found") @@ -118,7 +149,13 @@ def remove_project_member(project_id: int, member_id: int, session: Session = De @router.patch("/{project_id}/members/{member_id}", response_model=ProjectMember) -def update_project_member(project_id: int, member_id: int, payload: ProjectMember, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def update_project_member( + project_id: int, + member_id: int, + payload: ProjectMember, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): member = session.get(ProjectMember, member_id) if not member or member.project_id != project_id: raise HTTPException(status_code=404, detail="Project member not found") diff --git a/backend/app/api/work.py b/backend/app/api/work.py index dc58469..2a01510 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -2,16 +2,16 @@ from __future__ import annotations from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks -from sqlmodel import Session, select +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select -from app.api.utils import log_activity, get_actor_employee_id +from app.api.utils import get_actor_employee_id, log_activity from app.db.session import get_session +from app.integrations.notify import NotifyContext, notify_openclaw from app.models.org import Employee from app.models.work import Task, TaskComment from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate -from app.integrations.notify import NotifyContext, notify_openclaw router = APIRouter(tags=["work"]) @@ -33,7 +33,9 @@ def _validate_task_assignee(session: Session, assignee_employee_id: int) -> None if emp.status != "active": raise HTTPException(status_code=400, detail="Cannot assign task to inactive agent") if not emp.notify_enabled: - raise HTTPException(status_code=400, detail="Cannot assign task to agent with notifications disabled") + raise HTTPException( + status_code=400, detail="Cannot assign task to agent with notifications disabled" + ) if not emp.openclaw_session_key: raise HTTPException(status_code=400, detail="Cannot assign task to unprovisioned agent") @@ -47,9 +49,16 @@ def list_tasks(project_id: int | None = None, session: Session = Depends(get_ses @router.post("/tasks", response_model=Task) -def create_task(payload: TaskCreate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def create_task( + payload: TaskCreate, + background: BackgroundTasks, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): if payload.created_by_employee_id is None: - payload = TaskCreate(**{**payload.model_dump(), "created_by_employee_id": actor_employee_id}) + payload = TaskCreate( + **{**payload.model_dump(), "created_by_employee_id": actor_employee_id} + ) if payload.assignee_employee_id is not None: _validate_task_assignee(session, payload.assignee_employee_id) @@ -58,7 +67,9 @@ def create_task(payload: TaskCreate, background: BackgroundTasks, session: Sessi if payload.reviewer_employee_id is None and payload.assignee_employee_id is not None: assignee = session.get(Employee, payload.assignee_employee_id) if assignee is not None and assignee.manager_id is not None: - payload = TaskCreate(**{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id}) + payload = TaskCreate( + **{**payload.model_dump(), "reviewer_employee_id": assignee.manager_id} + ) task = Task(**payload.model_dump()) if task.status not in ALLOWED_STATUSES: @@ -82,18 +93,32 @@ def create_task(payload: TaskCreate, background: BackgroundTasks, session: Sessi raise HTTPException(status_code=409, detail="Task create violates constraints") session.refresh(task) - background.add_task(notify_openclaw, session, NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task=task)) + background.add_task( + notify_openclaw, + session, + NotifyContext(event="task.created", actor_employee_id=actor_employee_id, task=task), + ) # Explicitly return a serializable payload (guards against empty {} responses) return Task.model_validate(task) @router.patch("/tasks/{task_id}", response_model=Task) -def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def update_task( + task_id: int, + payload: TaskUpdate, + background: BackgroundTasks, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): task = session.get(Task, task_id) if not task: raise HTTPException(status_code=404, detail="Task not found") - before = {"assignee_employee_id": task.assignee_employee_id, "reviewer_employee_id": task.reviewer_employee_id, "status": task.status} + before = { + "assignee_employee_id": task.assignee_employee_id, + "reviewer_employee_id": task.reviewer_employee_id, + "status": task.status, + } data = payload.model_dump(exclude_unset=True) if "assignee_employee_id" in data and data["assignee_employee_id"] is not None: @@ -108,7 +133,14 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, try: session.flush() - log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=task.id, verb="updated", payload=data) + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="task", + entity_id=task.id, + verb="updated", + payload=data, + ) session.commit() except IntegrityError: session.rollback() @@ -119,19 +151,53 @@ def update_task(task_id: int, payload: TaskUpdate, background: BackgroundTasks, # notify based on meaningful changes changed = {} if before.get("assignee_employee_id") != task.assignee_employee_id: - changed["assignee_employee_id"] = {"from": before.get("assignee_employee_id"), "to": task.assignee_employee_id} - background.add_task(notify_openclaw, session, NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task, changed_fields=changed)) + changed["assignee_employee_id"] = { + "from": before.get("assignee_employee_id"), + "to": task.assignee_employee_id, + } + background.add_task( + notify_openclaw, + session, + NotifyContext( + event="task.assigned", + actor_employee_id=actor_employee_id, + task=task, + changed_fields=changed, + ), + ) if before.get("status") != task.status: changed["status"] = {"from": before.get("status"), "to": task.status} - background.add_task(notify_openclaw, session, NotifyContext(event="status.changed", actor_employee_id=actor_employee_id, task=task, changed_fields=changed)) + background.add_task( + notify_openclaw, + session, + NotifyContext( + event="status.changed", + actor_employee_id=actor_employee_id, + task=task, + changed_fields=changed, + ), + ) if not changed and data: - background.add_task(notify_openclaw, session, NotifyContext(event="task.updated", actor_employee_id=actor_employee_id, task=task, changed_fields=data)) + background.add_task( + notify_openclaw, + session, + NotifyContext( + event="task.updated", + actor_employee_id=actor_employee_id, + task=task, + changed_fields=data, + ), + ) return Task.model_validate(task) @router.delete("/tasks/{task_id}") -def delete_task(task_id: int, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def delete_task( + task_id: int, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): task = session.get(Task, task_id) if not task: raise HTTPException(status_code=404, detail="Task not found") @@ -139,7 +205,13 @@ def delete_task(task_id: int, session: Session = Depends(get_session), actor_emp session.delete(task) try: session.flush() - log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=task_id, verb="deleted") + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="task", + entity_id=task_id, + verb="deleted", + ) session.commit() except IntegrityError: session.rollback() @@ -150,20 +222,35 @@ def delete_task(task_id: int, session: Session = Depends(get_session), actor_emp @router.get("/task-comments", response_model=list[TaskComment]) def list_task_comments(task_id: int, session: Session = Depends(get_session)): - return session.exec(select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())).all() + return session.exec( + select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc()) + ).all() @router.post("/task-comments", response_model=TaskComment) -def create_task_comment(payload: TaskCommentCreate, background: BackgroundTasks, session: Session = Depends(get_session), actor_employee_id: int = Depends(get_actor_employee_id)): +def create_task_comment( + payload: TaskCommentCreate, + background: BackgroundTasks, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): if payload.author_employee_id is None: - payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id}) + payload = TaskCommentCreate( + **{**payload.model_dump(), "author_employee_id": actor_employee_id} + ) c = TaskComment(**payload.model_dump()) session.add(c) try: session.flush() - log_activity(session, actor_employee_id=actor_employee_id, entity_type="task", entity_id=c.task_id, verb="commented") + log_activity( + session, + actor_employee_id=actor_employee_id, + entity_type="task", + entity_id=c.task_id, + verb="commented", + ) session.commit() except IntegrityError: session.rollback() @@ -172,5 +259,11 @@ def create_task_comment(payload: TaskCommentCreate, background: BackgroundTasks, session.refresh(c) task = session.get(Task, c.task_id) if task is not None: - background.add_task(notify_openclaw, session, NotifyContext(event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c)) + background.add_task( + notify_openclaw, + session, + NotifyContext( + event="comment.created", actor_employee_id=actor_employee_id, task=task, comment=c + ), + ) return TaskComment.model_validate(c) diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc index 29ad631d1dcf6e941c86e23f0552e463f5ff4b1c..2695f548abf0cb8749b2d3ea767d4064a5ca15d4 100644 GIT binary patch delta 55 zcmZ3=x|NmdG%qg~0}$|U-N-eIQQlKOBR@A)Ke0G5Be5tkPd}hC8OX>DD9X=DO)e>( IyqWP60KR_`tN;K2 delta 39 tcmdnWx|EgcG%qg~0}vQ2-pDnJk>653BR@A)Ke0G5Be5tkZ}MTrPXNzZ3|;^L diff --git a/backend/app/db/__pycache__/session.cpython-312.pyc b/backend/app/db/__pycache__/session.cpython-312.pyc index 0d1077b347bf6e8eddc53bc97a942565b7d514c7..7d7d28362e8b937a41e70d7459061dea9b747208 100644 GIT binary patch delta 57 zcmZ3$zJZZVi4w#B delta 41 vcmdnMzJQ(kG%qg~0}vQ2ZrI43!N_l+pOK%Ns-IY#n2}hNn74TnqZ|_e str: if len(snippet) > 180: snippet = snippet[:177] + "..." snippet = f"\nComment: {snippet}" - return f"New comment on {base}.{snippet}\nWork ONE task only; reply/update in Mission Control." + return ( + f"New comment on {base}.{snippet}\nWork ONE task only; reply/update in Mission Control." + ) if ctx.event == "status.changed": return f"Status changed on {base} → {t.status}.\nWork ONE task only; update Mission Control with next step." diff --git a/backend/app/integrations/openclaw.py b/backend/app/integrations/openclaw.py index 26fba7d..b530112 100644 --- a/backend/app/integrations/openclaw.py +++ b/backend/app/integrations/openclaw.py @@ -19,7 +19,14 @@ class OpenClawClient: return None return cls(url, token) - def tools_invoke(self, tool: str, args: dict[str, Any], *, session_key: str | None = None, timeout_s: float = 5.0) -> dict[str, Any]: + def tools_invoke( + self, + tool: str, + args: dict[str, Any], + *, + session_key: str | None = None, + timeout_s: float = 5.0, + ) -> dict[str, Any]: payload: dict[str, Any] = {"tool": tool, "args": args} if session_key is not None: payload["sessionKey"] = session_key diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9bd9a65..85f0fd7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,5 @@ from app.models.activity import Activity -from app.models.org import Department, Team, Employee +from app.models.org import Department, Employee, Team from app.models.projects import Project, ProjectMember from app.models.work import Task, TaskComment diff --git a/backend/app/models/org.py b/backend/app/models/org.py index 81a07c4..826841d 100644 --- a/backend/app/models/org.py +++ b/backend/app/models/org.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Optional - from sqlmodel import Field, SQLModel diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..02f5b30 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,9 @@ +[tool.black] +line-length = 100 +target-version = ["py312"] +extend-exclude = '(\.venv|alembic/versions)' + +[tool.isort] +profile = "black" +line_length = 100 +skip = [".venv", "alembic/versions"] diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..4d45217 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +black==24.10.0 +isort==5.13.2 +flake8==7.1.1 +pre-commit==4.1.0 diff --git a/backend/scripts/lint.sh b/backend/scripts/lint.sh new file mode 100755 index 0000000..36a0808 --- /dev/null +++ b/backend/scripts/lint.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +. .venv/bin/activate + +python -m black . +python -m isort . +python -m flake8 . diff --git a/frontend/public/file.svg b/frontend/public/file.svg index 004145c..16fe3d3 100644 --- a/frontend/public/file.svg +++ b/frontend/public/file.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg index 567f17b..c7215fe 100644 --- a/frontend/public/globe.svg +++ b/frontend/public/globe.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/next.svg b/frontend/public/next.svg index 5174b28..5bb00d4 100644 --- a/frontend/public/next.svg +++ b/frontend/public/next.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg index 7705396..5215157 100644 --- a/frontend/public/vercel.svg +++ b/frontend/public/vercel.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/public/window.svg b/frontend/public/window.svg index b2b2a44..d05e7a1 100644 --- a/frontend/public/window.svg +++ b/frontend/public/window.svg @@ -1 +1 @@ - \ No newline at end of file + From 61aaee02141b9d4976876c70a0b107b120f07783 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 20:32:38 +0530 Subject: [PATCH 12/27] feat(notify): dispatch actionable instructions to assigned agents --- backend/app/integrations/notify.py | 59 +++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/backend/app/integrations/notify.py b/backend/app/integrations/notify.py index 5f44c32..5bcd307 100644 --- a/backend/app/integrations/notify.py +++ b/backend/app/integrations/notify.py @@ -20,20 +20,19 @@ class NotifyContext: changed_fields: dict | None = None -def _employee_session_keys(session: Session, employee_ids: Iterable[int]) -> list[str]: +def _employees_with_session_keys(session: Session, employee_ids: Iterable[int]) -> list[Employee]: ids = sorted({i for i in employee_ids if i is not None}) if not ids: return [] emps = session.exec(select(Employee).where(Employee.id.in_(ids))).all() - keys: list[str] = [] + out: list[Employee] = [] for e in emps: if not getattr(e, "notify_enabled", True): continue - sk = getattr(e, "openclaw_session_key", None) - if sk: - keys.append(sk) - return sorted(set(keys)) + if getattr(e, "openclaw_session_key", None): + out.append(e) + return out def _project_pm_employee_ids(session: Session, project_id: int) -> set[int]: @@ -86,12 +85,35 @@ def resolve_recipients(session: Session, ctx: NotifyContext) -> set[int]: return recipients -def build_message(ctx: NotifyContext) -> str: +def build_message(ctx: NotifyContext, recipient: Employee) -> str: t = ctx.task base = f"Task #{t.id}: {t.title}" if t.id is not None else f"Task: {t.title}" + # Agent-specific dispatch instructions. These notifications should result in the agent + # taking concrete actions in Mission Control, not just acknowledging. + if ctx.event in {"task.created", "task.assigned"} and recipient.employee_type == "agent": + desc = (t.description or "").strip() + if len(desc) > 500: + desc = desc[:497] + "..." + desc_block = f"\n\nDescription:\n{desc}" if desc else "" + + # Keep this deterministic: agents already have base URL + header guidance in their prompt. + return ( + f"{base}\n\n" + "You are the assignee. Start NOW:\n" + f"1) PATCH /tasks/{t.id} → status=in_progress (use X-Actor-Employee-Id: {recipient.id})\n" + f"2) POST /task-comments → task_id={t.id} with a 1-2 line plan + next action\n" + "3) Do the work\n" + "4) POST /task-comments → progress updates\n" + f"5) When complete: PATCH /tasks/{t.id} → status=done and post a final summary comment" + f"{desc_block}" + ) + if ctx.event == "task.assigned": - return f"Assigned: {base}.\nWork ONE task only; update Mission Control with a comment when you make progress." + return ( + f"Assigned: {base}.\n" + "Work ONE task only; update Mission Control with a comment when you make progress." + ) if ctx.event == "comment.created": snippet = "" @@ -105,10 +127,16 @@ def build_message(ctx: NotifyContext) -> str: ) if ctx.event == "status.changed": - return f"Status changed on {base} → {t.status}.\nWork ONE task only; update Mission Control with next step." + return ( + f"Status changed on {base} → {t.status}.\n" + "Work ONE task only; update Mission Control with next step." + ) if ctx.event == "task.created": - return f"New task created: {base}.\nWork ONE task only; add acceptance criteria / next step in Mission Control." + return ( + f"New task created: {base}.\n" + "Work ONE task only; add acceptance criteria / next step in Mission Control." + ) return f"Update on {base}.\nWork ONE task only; update Mission Control." @@ -119,13 +147,16 @@ def notify_openclaw(session: Session, ctx: NotifyContext) -> None: return recipient_ids = resolve_recipients(session, ctx) - session_keys = _employee_session_keys(session, recipient_ids) - if not session_keys: + recipients = _employees_with_session_keys(session, recipient_ids) + if not recipients: return - message = build_message(ctx) + for e in recipients: + sk = getattr(e, "openclaw_session_key", None) + if not sk: + continue - for sk in session_keys: + message = build_message(ctx, recipient=e) try: client.tools_invoke( "sessions_send", From 56dd0ee544d8efcb31aef12fc5b9bcdeb7619606 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 20:38:54 +0530 Subject: [PATCH 13/27] feat(ui): add Task Trigger button + backend dispatch endpoint --- backend/app/api/work.py | 26 +++ frontend/src/api/generated/work/work.ts | 116 ++++++++++ frontend/src/app/projects/[id]/page.tsx | 281 ++++++++++++++++++------ 3 files changed, 361 insertions(+), 62 deletions(-) diff --git a/backend/app/api/work.py b/backend/app/api/work.py index 2a01510..bb1de89 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -102,6 +102,32 @@ def create_task( return Task.model_validate(task) +@router.post("/tasks/{task_id}/dispatch") +def dispatch_task( + task_id: int, + background: BackgroundTasks, + session: Session = Depends(get_session), + actor_employee_id: int = Depends(get_actor_employee_id), +): + task = session.get(Task, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + if task.assignee_employee_id is None: + raise HTTPException(status_code=400, detail="Task has no assignee") + + _validate_task_assignee(session, task.assignee_employee_id) + + # Best-effort: enqueue an agent dispatch. This does not mutate the task. + background.add_task( + notify_openclaw, + session, + NotifyContext(event="task.assigned", actor_employee_id=actor_employee_id, task=task), + ) + + return {"ok": True} + + @router.patch("/tasks/{task_id}", response_model=Task) def update_task( task_id: int, diff --git a/frontend/src/api/generated/work/work.ts b/frontend/src/api/generated/work/work.ts index 6ad2a47..505d893 100644 --- a/frontend/src/api/generated/work/work.ts +++ b/frontend/src/api/generated/work/work.ts @@ -351,6 +351,122 @@ export const useCreateTaskTasksPost = < queryClient, ); }; +/** + * @summary Dispatch Task + */ +export type dispatchTaskTasksTaskIdDispatchPostResponse200 = { + data: unknown; + status: 200; +}; + +export type dispatchTaskTasksTaskIdDispatchPostResponse422 = { + data: HTTPValidationError; + status: 422; +}; + +export type dispatchTaskTasksTaskIdDispatchPostResponseSuccess = + dispatchTaskTasksTaskIdDispatchPostResponse200 & { + headers: Headers; + }; +export type dispatchTaskTasksTaskIdDispatchPostResponseError = + dispatchTaskTasksTaskIdDispatchPostResponse422 & { + headers: Headers; + }; + +export type dispatchTaskTasksTaskIdDispatchPostResponse = + | dispatchTaskTasksTaskIdDispatchPostResponseSuccess + | dispatchTaskTasksTaskIdDispatchPostResponseError; + +export const getDispatchTaskTasksTaskIdDispatchPostUrl = (taskId: number) => { + return `/tasks/${taskId}/dispatch`; +}; + +export const dispatchTaskTasksTaskIdDispatchPost = async ( + taskId: number, + options?: RequestInit, +): Promise => { + return customFetch( + getDispatchTaskTasksTaskIdDispatchPostUrl(taskId), + { + ...options, + method: "POST", + }, + ); +}; + +export const getDispatchTaskTasksTaskIdDispatchPostMutationOptions = < + TError = HTTPValidationError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { taskId: number }, + TContext +> => { + const mutationKey = ["dispatchTaskTasksTaskIdDispatchPost"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { taskId: number } + > = (props) => { + const { taskId } = props ?? {}; + + return dispatchTaskTasksTaskIdDispatchPost(taskId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DispatchTaskTasksTaskIdDispatchPostMutationResult = NonNullable< + Awaited> +>; + +export type DispatchTaskTasksTaskIdDispatchPostMutationError = + HTTPValidationError; + +/** + * @summary Dispatch Task + */ +export const useDispatchTaskTasksTaskIdDispatchPost = < + TError = HTTPValidationError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { taskId: number }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { taskId: number }, + TContext +> => { + return useMutation( + getDispatchTaskTasksTaskIdDispatchPostMutationOptions(options), + queryClient, + ); +}; /** * @summary Update Task */ diff --git a/frontend/src/app/projects/[id]/page.tsx b/frontend/src/app/projects/[id]/page.tsx index c1fe7df..253faeb 100644 --- a/frontend/src/app/projects/[id]/page.tsx +++ b/frontend/src/app/projects/[id]/page.tsx @@ -4,7 +4,13 @@ import { useState } from "react"; import { useParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; @@ -14,15 +20,16 @@ import { useListProjectsProjectsGet } from "@/api/generated/projects/projects"; import { useListEmployeesEmployeesGet } from "@/api/generated/org/org"; import { useCreateTaskTasksPost, + useDeleteTaskTasksTaskIdDelete, + useDispatchTaskTasksTaskIdDispatchPost, + useListTaskCommentsTaskCommentsGet, useListTasksTasksGet, useUpdateTaskTasksTaskIdPatch, - useDeleteTaskTasksTaskIdDelete, useCreateTaskCommentTaskCommentsPost, - useListTaskCommentsTaskCommentsGet, } from "@/api/generated/work/work"; import { - useListProjectMembersProjectsProjectIdMembersGet, useAddProjectMemberProjectsProjectIdMembersPost, + useListProjectMembersProjectsProjectIdMembersGet, useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete, useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch, } from "@/api/generated/projects/projects"; @@ -51,7 +58,10 @@ export default function ProjectDetailPage() { const employees = useListEmployeesEmployeesGet(); const employeeList = employees.data?.status === 200 ? employees.data.data : []; - const eligibleAssignees = employeeList.filter((e) => e.employee_type !== "agent" || !!e.openclaw_session_key); + + const eligibleAssignees = employeeList.filter( + (e) => e.employee_type !== "agent" || !!e.openclaw_session_key, + ); const members = useListProjectMembersProjectsProjectIdMembersGet(projectId); const memberList = members.data?.status === 200 ? members.data.data : []; @@ -76,6 +86,11 @@ export default function ProjectDetailPage() { const deleteTask = useDeleteTaskTasksTaskIdDelete({ mutation: { onSuccess: () => tasks.refetch() }, }); + const dispatchTask = useDispatchTaskTasksTaskIdDispatchPost({ + mutation: { + onSuccess: () => tasks.refetch(), + }, + }); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -110,6 +125,11 @@ export default function ProjectDetailPage() { return map; })(); + const employeeById = new Map(); + for (const e of employeeList) { + if (e.id != null) employeeById.set(Number(e.id), e); + } + const employeeName = (id: number | null | undefined) => employeeList.find((e) => e.id === id)?.name ?? "—"; @@ -128,16 +148,40 @@ export default function ProjectDetailPage() { {projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
Loading…
) : null} - {projects.error ?
{(projects.error as Error).message}
: null} - {employees.error ?
{(employees.error as Error).message}
: null} - {members.error ?
{(members.error as Error).message}
: null} - {tasks.error ?
{(tasks.error as Error).message}
: null} + {projects.error ? ( +
+ {(projects.error as Error).message} +
+ ) : null} + {employees.error ? ( +
+ {(employees.error as Error).message} +
+ ) : null} + {members.error ? ( +
{(members.error as Error).message}
+ ) : null} + {tasks.error ? ( +
{(tasks.error as Error).message}
+ ) : null} +
-

{project?.name ?? `Project #${projectId}`}

-

Project detail: staffing + tasks.

+

+ {project?.name ?? `Project #${projectId}`} +

+

+ Project detail: staffing + tasks. +

-
@@ -149,14 +193,31 @@ export default function ProjectDetailPage() { Project-scoped tasks - {createTask.error ?
{(createTask.error as Error).message}
: null} - setTitle(e.target.value)} /> -