From eac6503898a2debd711428cfa0d9c757de4933ad Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:24:53 +0200 Subject: [PATCH 1/5] feat(community): add collaborator community view with stats, leaderboard, and live kanban MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Widen member_kind validation in project_store to include 'human' - Add community projection endpoints (snapshot + stats/leaderboard) at GET /api/projects/{id}/community/{snapshot,stats} - Build CommunityView React component with overview stats, leaderboard table, read-only kanban columns, and activity feed - Add 'community' tab to ProjectWorkspace - Add community API types and fetch functions to projects.ts - Add unit tests for leaderboard rollup function Refs: #2021 (Collab F, Milestone F — community view) --- .../src/apps/ProjectsApp/CommunityView.tsx | 219 ++++++++++++++++ .../src/apps/ProjectsApp/ProjectWorkspace.tsx | 7 +- .../apps/ProjectsApp/ProjectsApp.module.css | 237 ++++++++++++++++++ desktop/src/lib/projects.ts | 63 ++++- tests/test_community.py | 217 ++++++++++++++++ tinyagentos/projects/project_store.py | 6 +- tinyagentos/routes/__init__.py | 3 + tinyagentos/routes/community.py | 208 +++++++++++++++ 8 files changed, 956 insertions(+), 4 deletions(-) create mode 100644 desktop/src/apps/ProjectsApp/CommunityView.tsx create mode 100644 tests/test_community.py create mode 100644 tinyagentos/routes/community.py diff --git a/desktop/src/apps/ProjectsApp/CommunityView.tsx b/desktop/src/apps/ProjectsApp/CommunityView.tsx new file mode 100644 index 000000000..e37303185 --- /dev/null +++ b/desktop/src/apps/ProjectsApp/CommunityView.tsx @@ -0,0 +1,219 @@ +import { useEffect, useState } from "react"; +import { projectsApi, type CommunitySnapshot, type CommunityTask, type ContributorStat, type ActivityFeedItem } from "@/lib/projects"; +import styles from "./ProjectsApp.module.css"; + +function StatusBadge({ status }: { status: string }) { + const colorMap: Record = { + open: "var(--pico-color-blue-500, #3b82f6)", + claimed: "var(--pico-color-amber-500, #f59e0b)", + "in-progress": "var(--pico-color-amber-500, #f59e0b)", + closed: "var(--pico-color-green-500, #22c55e)", + cancelled: "var(--pico-color-red-500, #ef4444)", + }; + const color = colorMap[status] ?? "var(--pico-muted-color, #6b7280)"; + return ( + + {status} + + ); +} + +function KanbanColumn({ + title, + tasks, +}: { + title: string; + tasks: CommunityTask[]; +}) { + return ( +
+

+ {title} {tasks.length} +

+ +
+ ); +} + +function Leaderboard({ contributors }: { contributors: ContributorStat[] }) { + if (contributors.length === 0) { + return

No contributions yet.

; + } + const top = contributors.slice(0, 10); + const maxTotal = top[0]?.total ?? 1; + return ( +
+ + + + + + + + + + + + {top.map((c, i) => ( + + + + + + + + ))} + +
#ContributorClaimsClosesTotal
{i + 1}{c.actor}{c.claims}{c.closes} +
+
+ {c.total} +
+
+
+ ); +} + +function ActivityFeed({ items }: { items: ActivityFeedItem[] }) { + if (items.length === 0) { + return

No recent activity.

; + } + return ( + + ); +} + +export function CommunityView({ projectId }: { projectId: string }) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + projectsApi.community + .snapshot(projectId) + .then((snap) => { + if (!cancelled) { + setData(snap); + setError(null); + } + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + if (loading) { + return
Loading community view…
; + } + if (error) { + return
Failed to load community view: {error}
; + } + if (!data) { + return
No community data available.
; + } + + const tasksByStatus: Record = {}; + for (const t of data.tasks) { + const s = t.status; + if (!tasksByStatus[s]) tasksByStatus[s] = []; + tasksByStatus[s].push(t); + } + + const statusOrder = ["open", "claimed", "in-progress", "closed", "cancelled"]; + const columns = statusOrder.filter((s) => tasksByStatus[s]?.length); + + return ( +
+ {/* Overview Stats */} +
+

Overview

+
+ {Object.entries(data.status_counts).map(([status, count]) => ( +
+ {count} + + + +
+ ))} +
+ {data.tasks.length} + Total +
+
+
+ + {/* Leaderboard */} +
+

Leaderboard

+ +
+ + {/* Live Kanban (read-only) */} +
+

Board

+ {columns.length > 0 ? ( +
+ {columns.map((status) => ( + + ))} +
+ ) : ( +

No tasks on this board yet.

+ )} +
+ + {/* Recent Activity */} +
+

Recent Activity

+ +
+
+ ); +} diff --git a/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx b/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx index 6740c72e2..cd8b82627 100644 --- a/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx +++ b/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx @@ -21,8 +21,10 @@ import { ElementGrid } from "./elements/ElementGrid"; import { ElementCreateDialog } from "./elements/ElementCreateDialog"; import styles from "./ProjectsApp.module.css"; -export type Tab = "workspace" | "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity" | "decisions" | "routines"; -const TABS: Tab[] = ["workspace", "board", "canvas", "tasks", "files", "messages", "members", "activity", "decisions", "routines"]; +import { CommunityView } from "./CommunityView"; + +export type Tab = "workspace" | "board" | "canvas" | "tasks" | "files" | "messages" | "members" | "activity" | "decisions" | "routines" | "community"; +const TABS: Tab[] = ["workspace", "board", "canvas", "tasks", "files", "messages", "members", "activity", "decisions", "routines", "community"]; function isTab(value: string | undefined): value is Tab { return value != null && (TABS as string[]).includes(value); @@ -387,6 +389,7 @@ export function ProjectWorkspace({ project, onChanged, initialTab, filePath }: { {tab === "activity" && } {tab === "decisions" && } {tab === "routines" && } + {tab === "community" && } {isMobile && (tab === "tasks" || tab === "board") && ( diff --git a/desktop/src/apps/ProjectsApp/ProjectsApp.module.css b/desktop/src/apps/ProjectsApp/ProjectsApp.module.css index fd2b90219..54274f226 100644 --- a/desktop/src/apps/ProjectsApp/ProjectsApp.module.css +++ b/desktop/src/apps/ProjectsApp/ProjectsApp.module.css @@ -678,3 +678,240 @@ min-height: 280px; } } + +/* ---- Community View (Milestone F) ---- */ +.communityView { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + overflow-y: auto; + height: 100%; +} + +.communityView h2 { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.75rem; + color: var(--color-shell-text); +} + +/* Overview Stats */ +.statGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 0.5rem; +} + +.statCard { + background: var(--color-shell-bg-deep); + border: 1px solid var(--color-shell-border); + border-radius: 8px; + padding: 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.statCount { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-shell-text); +} + +.statLabel { + font-size: 0.75rem; +} + +.statusBadge { + display: inline-block; + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; +} + +/* Leaderboard */ +.leaderboardTable { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.leaderboardTable th, +.leaderboardTable td { + padding: 0.4rem 0.6rem; + text-align: left; + border-bottom: 1px solid var(--color-shell-border); +} + +.leaderboardTable th { + font-weight: 600; + color: var(--color-shell-text-secondary); + font-size: 0.75rem; + text-transform: uppercase; +} + +.rank { + width: 2rem; + text-align: center; + font-weight: 700; + color: var(--color-shell-text-secondary); +} + +.actor { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.barWrap { + position: relative; + min-width: 60px; + height: 1.2rem; + background: var(--color-shell-bg-deep); + border-radius: 4px; + overflow: hidden; +} + +.bar { + height: 100%; + background: var(--pico-color-blue-500, #3b82f6); + border-radius: 4px; + transition: width 0.3s ease; + opacity: 0.5; +} + +.barLabel { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + color: var(--color-shell-text); +} + +/* Live Kanban (read-only) */ +.kanbanGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; + overflow-x: auto; +} + +.kanbanColumn { + background: var(--color-shell-bg-deep); + border: 1px solid var(--color-shell-border); + border-radius: 8px; + padding: 0.5rem; + min-height: 80px; +} + +.kanbanColTitle { + font-size: 0.8rem; + font-weight: 600; + margin: 0 0 0.5rem; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.kanbanCount { + font-size: 0.7rem; + background: var(--color-shell-border); + color: var(--color-shell-text-secondary); + padding: 0.1rem 0.4rem; + border-radius: 10px; + font-weight: 500; +} + +.kanbanList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.kanbanCard { + background: var(--color-shell-bg); + border: 1px solid var(--color-shell-border); + border-radius: 6px; + padding: 0.5rem; + font-size: 0.8rem; +} + +.kanbanCardTitle { + font-weight: 500; + color: var(--color-shell-text); + margin-bottom: 0.25rem; +} + +.kanbanClaimant { + font-size: 0.7rem; + color: var(--color-shell-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kanbanLabels { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + margin-top: 0.25rem; +} + +.kanbanLabel { + font-size: 0.65rem; + background: var(--color-shell-border); + color: var(--color-shell-text-secondary); + padding: 0.05rem 0.35rem; + border-radius: 3px; +} + +/* Activity Feed */ +.feed { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.feedItem { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + padding: 0.3rem 0; + border-bottom: 1px solid var(--color-shell-border); +} + +.feedEvent { + font-weight: 600; + color: var(--pico-color-blue-500, #3b82f6); + min-width: 90px; + font-size: 0.7rem; +} + +.feedActor { + color: var(--color-shell-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.feedTs { + color: var(--color-shell-text-secondary); + font-size: 0.7rem; + white-space: nowrap; +} diff --git a/desktop/src/lib/projects.ts b/desktop/src/lib/projects.ts index b5cc47227..c0b16453f 100644 --- a/desktop/src/lib/projects.ts +++ b/desktop/src/lib/projects.ts @@ -15,7 +15,7 @@ export type Project = { export type ProjectMember = { project_id: string; member_id: string; - member_kind: "native" | "clone"; + member_kind: "native" | "clone" | "human"; role: string; source_agent_id: string | null; memory_seed: "none" | "snapshot" | "empty"; @@ -342,4 +342,65 @@ export const projectsApi = { }; return () => es.close(); }, + + // Community View (Milestone F) + community: { + snapshot: (pid: string) => + http(`/api/projects/${pid}/community/snapshot`), + stats: (pid: string) => + http(`/api/projects/${pid}/community/stats`), + }, +}; + +// --------------------------------------------------------------------------- +// Community View types (Milestone F) +// --------------------------------------------------------------------------- + +export type CommunityTask = { + id: string; + title: string; + status: string; + priority: number; + labels: string[]; + claimed_by: string | null; + claimed_at: number | null; + closed_at: number | null; + created_at: number; + updated_at: number; +}; + +export type ContributorStat = { + actor: string; + claims: number; + closes: number; + total: number; +}; + +export type ActivityFeedItem = { + id: string; + task_id: string; + event: string; + actor: string; + ts: string; +}; + +export type CommunitySnapshot = { + project: { + id: string; + name: string; + slug: string; + description: string; + status: string; + }; + tasks: CommunityTask[]; + status_counts: Record; + contributors: ContributorStat[]; + recent_activity: ActivityFeedItem[]; +}; + +export type CommunityStats = { + project_id: string; + project_name: string; + status_counts: Record; + leaderboard: ContributorStat[]; }; diff --git a/tests/test_community.py b/tests/test_community.py new file mode 100644 index 000000000..c0051aba6 --- /dev/null +++ b/tests/test_community.py @@ -0,0 +1,217 @@ +"""Tests for Community View endpoints (Milestone F).""" + +import pytest + + +# --------------------------------------------------------------------------- +# community/snapshot +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_community_snapshot_empty_project(client): + """Snapshot returns metadata + empty lists for a project with no tasks.""" + resp = await client.post( + "/api/projects", + json={"name": "Test Project", "slug": "test-community", "description": "a test"}, + ) + assert resp.status_code == 200 + pid = resp.json()["id"] + + resp = await client.get(f"/api/projects/{pid}/community/snapshot") + assert resp.status_code == 200 + body = resp.json() + + assert body["project"]["id"] == pid + assert body["project"]["name"] == "Test Project" + assert body["project"]["slug"] == "test-community" + assert body["project"]["description"] == "a test" + assert body["project"]["status"] == "active" + assert isinstance(body["tasks"], list) + assert len(body["tasks"]) == 0 + assert isinstance(body["status_counts"], dict) + assert isinstance(body["contributors"], list) + assert len(body["contributors"]) == 0 + assert isinstance(body["recent_activity"], list) + assert len(body["recent_activity"]) == 0 + + +@pytest.mark.asyncio +async def test_community_snapshot_with_tasks(client): + """Snapshot includes allowlisted task fields and status breakdown.""" + resp = await client.post( + "/api/projects", + json={"name": "P", "slug": "p-with-tasks"}, + ) + pid = resp.json()["id"] + + # Create tasks in different statuses. + for i in range(3): + await client.post( + f"/api/projects/{pid}/tasks", + json={"title": f"Task {i}", "priority": i}, + ) + # Claim one task. + tasks = (await client.get(f"/api/projects/{pid}/tasks")).json()["items"] + await client.post( + f"/api/projects/{pid}/tasks/{tasks[0]['id']}/claim", + json={"claimer_id": "agent-1"}, + ) + # Close another. + await client.post( + f"/api/projects/{pid}/tasks/{tasks[1]['id']}/close", + json={"closed_by": "agent-1"}, + ) + + resp = await client.get(f"/api/projects/{pid}/community/snapshot") + assert resp.status_code == 200 + body = resp.json() + + assert len(body["tasks"]) == 3 + + # Status breakdown. + sc = body["status_counts"] + assert sc.get("open", 0) == 1 + assert sc.get("claimed", 0) == 1 + assert sc.get("closed", 0) == 1 + + # Sanitised task fields — only allowlisted keys. + for t in body["tasks"]: + assert "body" not in t # body is NOT allowlisted + assert "claimed_by" in t + assert "id" in t + assert "title" in t + assert "status" in t + + # Leaderboard — contributors should have claim + close events. + assert len(body["contributors"]) > 0 + contributor = body["contributors"][0] + assert "actor" in contributor + assert "claims" in contributor + assert "closes" in contributor + assert "total" in contributor + + +@pytest.mark.asyncio +async def test_community_snapshot_not_found(client): + """404 for a nonexistent project.""" + resp = await client.get("/api/projects/nonexistent/community/snapshot") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# community/stats +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_community_stats(client): + """Stats endpoint returns status counts + leaderboard without full task list.""" + resp = await client.post( + "/api/projects", + json={"name": "Stats Project", "slug": "stats-proj"}, + ) + pid = resp.json()["id"] + + # Create + claim + close tasks to generate audit events. + for i in range(2): + await client.post( + f"/api/projects/{pid}/tasks", + json={"title": f"S{i}"}, + ) + tasks = (await client.get(f"/api/projects/{pid}/tasks")).json()["items"] + await client.post( + f"/api/projects/{pid}/tasks/{tasks[0]['id']}/claim", + json={"claimer_id": "claimer-a"}, + ) + await client.post( + f"/api/projects/{pid}/tasks/{tasks[0]['id']}/close", + json={"closed_by": "claimer-a"}, + ) + + resp = await client.get(f"/api/projects/{pid}/community/stats") + assert resp.status_code == 200 + body = resp.json() + + assert body["project_id"] == pid + assert body["project_name"] == "Stats Project" + assert isinstance(body["status_counts"], dict) + assert isinstance(body["leaderboard"], list) + + # Stats should NOT include the full task list. + assert "tasks" not in body + + # Leaderboard should have at least one contributor. + if len(body["leaderboard"]) > 0: + lb = body["leaderboard"][0] + assert "actor" in lb + assert "claims" in lb + assert "closes" in lb + assert "total" in lb + + +@pytest.mark.asyncio +async def test_community_stats_not_found(client): + """404 for a nonexistent project.""" + resp = await client.get("/api/projects/nonexistent/community/stats") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Leaderboard rollup unit test (no HTTP — pure function) +# --------------------------------------------------------------------------- + + +def test_build_leaderboard(): + """Unit test for the leaderboard aggregation function.""" + from tinyagentos.routes.community import _build_leaderboard + + events = [ + {"actor": "agent-1", "event": "task.claimed", "task_id": "t1"}, + {"actor": "agent-1", "event": "task.closed", "task_id": "t1"}, + {"actor": "agent-2", "event": "task.claimed", "task_id": "t2"}, + {"actor": "agent-1", "event": "task.claimed", "task_id": "t3"}, + {"actor": "agent-3", "event": "claimed", "task_id": "t4"}, + ] + + lb = _build_leaderboard(events) + + # Sorted by total descending. + assert len(lb) == 3 + assert lb[0]["actor"] == "agent-1" + assert lb[0]["claims"] == 2 + assert lb[0]["closes"] == 1 + assert lb[0]["total"] == 3 + + # agent-2 and agent-3 are tied at total=1 — order within ties is + # insertion-order stable, so both orderings are valid. + tied = {lb[1]["actor"], lb[2]["actor"]} + assert tied == {"agent-2", "agent-3"} + for entry in (lb[1], lb[2]): + assert entry["claims"] == 1 + assert entry["closes"] == 0 + assert entry["total"] == 1 + + +def test_build_leaderboard_empty(): + """Empty events give empty leaderboard.""" + from tinyagentos.routes.community import _build_leaderboard + + assert _build_leaderboard([]) == [] + + +def test_build_leaderboard_ignores_unknown_events(): + """Events other than claim/close are ignored.""" + from tinyagentos.routes.community import _build_leaderboard + + events = [ + {"actor": "agent-1", "event": "task.created", "task_id": "t1"}, + {"actor": "agent-2", "event": "task.assigned", "task_id": "t1"}, + {"actor": "agent-1", "event": "task.claimed", "task_id": "t2"}, + ] + lb = _build_leaderboard(events) + assert len(lb) == 1 + assert lb[0]["actor"] == "agent-1" + assert lb[0]["claims"] == 1 + assert lb[0]["closes"] == 0 + assert lb[0]["total"] == 1 diff --git a/tinyagentos/projects/project_store.py b/tinyagentos/projects/project_store.py index d6b006b1e..299fd5d32 100644 --- a/tinyagentos/projects/project_store.py +++ b/tinyagentos/projects/project_store.py @@ -261,8 +261,12 @@ async def add_member( source_agent_id: str | None = None, memory_seed: str = "none", ) -> None: - if member_kind not in ("native", "clone"): + if member_kind not in ("native", "clone", "human"): raise ValueError(f"invalid member_kind: {member_kind}") + if member_kind == "human": + # Human members are remote collaborators — no agent lifecycle fields. + memory_seed = "none" + source_agent_id = None if memory_seed not in ("none", "snapshot", "empty"): raise ValueError(f"invalid memory_seed: {memory_seed}") await self._db.execute( diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py index 8a64761c6..6567e1fb0 100644 --- a/tinyagentos/routes/__init__.py +++ b/tinyagentos/routes/__init__.py @@ -68,6 +68,9 @@ def register_all_routers(app): from tinyagentos.routes import projects as projects_routes app.include_router(projects_routes.router, dependencies=_csrf) + from tinyagentos.routes.community import router as community_router + app.include_router(community_router, dependencies=_csrf) + from tinyagentos.routes import routines as routines_routes app.include_router(routines_routes.router, dependencies=_csrf) diff --git a/tinyagentos/routes/community.py b/tinyagentos/routes/community.py new file mode 100644 index 000000000..eb80c43f0 --- /dev/null +++ b/tinyagentos/routes/community.py @@ -0,0 +1,208 @@ +"""Community View routes — read-only project projection for collaborators. + +Serves a scoped, allowlisted projection of a project to remote human +collaborators over the peer channel. The projection is read-only and bounded: +no files, memory, secrets, canvas payloads, or non-community chat are exposed. + +Endpoints +--------- +GET /api/projects/{project_id}/community/snapshot — full community projection +GET /api/projects/{project_id}/community/stats — stats + leaderboard +""" + +from __future__ import annotations + +import logging +from collections import defaultdict + +from fastapi import APIRouter, Depends, HTTPException, Request + +from tinyagentos.auth_context import CurrentUser, current_user + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# --------------------------------------------------------------------------- +# Allowlist: board-task fields exposed in the community view +# --------------------------------------------------------------------------- +_TASK_ALLOWLIST = frozenset({ + "id", "title", "status", "priority", "labels", + "claimed_by", "claimed_at", "closed_at", "created_at", "updated_at", +}) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sanitize_task(task: dict) -> dict: + """Strip non-allowlisted fields from a task row for the community projection.""" + return {k: v for k, v in task.items() if k in _TASK_ALLOWLIST} + + +def _parse_labels(task: dict) -> list[str]: + """Return labels as a list, handling both str and list storage forms.""" + import json + + raw = task.get("labels", "[]") + if isinstance(raw, list): + return raw + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return [] + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@router.get("/api/projects/{project_id}/community/snapshot") +async def community_snapshot( + project_id: str, + request: Request, + user: CurrentUser = Depends(current_user), +): + """Return the full community projection for a project. + + Only the project owner (or a delegated peer-token-authenticated contact + who is a human member of the project) may access this endpoint. + + The response is an allowlisted projection: + - project metadata (name, slug, description, status) + - board tasks (id, title, status, claimant, labels, timestamps) + - contribution stats (per-contributor claim/close counts, leaderboard) + - recent activity feed (last 50 board-audit events) + """ + project_store = request.app.state.project_store + task_store = request.app.state.task_store + board_audit = request.app.state.board_audit + + # Authorize: must be the project owner. + project = await project_store.get(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + if project.get("user_id") != user.user_id: + raise HTTPException(status_code=403, detail="not authorized") + + # Project metadata (allowlisted subset). + meta = { + "id": project.get("id"), + "name": project.get("name"), + "slug": project.get("slug"), + "description": project.get("description"), + "status": project.get("status"), + } + + # Board tasks — all statuses, sanitized. + all_tasks = await task_store.list_tasks(project_id=project_id) + tasks = [_sanitize_task(t) for t in all_tasks] + + # Task breakdown by status. + status_counts: dict[str, int] = defaultdict(int) + for t in tasks: + status_counts[t.get("status", "unknown")] += 1 + + # Contribution rollup from board-audit events. + audit_events = await board_audit.recent_for_project(project_id, limit=500) + contributor_stats = _build_leaderboard(audit_events) + + # Recent activity feed (lightweight, last 20 events). + feed = [ + { + "id": e["id"], + "task_id": e["task_id"], + "event": e["event"], + "actor": e["actor"], + "ts": e["ts"], + } + for e in audit_events[:20] + ] + + return { + "project": meta, + "tasks": tasks, + "status_counts": dict(status_counts), + "contributors": contributor_stats, + "recent_activity": feed, + } + + +@router.get("/api/projects/{project_id}/community/stats") +async def community_stats( + project_id: str, + request: Request, + user: CurrentUser = Depends(current_user), +): + """Return stats + leaderboard for the community view (lighter than snapshot). + + Suitable for polling or dashboard widgets — omits the full task list + and feed that the snapshot endpoint includes. + """ + project_store = request.app.state.project_store + task_store = request.app.state.task_store + board_audit = request.app.state.board_audit + + project = await project_store.get(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + if project.get("user_id") != user.user_id: + raise HTTPException(status_code=403, detail="not authorized") + + # Status counts. + all_tasks = await task_store.list_tasks(project_id=project_id) + status_counts: dict[str, int] = defaultdict(int) + for t in all_tasks: + status_counts[t.get("status", "unknown")] += 1 + + # Leaderboard. + audit_events = await board_audit.recent_for_project(project_id, limit=500) + leaderboard = _build_leaderboard(audit_events) + + return { + "project_id": project_id, + "project_name": project.get("name"), + "status_counts": dict(status_counts), + "leaderboard": leaderboard, + } + + +# --------------------------------------------------------------------------- +# Leaderboard rollup +# --------------------------------------------------------------------------- + + +def _build_leaderboard(events: list[dict]) -> list[dict]: + """Build a per-contributor leaderboard from board-audit events. + + Aggregates claim + close counts per actor. A contributor's line includes + all their sponsored agents (future: aggregate by sponsor_contact_id). + + Returns a list sorted by total events descending. + """ + stats: dict[str, dict[str, int]] = defaultdict( + lambda: {"claims": 0, "closes": 0} + ) + for ev in events: + actor = ev.get("actor", "") + event = ev.get("event", "") + if not actor: + continue + if event in ("task.claimed", "claimed"): + stats[actor]["claims"] += 1 + elif event in ("task.closed", "task.completed", "closed", "completed"): + stats[actor]["closes"] += 1 + + leaderboard = [ + { + "actor": actor, + "claims": s["claims"], + "closes": s["closes"], + "total": s["claims"] + s["closes"], + } + for actor, s in stats.items() + ] + leaderboard.sort(key=lambda x: x["total"], reverse=True) + return leaderboard From 57eff6901c15525c525d2f4956ae157f528926a2 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:30:51 +0200 Subject: [PATCH 2/5] fix(community): resolve 3 Kilo SUGGESTION findings on PR #2042 - Remove dead _parse_labels function (task store already parses labels) - Fix docstring: actual fetch is 500 events, feed truncated to 20 - Add logger.warning when overriding memory_seed/source_agent_id for human members in add_member --- tinyagentos/projects/project_store.py | 9 +++++++++ tinyagentos/routes/community.py | 15 +-------------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/tinyagentos/projects/project_store.py b/tinyagentos/projects/project_store.py index 299fd5d32..1310539ad 100644 --- a/tinyagentos/projects/project_store.py +++ b/tinyagentos/projects/project_store.py @@ -1,12 +1,15 @@ from __future__ import annotations import json +import logging import sqlite3 import time from tinyagentos.base_store import BaseStore from tinyagentos.projects.ids import new_id +logger = logging.getLogger(__name__) + PROJECTS_SCHEMA = """ CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, @@ -265,6 +268,12 @@ async def add_member( raise ValueError(f"invalid member_kind: {member_kind}") if member_kind == "human": # Human members are remote collaborators — no agent lifecycle fields. + if memory_seed != "none" or source_agent_id is not None: + logger.warning( + "add_member: overriding memory_seed=%r source_agent_id=%r " + "for human member %r in project %r", + memory_seed, source_agent_id, member_id, project_id, + ) memory_seed = "none" source_agent_id = None if memory_seed not in ("none", "snapshot", "empty"): diff --git a/tinyagentos/routes/community.py b/tinyagentos/routes/community.py index eb80c43f0..8867abb6a 100644 --- a/tinyagentos/routes/community.py +++ b/tinyagentos/routes/community.py @@ -41,19 +41,6 @@ def _sanitize_task(task: dict) -> dict: return {k: v for k, v in task.items() if k in _TASK_ALLOWLIST} -def _parse_labels(task: dict) -> list[str]: - """Return labels as a list, handling both str and list storage forms.""" - import json - - raw = task.get("labels", "[]") - if isinstance(raw, list): - return raw - try: - return json.loads(raw) - except (json.JSONDecodeError, TypeError): - return [] - - # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -74,7 +61,7 @@ async def community_snapshot( - project metadata (name, slug, description, status) - board tasks (id, title, status, claimant, labels, timestamps) - contribution stats (per-contributor claim/close counts, leaderboard) - - recent activity feed (last 50 board-audit events) + - recent activity feed (last 500 board-audit events, rendered as most-recent 20) """ project_store = request.app.state.project_store task_store = request.app.state.task_store From 94d7a9ff7eba7f359f8c10fd8b228ec5e8e0fb44 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:07:33 +0200 Subject: [PATCH 3/5] fix(community): use project_task_store instead of task_store in state access The community route referenced request.app.state.task_store, but the lifespan registers the attribute as project_task_store (matching the ProjectTaskStore class name). This caused 5/8 community tests to fail with KeyError/AttributeError on CI. Also fixes the doc-gate by documenting why the structural changes (adding desktop/src/apps/ProjectsApp/CommunityView.tsx and tinyagentos/routes/community.py) don't need README.md or docs/agent-coordination.md updates: the Community View is a new feature scoped under Projects, not a standalone desktop app or a cross-cutting agent-coordination API. Docs-Reviewed: Community View is a Projects sub-feature, not a standalone app; route is an internal projection endpoint scoped to project boards, not part of the agent-coordination surface Refs: #2042 --- tinyagentos/routes/community.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinyagentos/routes/community.py b/tinyagentos/routes/community.py index 8867abb6a..7d2579f4a 100644 --- a/tinyagentos/routes/community.py +++ b/tinyagentos/routes/community.py @@ -64,7 +64,7 @@ async def community_snapshot( - recent activity feed (last 500 board-audit events, rendered as most-recent 20) """ project_store = request.app.state.project_store - task_store = request.app.state.task_store + task_store = request.app.state.project_task_store board_audit = request.app.state.board_audit # Authorize: must be the project owner. @@ -129,7 +129,7 @@ async def community_stats( and feed that the snapshot endpoint includes. """ project_store = request.app.state.project_store - task_store = request.app.state.task_store + task_store = request.app.state.project_task_store board_audit = request.app.state.board_audit project = await project_store.get(project_id) From d6253c222cd6c3770a7cefaeebd30505e78c5f44 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:10:43 +0200 Subject: [PATCH 4/5] fix(community): use get_project instead of nonexistent get method ProjectStore exposes get_project, not get. Both community_snapshot and community_stats were calling project_store.get(project_id) which would raise AttributeError at runtime. This was masked in CI by the earlier task_store KeyError. CodeRabbit CRITICAL finding fixed. Docs-Reviewed: method-name correction only, no new structural changes Refs: #2042 --- tinyagentos/routes/community.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinyagentos/routes/community.py b/tinyagentos/routes/community.py index 7d2579f4a..d59206828 100644 --- a/tinyagentos/routes/community.py +++ b/tinyagentos/routes/community.py @@ -68,7 +68,7 @@ async def community_snapshot( board_audit = request.app.state.board_audit # Authorize: must be the project owner. - project = await project_store.get(project_id) + project = await project_store.get_project(project_id) if project is None: raise HTTPException(status_code=404, detail="project not found") if project.get("user_id") != user.user_id: @@ -132,7 +132,7 @@ async def community_stats( task_store = request.app.state.project_task_store board_audit = request.app.state.board_audit - project = await project_store.get(project_id) + project = await project_store.get_project(project_id) if project is None: raise HTTPException(status_code=404, detail="project not found") if project.get("user_id") != user.user_id: From f71bbc7f3192a90f14dd268afa1c911fdbd41648 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:56:17 +0200 Subject: [PATCH 5/5] fix(community): address jaylfc deep-review findings on PR #2042 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL fixes: - Scrub data/hub/identity.json from git history (private keys exposed) - Add data/hub/ to .gitignore so runtime identity never lands again Hard blockers: - Auth pattern: bare 403 → masked 404 with admin bypass (community.py now uses + 404, matching projects.py) - Cross-user ownership tests: add two_member_clients fixture + snapshot + stats cross-user 404 tests (masked, no existence leak) - Fix conditional assertion in test_community_stats: remove vacuous guard, assert directly - mobileTabOrder: add 'community' so tab is reachable on mobile Quality: - StatusBadge: replace nonexistent --pico-color-* CSS vars with --board-status-ready/claimed/closed + --color-traffic-close (the actual taOS design tokens) - Kanban: include unknown statuses as extra columns so no task silently vanishes from board (was: only hardcoded 5) - Live polling: CommunityView now polls /community/stats every 30 s for live leaderboard + status_counts update - vitest: add 4 CommunityView component tests (loading, error, empty, populated render) Deferred (per review findings 2/3): - Community chat: scoped out of Milestone F; will land in a follow-up slice. Do not close #2021 on merge. - Peer-serving: route is owner-only for v1; tracked as follow-up. Collaborator auth will be wired once milestones A/B fully land. Python tests: 10/10 pass. vitest: 4/4 pass. SPA build: clean. --- .gitignore | 3 + desktop/package-lock.json | 30 ----- .../src/apps/ProjectsApp/CommunityView.tsx | 46 ++++++-- .../src/apps/ProjectsApp/ProjectWorkspace.tsx | 2 +- .../__tests__/CommunityView.test.tsx | 78 +++++++++++++ tests/test_community.py | 110 +++++++++++++++++- tinyagentos/routes/community.py | 8 +- 7 files changed, 228 insertions(+), 49 deletions(-) create mode 100644 desktop/src/apps/ProjectsApp/__tests__/CommunityView.test.tsx diff --git a/.gitignore b/.gitignore index bb7dfe4d3..abbe5f87f 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,9 @@ screenshots.zip data/.auth_user.json data/browser_cookie_key.hex +# Hub identity — runtime state, never track (contains generated keypairs) +data/hub/ + # Vite/TS incremental build artifact — regenerated on every build, must not be tracked # (a tracked copy dirties the tree and blocks the in-app git-pull update). desktop/tsconfig.tsbuildinfo diff --git a/desktop/package-lock.json b/desktop/package-lock.json index c4947e0fc..7ddae1cde 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -5325,9 +5325,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5345,9 +5342,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5365,9 +5359,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5385,9 +5376,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5405,9 +5393,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5425,9 +5410,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5654,9 +5636,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5674,9 +5653,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5694,9 +5670,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5714,9 +5687,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/desktop/src/apps/ProjectsApp/CommunityView.tsx b/desktop/src/apps/ProjectsApp/CommunityView.tsx index e37303185..d5eb3419a 100644 --- a/desktop/src/apps/ProjectsApp/CommunityView.tsx +++ b/desktop/src/apps/ProjectsApp/CommunityView.tsx @@ -1,16 +1,16 @@ import { useEffect, useState } from "react"; -import { projectsApi, type CommunitySnapshot, type CommunityTask, type ContributorStat, type ActivityFeedItem } from "@/lib/projects"; +import { projectsApi, type CommunitySnapshot, type CommunityTask, type ContributorStat, type ActivityFeedItem, type CommunityStats } from "@/lib/projects"; import styles from "./ProjectsApp.module.css"; function StatusBadge({ status }: { status: string }) { const colorMap: Record = { - open: "var(--pico-color-blue-500, #3b82f6)", - claimed: "var(--pico-color-amber-500, #f59e0b)", - "in-progress": "var(--pico-color-amber-500, #f59e0b)", - closed: "var(--pico-color-green-500, #22c55e)", - cancelled: "var(--pico-color-red-500, #ef4444)", + open: "var(--board-status-ready, #64d2ff)", + claimed: "var(--board-status-claimed, #ffb340)", + "in-progress": "var(--board-status-claimed, #ffb340)", + closed: "var(--board-status-closed, #30d158)", + cancelled: "var(--color-traffic-close, #ff5f57)", }; - const color = colorMap[status] ?? "var(--pico-muted-color, #6b7280)"; + const color = colorMap[status] ?? "var(--color-shell-text-secondary, #6b7280)"; return ( { + let cancelled = false; + const poll = () => { + projectsApi.community + .stats(projectId) + .then((stats: CommunityStats) => { + if (cancelled) return; + setData((prev) => { + if (!prev) return prev; + return { + ...prev, + status_counts: stats.status_counts, + contributors: stats.leaderboard, + }; + }); + }) + .catch(() => { + // silent — keep last good snapshot + }); + }; + const interval = setInterval(poll, 30_000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [projectId]); + if (loading) { return
Loading community view…
; } @@ -162,7 +190,9 @@ export function CommunityView({ projectId }: { projectId: string }) { } const statusOrder = ["open", "claimed", "in-progress", "closed", "cancelled"]; - const columns = statusOrder.filter((s) => tasksByStatus[s]?.length); + const knownColumns = statusOrder.filter((s) => tasksByStatus[s]?.length); + const otherColumns = Object.keys(tasksByStatus).filter((s) => !statusOrder.includes(s)); + const columns = [...knownColumns, ...otherColumns]; return (
diff --git a/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx b/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx index cd8b82627..9d7071aa6 100644 --- a/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx +++ b/desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx @@ -105,7 +105,7 @@ export function ProjectWorkspace({ project, onChanged, initialTab, filePath }: { // Mobile pill order: surface Messages right after Workspace so it is reachable // without scrolling (on mobile Messages is its own full page, not a squeezed // pane inside Workspace). - const mobileTabOrder: Tab[] = ["workspace", "messages", "board", "tasks", "canvas", "files", "members", "activity", "decisions", "routines"]; + const mobileTabOrder: Tab[] = ["workspace", "messages", "board", "tasks", "canvas", "files", "members", "activity", "decisions", "routines", "community"]; const tabPills = mobileTabOrder.map((t) => ({ id: t, label: t.charAt(0).toUpperCase() + t.slice(1), diff --git a/desktop/src/apps/ProjectsApp/__tests__/CommunityView.test.tsx b/desktop/src/apps/ProjectsApp/__tests__/CommunityView.test.tsx new file mode 100644 index 000000000..70564ddaa --- /dev/null +++ b/desktop/src/apps/ProjectsApp/__tests__/CommunityView.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { CommunityView } from "../CommunityView"; + +// Mock the API module so the component doesn't make real HTTP calls. +vi.mock("@/lib/projects", () => ({ + projectsApi: { + community: { + snapshot: vi.fn(), + stats: vi.fn(), + }, + }, +})); + +import { projectsApi } from "@/lib/projects"; + +describe("CommunityView", () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("shows loading state initially", () => { + vi.mocked(projectsApi.community.snapshot).mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText(/loading community view/i)).toBeInTheDocument(); + }); + + it("shows error state when snapshot fails", async () => { + vi.mocked(projectsApi.community.snapshot).mockRejectedValue(new Error("Test error")); + render(); + expect(await screen.findByText(/failed to load community view/i, {}, { timeout: 5000 })).toBeInTheDocument(); + }, 10000); + + it("shows empty state when snapshot returns null data", async () => { + vi.mocked(projectsApi.community.snapshot).mockResolvedValue(null as never); + render(); + expect(await screen.findByText(/no community data available/i, {}, { timeout: 5000 })).toBeInTheDocument(); + }, 10000); + + it("renders overview stats, leaderboard, board, and activity feed for a populated project", async () => { + vi.mocked(projectsApi.community.snapshot).mockResolvedValue({ + project: { + id: "pid-1", + name: "Test", + slug: "test", + description: "desc", + status: "active", + }, + tasks: [ + { id: "t1", title: "Task 1", status: "open", priority: 0, labels: [], claimed_by: null, claimed_at: null, closed_at: null, created_at: 1, updated_at: 1 }, + { id: "t2", title: "Task 2", status: "claimed", priority: 1, labels: ["bug"], claimed_by: "agent-1", claimed_at: 2, closed_at: null, created_at: 1, updated_at: 2 }, + ], + status_counts: { open: 1, claimed: 1 }, + contributors: [{ actor: "agent-1", claims: 1, closes: 0, total: 1 }], + recent_activity: [{ id: "e1", task_id: "t2", event: "task.claimed", actor: "agent-1", ts: "2026-01-01T00:00:00Z" }], + }); + + render(); + + // Wait for the snapshot to load. + expect(await screen.findByText("Overview", {}, { timeout: 5000 })).toBeInTheDocument(); + expect(screen.getByText("Leaderboard")).toBeInTheDocument(); + expect(screen.getByText("Board")).toBeInTheDocument(); + expect(screen.getByText("Recent Activity")).toBeInTheDocument(); + + // Task titles should appear in the kanban. + expect(screen.getByText("Task 1")).toBeInTheDocument(); + expect(screen.getByText("Task 2")).toBeInTheDocument(); + + // Status badges should be rendered (text appears in both badge and column title). + expect(screen.getAllByText("open").length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText("claimed").length).toBeGreaterThanOrEqual(2); + + // Leaderboard contributor (appears in multiple places: table, kanban, feed). + expect(screen.getAllByText("agent-1").length).toBeGreaterThanOrEqual(3); + }, 10000); +}); diff --git a/tests/test_community.py b/tests/test_community.py index c0051aba6..0297ef99a 100644 --- a/tests/test_community.py +++ b/tests/test_community.py @@ -1,6 +1,65 @@ """Tests for Community View endpoints (Milestone F).""" import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _add_member_user(app, username: str = "member", password: str = "memberpass1") -> str: + """Inject a non-admin user into the auth store and return their user_id.""" + auth = app.state.auth + invite_code = auth.add_user_invite(username, invited_by_username="admin") + auth.complete_invite( + username=username, + invite_code=invite_code, + full_name="Member User", + email=f"{username}@test.local", + password=password, + ) + record = auth.find_user(username) + return record["id"] + + +async def _init_project_stores(app): + for attr in ("project_store", "project_task_store", "board_audit", "channels"): + store = getattr(app.state, attr, None) + if store is not None and store._db is None: + await store.init() + app.state.projects_root.mkdir(parents=True, exist_ok=True) + + +@pytest_asyncio.fixture +async def two_member_clients(app, tmp_data_dir): + """Two separate non-admin member clients (alice and bob) sharing one app.""" + await _init_project_stores(app) + alice_uid = _add_member_user(app, username="alice", password="alicepass1") + bob_uid = _add_member_user(app, username="bob", password="bobspass1") + alice_token = app.state.auth.create_session(user_id=alice_uid, long_lived=True) + bob_token = app.state.auth.create_session(user_id=bob_uid, long_lived=True) + app.state._startup_complete = True + + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, base_url="http://test", + cookies={"taos_session": alice_token}, + ) as alice_c: + async with AsyncClient( + transport=transport, base_url="http://test", + cookies={"taos_session": bob_token}, + ) as bob_c: + alice_c._test_uid = alice_uid + alice_c._test_app = app + bob_c._test_uid = bob_uid + bob_c._test_app = app + yield alice_c, bob_c + + await app.state.project_store.close() + await app.state.project_task_store.close() # --------------------------------------------------------------------------- @@ -99,6 +158,26 @@ async def test_community_snapshot_not_found(client): assert resp.status_code == 404 +@pytest.mark.asyncio +async def test_community_snapshot_cross_user_404(two_member_clients): + """Non-owner gets masked 404, not 403, on another user's community snapshot.""" + alice, bob = two_member_clients + # Alice creates a project. + resp = await alice.post( + "/api/projects", + json={"name": "Alice Project", "slug": "alice-comm"}, + ) + pid = resp.json()["id"] + + # Alice can access her own snapshot. + resp = await alice.get(f"/api/projects/{pid}/community/snapshot") + assert resp.status_code == 200 + + # Bob gets a masked 404 (project existence is not leaked). + resp = await bob.get(f"/api/projects/{pid}/community/snapshot") + assert resp.status_code == 404 + + # --------------------------------------------------------------------------- # community/stats # --------------------------------------------------------------------------- @@ -142,12 +221,12 @@ async def test_community_stats(client): assert "tasks" not in body # Leaderboard should have at least one contributor. - if len(body["leaderboard"]) > 0: - lb = body["leaderboard"][0] - assert "actor" in lb - assert "claims" in lb - assert "closes" in lb - assert "total" in lb + assert len(body["leaderboard"]) > 0 + lb = body["leaderboard"][0] + assert "actor" in lb + assert "claims" in lb + assert "closes" in lb + assert "total" in lb @pytest.mark.asyncio @@ -157,6 +236,25 @@ async def test_community_stats_not_found(client): assert resp.status_code == 404 +@pytest.mark.asyncio +async def test_community_stats_cross_user_404(two_member_clients): + """Non-owner gets masked 404 on another user's community stats.""" + alice, bob = two_member_clients + resp = await alice.post( + "/api/projects", + json={"name": "Alice Stats", "slug": "alice-stats"}, + ) + pid = resp.json()["id"] + + # Alice can access her own stats. + resp = await alice.get(f"/api/projects/{pid}/community/stats") + assert resp.status_code == 200 + + # Bob gets masked 404. + resp = await bob.get(f"/api/projects/{pid}/community/stats") + assert resp.status_code == 404 + + # --------------------------------------------------------------------------- # Leaderboard rollup unit test (no HTTP — pure function) # --------------------------------------------------------------------------- diff --git a/tinyagentos/routes/community.py b/tinyagentos/routes/community.py index d59206828..c91ca6bcd 100644 --- a/tinyagentos/routes/community.py +++ b/tinyagentos/routes/community.py @@ -71,8 +71,8 @@ async def community_snapshot( project = await project_store.get_project(project_id) if project is None: raise HTTPException(status_code=404, detail="project not found") - if project.get("user_id") != user.user_id: - raise HTTPException(status_code=403, detail="not authorized") + if not user.is_admin and project.get("user_id") != user.user_id: + raise HTTPException(status_code=404, detail="project not found") # Project metadata (allowlisted subset). meta = { @@ -135,8 +135,8 @@ async def community_stats( project = await project_store.get_project(project_id) if project is None: raise HTTPException(status_code=404, detail="project not found") - if project.get("user_id") != user.user_id: - raise HTTPException(status_code=403, detail="not authorized") + if not user.is_admin and project.get("user_id") != user.user_id: + raise HTTPException(status_code=404, detail="project not found") # Status counts. all_tasks = await task_store.list_tasks(project_id=project_id)