[21e195cd] Panel-wide UI standardization and usability pass (#194)

* [4c179e3a] Add git pull, fetch, and rebase backend endpoints (#190)

* [f966f772] feat(git): add pull, fetch, and rebase endpoints with integration tests (#185)

- Add GitPullRequest/Response, GitFetchRequest/Response, GitRebaseRequest/Response schemas
- Add GitService.pull(), fetch(), and rebase() methods using _network_git_timeout()
- Add POST /api/git/pull, /api/git/fetch, /api/git/rebase route handlers
- Rebase detects conflicts via git diff --name-only --diff-filter=U and aborts cleanly
- Integration tests cover success path and GitCommandError→500 for all three endpoints
- Rebase conflict test verifies conflict=True with populated conflicted_files list

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [26e2b7af] test(git): add AsyncMock unit tests for rebase_onto_base conflict-state handling (#186)

New test_git_rebase.py covers three branches of rebase_onto_base:
- success path: rebase exits 0, returns rebased status, abort never called
- conflict path: non-zero exit → diff → abort → returns conflict+files
- resilience: both rebase and abort exit non-zero, still returns conflict dict without exception

All tests use AsyncMock with side_effect sequences to mock _run_git at the service-method level.

Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

* [551b1dbf] Panel-wide frontend UI standardization and page fixes (#193)

* [1ec787b2] feat(panel): design-system sweep — full-width layouts, scrollbar fix, Secretary button, component audit (#188)

- Settings page: remove max-w-3xl, wrap cards in grid-cols-1 lg:grid-cols-2 two-column layout
- AI Providers page: remove max-w-5xl so AIRoutingCard fills available width
- Journals AgentList: replace ScrollArea with overflow-y-auto div to eliminate nested scrollbar
- Secretary chat input: add items-stretch to flex row so Send/Start button matches Textarea height
- Component audit: replace all raw <button>/<input>/hand-rolled badge spans outside components/ui/ with canonical Button, Checkbox, Badge variants across 15 files:
  - ai-routing-card.tsx: ModeButton → Button, checkbox → Checkbox, badge spans → Badge
  - self-hosted-section.tsx: eye-toggle → Button ghost icon-sm, badge spans → Badge
  - journals/agent-item.tsx, communications/channel-item.tsx → Button ghost
  - kb-search-bar.tsx, kb-filters.tsx → Button ghost
  - kb-category-nav.tsx, git-log-panel.tsx → Button ghost
  - communications/page.tsx (channel + group lists) → Button ghost
  - projects/project-table.tsx, products/product-table.tsx → Button link
  - git-branch-panel.tsx (local + remote lists) → Button ghost
  - tasks/dependency-selector.tsx: Button ghost + Checkbox for visual indicator
  - tasks/task-table.tsx: sortable header + expand toggle → Button ghost
  - business/goals-tab.tsx: hidden button → Button

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [435b37b4] feat(metrics,notifications): URL-persisted tab state, semantic chart colors, humanized counts (#187)

- Notifications page: replace useState with useSearchParams/useRouter for
  ?tab= URL parameter (all/unread/pending, default: unread); Suspense wrapper
  with skeleton fallback for SSR compatibility.

- Metrics page: split into Performance tab (Velocity + Task Status + Agent
  Status + Team Health) and Token Usage tab (TokenUsageCostsSection) with
  ?tab= URL parameter (default: performance); Suspense wrapper; Refresh button
  moved inside PerformanceTabContent; humanizeCount() helper applies K/M
  suffixes to all MetricCard numeric values >= 1000.

- Chart components (usage-time-series, agent-usage, team-usage, model-donut):
  replace var(--chart-N) CSS vars with explicit semantic hex colors —
  #3b82f6 blue for informational, #f59e0b amber for warning/pending,
  #22c55e green for success/healthy, #ef4444 red for error/blocked,
  #a855f7 purple for supplemental.

pnpm lint and pnpm typecheck pass with zero new errors.

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [ccd256f4] Kanban mobile viewport: 375px layout, column navigation, 44px touch targets (#191)

* [ccd256f4] feat(kanban): mobile 375px layout with column navigator and 44px touch targets

- KanbanBoard: add activeColumnIndex state + mobile prev/next column
  navigator (lg:hidden); existing horizontal-scroll layout hidden on
  mobile (hidden lg:flex). Desktop DnD behavior unchanged.
- KanbanColumn: add optional className prop (cn-based) so mobile view
  can pass w-full/sm:w-full to fill the viewport.
- KanbanCard: bump all action buttons to min-h-11 (44px) touch targets
  (Assign, Pass, Fail, Move-forward).

* [ccd256f4] fix(kanban): change breakpoint from lg to sm for mobile/desktop layout switch

AC3 requires >=640px viewport to show multi-column layout (sm: breakpoint).
Previous impl used lg: (1024px), leaving 640-1023px in single-column mode.

Change:
- Mobile navigator div: lg:hidden → sm:hidden
- Desktop multi-column div: hidden lg:flex → hidden sm:flex

At <640px: single-column with prev/next navigator (375px mobile use case).
At >=640px: full horizontal-scroll multi-column layout (per AC3).
DnD behavior and all other layout unchanged.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [23f02af4] Agents page On-Demand section + Board composition; Overview Quick Actions visibility + Team Health Intake/Secretary (#189)

* [23f02af4] feat(agents,overview): On-Demand section, Board composition fix, Intake/Secretary in Quick Actions + Team Health

- agent-definitions.ts: remove AgentRole.MAIN_PM from getBoardAgents
  (Main PM has its own dedicated section; including it there was redundant).
  Add getOnDemandAgents() that catches agents not in any standard team
  (board/main_pm/backend/frontend/ux_ui/marketing) and not a standard cell
  role — surfaces prompter/intake agents that the API may return.

- agents/page.tsx: import getOnDemandAgents; add a conditional
  'On-Demand Agents' AgentGrid section (only rendered when the API returns
  at least one matching agent, e.g. the Intake interviewer).

- quick-actions-bar.tsx: add 'Task Intake' button (→/prompter, Sparkles
  icon) and 'Secretary' button (→/business?tab=secretary, Bot icon)
  alongside existing quick actions so operators can reach on-demand agents
  from the Overview in one click.

- team-health-cards.tsx: add OnDemandAgentCard sub-component (link card
  with On-Demand badge) and render static cards for 'Task Intake' and
  'Secretary' appended after the API-driven TeamHealthCard list, giving
  them equal visual presence in the Team Health section.

pnpm lint and pnpm typecheck pass with zero new errors.

* [23f02af4] fix(agents,overview): QA revision — enum entries, QuickActions placement, On-Demand title, Board PR_REVIEWER

AC3: types/index.ts AgentRole enum adds PR_REVIEWER, PROMPTER, SECRETARY.
     agent-selector.tsx ROLE_LABELS exhaustive Record updated accordingly.

AC4: command-center.tsx QuickActionsBar moved to after Team Health section,
     before CEO Approval Queue and data-heavy grid rows — visible without
     scrolling on a 900px viewport.

AC1: agents/page.tsx On-Demand AgentGrid title fixed to 'On-Demand'
     (was 'On-Demand Agents' in prior commit).

AC2: agent-definitions.ts getBoardAgents adds explicit PR_REVIEWER inclusion
     and uses inclusion-based getOnDemandAgents (PROMPTER|SECRETARY roles).

AC5: team-health-cards.tsx static OnDemandAgentCard implementation refined
     with correct fallback rendering when no API team data.

AC6: pnpm lint and pnpm typecheck (src only) pass with zero new errors.

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [b1c59206] Git page: Pull, Fetch, Rebase buttons wired to backend; Rebase destructive confirmation dialog (#192)

* [b1c59206] feat(git): add Pull, Fetch, Rebase operations to Git page with destructive confirmation dialog for Rebase

- Add GitPullRequest/Response, GitFetchRequest/Response, GitRebaseRequest/Response types
- Add gitApi.pull(), gitApi.fetch(), gitApi.rebase() with mock stubs for /git/pull, /git/fetch, /git/rebase
- Add useGitPull, useGitFetch, useGitRebase mutation hooks with cache invalidation; exported via useGitOperations
- Add Pull (Download icon), Fetch (RefreshCcw icon), Rebase (GitGraph icon) buttons to GitActionsPanel
- Rebase button triggers AlertDialog with destructive confirmation before calling API
- Wire handlePull, handleFetch, handleRebase handlers in git-browser.tsx with toast feedback

* [b1c59206] fix(git): add destructive styling and branch name to Rebase AlertDialog

- Add className='border-destructive bg-destructive/5' to AlertDialogContent
  so the dialog container has the required red-tinted styling (AC3)
- Update AlertDialogDescription to interpolate status?.current_branch so
  the dialog body explicitly names the branch being rebased (AC3)

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [3f305ed9] Frontend: Fix git control contract, complete Secretary restyling, and apply polish (CEO revision) (#199)

* [72de8a65] fix(git): correct Pull/Fetch/Rebase types, API mocks, request fields, and toast handlers (#197)

- types/git.ts: GitPullResponse and GitFetchResponse now have current_branch,
  has_changes, staged_files, unstaged_files, untracked_files, ahead, behind
  (matching backend GitStatusResponse); removed nonexistent commits_received/
  refs_updated/remote fields
- types/git.ts: GitRebaseRequest now uses target_branch: string (not onto?: string);
  GitRebaseResponse now has conflict: boolean and conflicted_files: string[]
  (removed branch/onto/commits_rebased); task_id made optional on all three
  request types
- lib/api/git.ts: Updated mock returns for pull/fetch/rebase to match new types
- git-actions-panel.tsx: onRebase prop now (targetBranch: string) => void;
  Rebase AlertDialog now contains an Input for target_branch; AlertDialogAction
  disabled when targetBranch empty and passes the value to onRebase
- git-browser.tsx: handlePull and handleFetch toast references result.current_branch;
  handleRebase accepts targetBranch, sends target_branch in payload, toasts
  result.conflict and result.conflicted_files; no 'manual' task_id for any
  pull/fetch/rebase operation

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [be6a17fc] feat(ui): design-system polish — chart tokens, KB aria-label, Kanban touch targets (#196)

- kb-search-bar.tsx: add aria-label="Clear search" to the clear (X) button
- model-usage-donut.tsx: replace hex CHART_COLORS with var(--chart-1)…var(--chart-5)
- usage-time-series-chart.tsx: replace hex stopColor/stroke with var(--chart-1)/var(--chart-2)
- agent-usage-chart.tsx: Bar fill hex → var(--chart-1)
- team-usage-chart.tsx: Bar fill hex → var(--chart-1)
- kanban-card.tsx: min-h-11 → max-sm:min-h-11 (44px touch target mobile-only, 3 buttons)
- secretary-tab.tsx: already compliant (Button + design-system tokens), no change needed

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [d62036bd] Backend: Fix git endpoint schemas, add safety gates, and unit tests (CEO revision) (#200)

* [d0593fe3] feat(git): remove agent_id from schemas and add service-layer safety gates (#195)

- Remove agent_id field from all 9 git request schemas (GitCreateBranchRequest, GitCheckoutRequest, GitCommitRequest, GitPushRequest, GitCreatePRRequest, GitMergePRRequest, GitPullRequest, GitFetchRequest, GitRebaseRequest); agent identity comes from JWT auth context
- Make task_id Optional[UUID]=None in GitPullRequest, GitFetchRequest, GitRebaseRequest
- Add field_validator to GitRebaseRequest rejecting target_branch starting with '-' or equal to 'master'/'main'
- Add lightweight PullRequest, FetchRequest, RebaseRequest schemas for gateway layer
- Add dirty-workspace check to GitService.pull() (raises ValidationError if porcelain output)
- Switch GitService.pull() to --ff-only; raises ValidationError with diverged-branch message on non-zero exit
- Add master/main guard to GitService.rebase() for both head_branch and target_branch
- Update callers: routes/tasks.py (2x), services/task.py, tests/unit/services/test_git.py (2x)

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [a2f96961] Add role-gated rebase endpoint and unit tests (test_git_rebase.py) (#198)

* [a2f96961] feat(git): add role-gated rebase endpoint and unit tests

Add role-gate to POST /rebase restricting access to DEVELOPER and
CELL_PM roles; add master/main protected-branch guard to
GitService.rebase() before any git subprocess runs; add 4 unit tests
in tests/unit/services/test_git_rebase.py covering both
target-branch and head-branch REBASE_FORBIDDEN cases

* [a2f96961] fix(git): invert rebase role gate, add ownership check, schema validator, and missing tests

- _REBASE_ALLOWED_ROLES changed from {DEVELOPER, CELL_PM} to {CEO, CELL_PM, MAIN_PM}
  so developers correctly receive 403 per AC1/AC2
- rebase_branch() now verifies task ownership for non-CEO PM callers: if task_id
  is provided and the task is not assigned to the calling agent, returns 403/404
- GitRebaseRequest.target_branch gets a @field_validator rejecting '-' prefix
  names and protected branch names (main, master, develop)
- GitService.pull() gains pre-flight safety gates: raises ValidationError
  DIRTY_TREE when staged/unstaged changes exist, DIVERGED_BRANCH when
  ahead > 0 and behind > 0
- test_git_rebase.py adds 9 new tests: pull() dirty-tree ValidationError,
  pull() diverged-branch ValidationError, pull() success path, schema
  validator for '-' prefix and protected names, and route-level tests
  confirming HTTP 403 for DEVELOPER and HTTP 200 for CELL_PM on POST /rebase

* [a2f96961] fix(tests): add type annotations for tuple variables in test_git_rebase.py

mypy needs explicit tuple type annotations when assigning bare tuples
to variables used as mock side_effect return values — fixes var-annotated
error caught by the server-side quality gate

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* [94015c6d] Frontend R3: Fix legacy git taskId coercion + rebase placeholder + phantom fields (#204)

* [401ddb40] fix(git): remove phantom fields from GitPullRequest/GitFetchRequest and make task_id optional in write request interfaces; use taskId || undefined in git-browser.tsx handlers to avoid 422 errors when no task context is active (#201)

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [cca8d0c0] fix(git): fix rebase placeholder and surface backend error in toast (#202)

git-actions-panel.tsx: change rebase target_branch Input placeholder
from "e.g. main or origin/main" to "Remote ref (e.g. origin/HEAD)" so
no default branch name (main/master/develop) is suggested.

git-browser.tsx: import getErrorMessage from @/lib/api/client and use
it in handleRebase catch block instead of the hardcoded string "Failed
to rebase". getErrorMessage extracts the real detail from
AxiosError.response.data.detail and falls back to a non-empty generic
message, satisfying both the detail-surfacing and fallback criteria.

Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>

* [1ea0fbcb] Backend R3: Relax legacy git schemas + fix integration tests (#206)

* [219c539b] Make task_id Optional in git request schemas and update service methods (#205)

* [219c539b] feat(git): make task_id Optional in git schemas and add None-guards in service methods

- GitCommitRequest, GitPushRequest, GitCreatePRRequest, GitMergePRRequest now have task_id: UUID | None = None
- commit_for_task, push_for_task, create_pr_for_task, merge_pr_for_task skip ownership/state checks when task_id is None and proceed to the git operation
- Added 16 unit tests in tests/unit/api/routes/test_git_optional_task_id.py covering schema validation and HTTP endpoint responses
- Added 4 integration tests in tests/integration/test_git_routes.py for no-422 behaviour
- All quality gates pass: ruff format, ruff check, mypy, pytest

* [219c539b] fix(tests): remove unused type-ignore comments, redundant cast, and invalid agent_id kwarg in git_optional_task_id unit tests

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [de95ce94] test(git): fix 3 rebase integration tests to use non-protected target_branch (#203)

- Add pm_git_client fixture (CELL_PM role) needed for the role-gated rebase endpoint
- Change target_branch from 'main' to 'develop' in test_rebase_success, test_rebase_conflict, and test_rebase_git_command_error
- Remove task_id from request bodies (optional field; random UUIDs trigger 404)
- Switch all 3 rebase tests to use pm_git_client instead of git_client

Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>

* chore: ruff format test_agent_image_registry.py (unblock quality gate)

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-17 06:36:28 +02:00
committed by GitHub
co-authored by Backend Developer 2 Backend Developer 1 Frontend Developer 1 Frontend Developer 2 Renn F
parent e71a746499
commit 818f2ac7a6
48 changed files with 2867 additions and 575 deletions
+14
View File
@@ -17,6 +17,7 @@ import {
getBackendAgents,
getFrontendAgents,
getUxAgents,
getOnDemandAgents,
} from "@/lib/agent-definitions";
import {
OrchestratorStatusCards,
@@ -134,6 +135,19 @@ export default function AgentsPage() {
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
{/* On-Demand section: Prompter/Intake and Secretary agents — only rendered
when the API returns at least one matching agent */}
{getOnDemandAgents(agents).length > 0 && (
<AgentGrid
title="On-Demand"
agents={getOnDemandAgents(agents)}
agentStatuses={agentStatuses}
agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
)}
</div>
);
}
@@ -53,13 +53,14 @@ function ChannelList({ channels, selectedId, onSelect, isLoading }: ChannelListP
{title}
</h4>
{items.map((channel) => (
<button
<Button
key={channel.id}
onClick={() => onSelect(channel.id)}
variant="ghost"
className={
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors " +
"w-full h-auto justify-start gap-2 px-3 py-2 text-sm font-normal whitespace-normal " +
(selectedId === channel.id
? "bg-primary text-primary-foreground"
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
: "hover:bg-muted")
}
>
@@ -69,7 +70,7 @@ function ChannelList({ channels, selectedId, onSelect, isLoading }: ChannelListP
<Hash className="h-4 w-4 shrink-0" />
)}
<span className="truncate">{channel.name}</span>
</button>
</Button>
))}
</div>
);
@@ -135,13 +136,14 @@ function GroupList({ channelId, selectedId, onSelect }: GroupListProps) {
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
{groups.map((group) => (
<button
<Button
key={group.id}
onClick={() => onSelect(group.id)}
variant="ghost"
className={
"w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm text-left transition-colors " +
"w-full h-auto justify-between px-3 py-2 text-sm font-normal whitespace-normal " +
(selectedId === group.id
? "bg-primary text-primary-foreground"
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
: "hover:bg-muted")
}
>
@@ -152,7 +154,7 @@ function GroupList({ channelId, selectedId, onSelect }: GroupListProps) {
<Badge variant="secondary" className="text-xs shrink-0 ml-2">
{group.total_messages}
</Badge>
</button>
</Button>
))}
</div>
</ScrollArea>
+239 -156
View File
@@ -1,5 +1,7 @@
"use client";
import { Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useOrchestratorStatus } from "@/hooks/use-agents";
import { useTasks } from "@/hooks/use-tasks";
import {
@@ -14,10 +16,10 @@ import {
} from "@/hooks/use-usage";
import { TaskStatus, Team } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { OfflineState } from "@/components/ui/offline-state";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OfflineState } from "@/components/ui/offline-state";
import {
UsageTimeSeriesChart,
ModelUsageDonut,
@@ -34,12 +36,30 @@ import {
Users,
CheckCircle,
XCircle,
RefreshCw,
Zap,
Timer,
Coins,
Sparkles,
} from "lucide-react";
import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
// ─── Humanized number formatting ─────────────────────────────────────────────
/** Format counts with K/M suffix for values >= 1000. */
function humanizeCount(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return String(n);
}
/** Format token counts (same as humanizeCount but used for token display). */
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return String(n);
}
// ─── Shared sub-components ────────────────────────────────────────────────────
interface MetricCardProps {
title: string;
@@ -51,6 +71,7 @@ interface MetricCardProps {
}
function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricCardProps) {
const displayValue = typeof value === "number" ? humanizeCount(value) : value;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -60,12 +81,12 @@ function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricC
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<div className="text-2xl font-bold">{displayValue}</div>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
)}
{trend && trendValue && (
<div className={"flex items-center gap-1 mt-2 text-xs " +
<div className={"flex items-center gap-1 mt-2 text-xs " +
(trend === "up" ? "text-green-600" : trend === "down" ? "text-red-600" : "text-gray-500")
}>
<TrendingUp className={"h-3 w-3 " + (trend === "down" ? "rotate-180" : "")} />
@@ -118,7 +139,9 @@ function TeamHealthCard({ team, activeTasks, blockedTasks, completedToday }: Tea
);
}
export default function MetricsPage() {
// ─── Performance tab content ─────────────────────────────────────────────────
function PerformanceTabContent() {
const { data: tasks, error: tasksError, refetch: refetchTasks } = useTasks();
const { data: status, error: statusError, refetch: refetchStatus } = useOrchestratorStatus();
@@ -132,7 +155,6 @@ export default function MetricsPage() {
refetchStatus();
};
// Calculate metrics from local data
const taskList = tasks || [];
const agentList = status?.agents || [];
@@ -159,7 +181,7 @@ export default function MetricsPage() {
const awaitingQa = taskList.filter((t) => t.status === TaskStatus.AWAITING_QA).length;
const completed = taskList.filter((t) => t.status === TaskStatus.COMPLETED).length;
// Agent counts (from by_state if available, or count from agents array)
// Agent counts
const runningAgents = status?.by_state?.running || agentList.filter((a) => a.state === "running").length;
const idleAgents = status?.by_state?.idle || agentList.filter((a) => a.state === "idle" || a.state === "stopped").length;
const waitingAgents = status?.waiting_count || agentList.filter((a) => a.state === "waiting_long").length;
@@ -170,164 +192,145 @@ export default function MetricsPage() {
const teamTasks = taskList.filter((t) => t.team === team);
return {
team,
activeTasks: teamTasks.filter((t) =>
activeTasks: teamTasks.filter((t) =>
[TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED].includes(t.status)
).length,
blockedTasks: teamTasks.filter((t) => t.status === TaskStatus.BLOCKED).length,
completedToday: teamTasks.filter((t) => {
if (!t.completed_at) return false;
const completed = new Date(t.completed_at);
const c = new Date(t.completed_at);
const today = new Date();
return completed.toDateString() === today.toDateString();
return c.toDateString() === today.toDateString();
}).length,
};
});
if (isOffline) {
return (
<OfflineState
title="Cannot Load Performance Metrics"
description="Start the RoboCo orchestrator to view performance analytics."
onRetry={refetch}
/>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Metrics</h1>
<p className="text-muted-foreground">
Performance analytics and operational insights
</p>
{/* Velocity Metrics */}
<div>
<h2 className="text-lg font-semibold mb-3">Velocity</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
<MetricCard
title="Completed Today"
value={completedToday}
subtitle="Tasks finished"
icon={<Zap className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Completed This Week"
value={completedThisWeek}
subtitle="Rolling 7 days"
icon={<TrendingUp className="h-4 w-4 text-blue-500" />}
/>
<MetricCard
title="Total Completed"
value={completed}
subtitle="All time"
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Completion Rate"
value={taskList.length > 0 ? Math.round((completed / taskList.length) * 100) + "%" : "0%"}
subtitle="Of all tasks"
icon={<Activity className="h-4 w-4 text-purple-500" />}
/>
</div>
<Button variant="outline" onClick={refetch}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load Metrics"
description="Start the RoboCo orchestrator to view performance analytics."
onRetry={refetch}
/>
) : (
<>
{/* Velocity Metrics */}
<div>
<h2 className="text-lg font-semibold mb-3">Velocity</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
<MetricCard
title="Completed Today"
value={completedToday}
subtitle="Tasks finished"
icon={<Zap className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Completed This Week"
value={completedThisWeek}
subtitle="Rolling 7 days"
icon={<TrendingUp className="h-4 w-4 text-blue-500" />}
/>
<MetricCard
title="Total Completed"
value={completed}
subtitle="All time"
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Completion Rate"
value={taskList.length > 0 ? Math.round((completed / taskList.length) * 100) + "%" : "0%"}
subtitle="Of all tasks"
icon={<Activity className="h-4 w-4 text-purple-500" />}
/>
</div>
</div>
{/* Task Status */}
<div>
<h2 className="text-lg font-semibold mb-3">Task Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5">
<MetricCard
title="Pending"
value={pending}
icon={<Clock className="h-4 w-4 text-gray-500" />}
/>
<MetricCard
title="In Progress"
value={inProgress}
icon={<Activity className="h-4 w-4 text-blue-500" />}
/>
<MetricCard
title="Blocked"
value={blocked}
icon={<AlertTriangle className="h-4 w-4 text-red-500" />}
/>
<MetricCard
title="Awaiting QA"
value={awaitingQa}
icon={<Timer className="h-4 w-4 text-yellow-500" />}
/>
<MetricCard
title="Completed"
value={completed}
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/>
</div>
</div>
{/* Task Status */}
<div>
<h2 className="text-lg font-semibold mb-3">Task Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5">
<MetricCard
title="Pending"
value={pending}
icon={<Clock className="h-4 w-4 text-gray-500" />}
/>
<MetricCard
title="In Progress"
value={inProgress}
icon={<Activity className="h-4 w-4 text-blue-500" />}
/>
<MetricCard
title="Blocked"
value={blocked}
icon={<AlertTriangle className="h-4 w-4 text-red-500" />}
/>
<MetricCard
title="Awaiting QA"
value={awaitingQa}
icon={<Timer className="h-4 w-4 text-yellow-500" />}
/>
<MetricCard
title="Completed"
value={completed}
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/>
</div>
</div>
{/* Agent Status */}
<div>
<h2 className="text-lg font-semibold mb-3">Agent Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
<MetricCard
title="Running"
value={runningAgents}
subtitle="Active agents"
icon={<Users className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Idle"
value={idleAgents}
subtitle="Available"
icon={<Users className="h-4 w-4 text-gray-500" />}
/>
<MetricCard
title="Waiting"
value={waitingAgents}
subtitle="Needs input"
icon={<Clock className="h-4 w-4 text-yellow-500" />}
/>
<MetricCard
title="Errors"
value={errorAgents}
subtitle="Failed agents"
icon={<XCircle className="h-4 w-4 text-red-500" />}
/>
</div>
</div>
{/* Agent Status */}
<div>
<h2 className="text-lg font-semibold mb-3">Agent Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4">
<MetricCard
title="Running"
value={runningAgents}
subtitle="Active agents"
icon={<Users className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Idle"
value={idleAgents}
subtitle="Available"
icon={<Users className="h-4 w-4 text-gray-500" />}
/>
<MetricCard
title="Waiting"
value={waitingAgents}
subtitle="Needs input"
icon={<Clock className="h-4 w-4 text-yellow-500" />}
/>
<MetricCard
title="Errors"
value={errorAgents}
subtitle="Failed agents"
icon={<XCircle className="h-4 w-4 text-red-500" />}
/>
</div>
</div>
{/* Team Health */}
<div>
<h2 className="text-lg font-semibold mb-3">Team Health</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
{teamMetrics.map((tm) => (
<TeamHealthCard
key={tm.team}
team={tm.team}
activeTasks={tm.activeTasks}
blockedTasks={tm.blockedTasks}
completedToday={tm.completedToday}
/>
))}
</div>
</div>
{/* ─── Token Usage & Costs ─────────────────────────────────── */}
<TokenUsageCostsSection />
</>
)}
{/* Team Health */}
<div>
<h2 className="text-lg font-semibold mb-3">Team Health</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
{teamMetrics.map((tm) => (
<TeamHealthCard
key={tm.team}
team={tm.team}
activeTasks={tm.activeTasks}
blockedTasks={tm.blockedTasks}
completedToday={tm.completedToday}
/>
))}
</div>
</div>
</div>
);
}
// =============================================================================
// TOKEN USAGE & COSTS SECTION
// =============================================================================
// ─── Token Usage & Costs tab content ─────────────────────────────────────────
function TokenUsageCostsSection() {
const { data: summary, isLoading: loadingSnap } = useUsageSummary("24h");
@@ -343,8 +346,6 @@ function TokenUsageCostsSection() {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold">Token Usage &amp; Costs</h2>
{/* Row 1 — Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 2xl:grid-cols-6">
<SummaryCard
@@ -411,7 +412,7 @@ function TokenUsageCostsSection() {
<CacheEfficiencyCard cacheStats={cacheStats} isLoading={loadingCache} />
</div>
{/* Row 5 — Sessions table (mock-mode only; empty in production) */}
{/* Row 5 — Sessions table */}
<SessionsTable data={sessions} isLoading={loadingSessions} />
</div>
);
@@ -419,12 +420,6 @@ function TokenUsageCostsSection() {
// ─── Helper sub-components ────────────────────────────────────────────────────
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return String(n);
}
interface SummaryCardProps {
title: string;
value: string | undefined;
@@ -463,8 +458,6 @@ function SummaryCard({ title, value, icon, trend, isLoading }: SummaryCardProps)
);
}
import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
interface ProjectionCardProps {
projection: UP | undefined;
isLoading: boolean;
@@ -531,3 +524,93 @@ function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps
</Card>
);
}
// ─── Tab types ────────────────────────────────────────────────────────────────
type MetricsTab = "performance" | "token-usage";
const VALID_METRICS_TABS: MetricsTab[] = ["performance", "token-usage"];
function isValidMetricsTab(value: string | null): value is MetricsTab {
return VALID_METRICS_TABS.includes(value as MetricsTab);
}
// ─── Main page content (uses useSearchParams) ─────────────────────────────────
function MetricsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read ?tab= from URL, default to "performance"
const rawTab = searchParams.get("tab");
const activeTab: MetricsTab = isValidMetricsTab(rawTab) ? rawTab : "performance";
function handleTabChange(value: string) {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.push(`?${params.toString()}`);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Metrics</h1>
<p className="text-muted-foreground">
Performance analytics and operational insights
</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="performance">Performance</TabsTrigger>
<TabsTrigger value="token-usage">Token Usage</TabsTrigger>
</TabsList>
<TabsContent value="performance" className="mt-6">
<PerformanceTabContent />
</TabsContent>
<TabsContent value="token-usage" className="mt-6">
<TokenUsageCostsSection />
</TabsContent>
</Tabs>
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function MetricsPage() {
return (
<Suspense fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-32 mb-2" />
<Skeleton className="h-5 w-72" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
</div>
}>
<MetricsPageContent />
</Suspense>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
useNotifications,
useMarkNotificationRead,
@@ -105,17 +106,34 @@ function NotificationCard({ notification, onMarkRead, onAcknowledge }: Notificat
);
}
export default function NotificationsPage() {
// Default to Unread: the actionable view. Landing on "All" buries new
// notifications under everything already seen.
const [activeTab, setActiveTab] = useState<"all" | "unread" | "pending">("unread");
type TabValue = "all" | "unread" | "pending";
const VALID_TABS: TabValue[] = ["all", "unread", "pending"];
function isValidTab(value: string | null): value is TabValue {
return VALID_TABS.includes(value as TabValue);
}
function NotificationsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read ?tab= from URL, default to "unread" (most actionable view)
const rawTab = searchParams.get("tab");
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "unread";
function handleTabChange(value: string) {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.push(`?${params.toString()}`);
}
const { data, isLoading, error, refetch } = useNotifications(
activeTab === "unread" ? { unread_only: true } :
activeTab === "pending" ? { pending_ack_only: true } :
undefined
);
const markRead = useMarkNotificationRead();
const acknowledge = useAcknowledgeNotification();
const markAllRead = useMarkAllNotificationsRead();
@@ -219,7 +237,7 @@ export default function NotificationsPage() {
onRetry={() => refetch()}
/>
) : (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="unread">
@@ -262,3 +280,46 @@ export default function NotificationsPage() {
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function NotificationsPage() {
return (
<Suspense fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-72" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-36" />
<Skeleton className="h-9 w-24" />
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-16" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-12" />
</CardContent>
</Card>
))}
</div>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
}>
<NotificationsPageContent />
</Suspense>
);
}
@@ -2,7 +2,7 @@ import { AIRoutingCard } from "@/components/settings/ai-routing-card";
export default function AIProvidersPage() {
return (
<div className="space-y-6 max-w-5xl">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">AI Providers</h1>
<p className="text-muted-foreground">
+175 -172
View File
@@ -43,7 +43,7 @@ export default function SettingsPage() {
};
return (
<div className="space-y-6 max-w-3xl">
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
@@ -52,189 +52,192 @@ export default function SettingsPage() {
</p>
</div>
{/* Appearance */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Appearance
</CardTitle>
<CardDescription>Customize the look and feel of the panel</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Theme</Label>
<p className="text-sm text-muted-foreground">
Select your preferred color scheme
</p>
{/* Cards grid — two columns on large screens */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Appearance */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Appearance
</CardTitle>
<CardDescription>Customize the look and feel of the panel</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Theme</Label>
<p className="text-sm text-muted-foreground">
Select your preferred color scheme
</p>
</div>
<Select value={theme} onValueChange={setTheme}>
<SelectTrigger className="w-auto min-w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={theme} onValueChange={setTheme}>
<SelectTrigger className="w-auto min-w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Collapsed Sidebar</Label>
<p className="text-sm text-muted-foreground">
Show icons only in the sidebar
</p>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Collapsed Sidebar</Label>
<p className="text-sm text-muted-foreground">
Show icons only in the sidebar
</p>
</div>
<Switch
checked={sidebarCollapsed}
onCheckedChange={setSidebarCollapsed}
/>
</div>
<Switch
checked={sidebarCollapsed}
onCheckedChange={setSidebarCollapsed}
/>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
<CardDescription>Configure how you receive updates</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Enable Notifications</Label>
<p className="text-sm text-muted-foreground">
Receive real-time notifications from agents
</p>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
<CardDescription>Configure how you receive updates</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Enable Notifications</Label>
<p className="text-sm text-muted-foreground">
Receive real-time notifications from agents
</p>
</div>
<Switch
checked={notificationsEnabled}
onCheckedChange={setNotificationsEnabled}
/>
</div>
<Switch
checked={notificationsEnabled}
onCheckedChange={setNotificationsEnabled}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Sound Alerts</Label>
<p className="text-sm text-muted-foreground">
Play sound for important notifications
</p>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Sound Alerts</Label>
<p className="text-sm text-muted-foreground">
Play sound for important notifications
</p>
</div>
<Switch
checked={soundEnabled}
onCheckedChange={setSoundEnabled}
disabled={!notificationsEnabled}
/>
</div>
<Switch
checked={soundEnabled}
onCheckedChange={setSoundEnabled}
disabled={!notificationsEnabled}
/>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Data & Refresh */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Data & Refresh
</CardTitle>
<CardDescription>Configure data fetching behavior</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto Refresh</Label>
<p className="text-sm text-muted-foreground">
Automatically refresh data periodically
</p>
{/* Data & Refresh */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Data & Refresh
</CardTitle>
<CardDescription>Configure data fetching behavior</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto Refresh</Label>
<p className="text-sm text-muted-foreground">
Automatically refresh data periodically
</p>
</div>
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
</div>
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Refresh Interval</Label>
<p className="text-sm text-muted-foreground">
How often to fetch new data (seconds)
</p>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Refresh Interval</Label>
<p className="text-sm text-muted-foreground">
How often to fetch new data (seconds)
</p>
</div>
<Select
value={refreshInterval}
onValueChange={setRefreshInterval}
disabled={!autoRefresh}
>
<SelectTrigger className="w-auto min-w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10s</SelectItem>
<SelectItem value="30">30s</SelectItem>
<SelectItem value="60">1m</SelectItem>
<SelectItem value="300">5m</SelectItem>
</SelectContent>
</Select>
</div>
<Select
value={refreshInterval}
onValueChange={setRefreshInterval}
disabled={!autoRefresh}
>
<SelectTrigger className="w-auto min-w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10s</SelectItem>
<SelectItem value="30">30s</SelectItem>
<SelectItem value="60">1m</SelectItem>
<SelectItem value="300">5m</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Transcript Retention (panel-tunable; persisted server-side) */}
<TranscriptRetentionCard />
{/* Transcript Retention (panel-tunable; persisted server-side) */}
<TranscriptRetentionCard />
{/* Connection Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
Connection Info
</CardTitle>
<CardDescription>Backend API configuration (read-only)</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>API URL</Label>
<Input value={API_URL} readOnly className="bg-muted" />
</div>
<div className="space-y-2">
<Label>WebSocket URL</Label>
<Input value={WS_URL} readOnly className="bg-muted" />
</div>
<p className="text-xs text-muted-foreground">
These values are configured via environment variables (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
</p>
</CardContent>
</Card>
{/* Connection Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
Connection Info
</CardTitle>
<CardDescription>Backend API configuration (read-only)</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>API URL</Label>
<Input value={API_URL} readOnly className="bg-muted" />
</div>
<div className="space-y-2">
<Label>WebSocket URL</Label>
<Input value={WS_URL} readOnly className="bg-muted" />
</div>
<p className="text-xs text-muted-foreground">
These values are configured via environment variables (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
</p>
</CardContent>
</Card>
{/* User Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
User Info
</CardTitle>
<CardDescription>Your account information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-2xl">CEO</span>
{/* User Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
User Info
</CardTitle>
<CardDescription>Your account information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-2xl">CEO</span>
</div>
<div>
<p className="font-semibold text-lg">Renzo</p>
<p className="text-sm text-muted-foreground">Chief Executive Officer</p>
<p className="text-xs text-muted-foreground mt-1">
Agent ID: 00000000-0000-0000-0000-000000000001
</p>
</div>
</div>
<div>
<p className="font-semibold text-lg">Renzo</p>
<p className="text-sm text-muted-foreground">Chief Executive Officer</p>
<p className="text-xs text-muted-foreground mt-1">
Agent ID: 00000000-0000-0000-0000-000000000001
</p>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
{/* Save Button */}
<div className="flex justify-end">
@@ -33,11 +33,14 @@ const ROLE_LABELS: Record<AgentRole, string> = {
[AgentRole.PRODUCT_OWNER]: "Product Owner",
[AgentRole.HEAD_MARKETING]: "Head Marketing",
[AgentRole.AUDITOR]: "Auditor",
[AgentRole.PR_REVIEWER]: "PR Reviewer",
[AgentRole.MAIN_PM]: "Main PM",
[AgentRole.CELL_PM]: "Cell PM",
[AgentRole.DEVELOPER]: "Developer",
[AgentRole.QA]: "QA",
[AgentRole.DOCUMENTER]: "Documenter",
[AgentRole.PROMPTER]: "Prompter",
[AgentRole.SECRETARY]: "Secretary",
};
export function AgentSelector({
+2 -2
View File
@@ -341,8 +341,8 @@ function GoalsForm({ goals, refetch }: GoalsFormProps) {
)}
</div>
{/* Hidden — just to make the refetch prop used */}
<button type="button" className="hidden" onClick={refetch} />
{/* Hidden — keeps the refetch prop wired up for future use */}
<Button type="button" className="hidden" onClick={refetch} />
</CardContent>
</Card>
);
@@ -245,7 +245,7 @@ export function SecretaryTab() {
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<ChatMessages messages={messages} streaming={streaming} />
<div className="flex gap-2">
<div className="flex items-stretch gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -2,6 +2,7 @@
import { Channel } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Hash, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -19,12 +20,13 @@ export function ChannelItem({
unreadCount = 0,
}: ChannelItemProps) {
return (
<button
<Button
onClick={onClick}
variant="ghost"
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left transition-colors",
"w-full h-auto justify-start gap-2 px-2 py-1.5 font-normal whitespace-normal",
isSelected
? "bg-primary/10 text-primary"
? "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
@@ -39,6 +41,6 @@ export function ChannelItem({
{unreadCount}
</Badge>
)}
</button>
</Button>
);
}
@@ -72,6 +72,14 @@ export function CommandCenter() {
/>
</section>
{/* Quick Actions — placed immediately after Team Health so it is
visible without scrolling on a 900px-tall viewport, before the
data-heavy grid rows below. */}
<section>
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<QuickActionsBar />
</section>
{/* CEO Approval Queue + Strategy Signals - side-by-side on lg+ */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CeoApprovalQueue />
@@ -99,12 +107,6 @@ export function CommandCenter() {
isLoading={loadingActivity}
/>
</div>
{/* Quick Actions */}
<section className="pt-4 border-t">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<QuickActionsBar />
</section>
</div>
);
}
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { CreateTaskDialog } from "@/components/tasks/create-task-dialog";
import { Users, Megaphone, BookOpen, Shield } from "lucide-react";
import { Users, Megaphone, BookOpen, Shield, Sparkles, Bot } from "lucide-react";
import Link from "next/link";
export function QuickActionsBar() {
@@ -17,6 +17,20 @@ export function QuickActionsBar() {
</Button>
</Link>
<Link href="/prompter">
<Button variant="outline">
<Sparkles className="h-4 w-4 mr-2" />
Task Intake
</Button>
</Link>
<Link href="/business?tab=secretary">
<Button variant="outline">
<Bot className="h-4 w-4 mr-2" />
Secretary
</Button>
</Link>
<Link href="/communications">
<Button variant="outline">
<Megaphone className="h-4 w-4 mr-2" />
@@ -3,12 +3,43 @@
import { TeamHealth } from "@/types";
import { TeamHealthCard } from "./team-health-card";
import { Skeleton } from "@/components/ui/skeleton";
import { Sparkles, Bot } from "lucide-react";
import Link from "next/link";
interface TeamHealthCardsProps {
teams: TeamHealth[] | undefined;
isLoading: boolean;
}
/** Static link-card for on-demand agents (Intake, Secretary) that are not
* part of any standing team and therefore never appear in the API health data. */
function OnDemandAgentCard({
title,
href,
icon: Icon,
description,
}: {
title: string;
href: string;
icon: React.ElementType;
description: string;
}) {
return (
<Link href={href} className="block">
<div className="rounded-lg border bg-card p-4 hover:bg-accent/50 transition-colors h-full flex flex-col gap-2">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span>
<span className="ml-auto text-xs rounded-full bg-secondary px-2 py-0.5 text-secondary-foreground">
On-Demand
</span>
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</Link>
);
}
export function TeamHealthCards({ teams, isLoading }: TeamHealthCardsProps) {
if (isLoading) {
return (
@@ -20,19 +51,33 @@ export function TeamHealthCards({ teams, isLoading }: TeamHealthCardsProps) {
);
}
if (!teams || teams.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No team health data available
</div>
);
}
const hasTeams = teams && teams.length > 0;
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-6 gap-4">
{teams.map((health) => (
<TeamHealthCard key={health.team} health={health} />
))}
{hasTeams ? (
teams.map((health) => (
<TeamHealthCard key={health.team} health={health} />
))
) : (
<div className="col-span-full text-center py-8 text-muted-foreground">
No team health data available
</div>
)}
{/* Static on-demand agent cards — always visible regardless of API data */}
<OnDemandAgentCard
title="Task Intake"
href="/prompter"
icon={Sparkles}
description="Intake interviewer — chat with CEO to draft and submit new tasks"
/>
<OnDemandAgentCard
title="Secretary"
href="/business?tab=secretary"
icon={Bot}
description="Secretary agent — manage business goals, pitches, and notes"
/>
</div>
);
}
@@ -15,6 +15,17 @@ import {
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
GitCommit,
Upload,
@@ -22,6 +33,9 @@ import {
GitMerge,
RefreshCw,
ArrowUp,
Download,
RefreshCcw,
GitGraph,
} from "lucide-react";
interface GitActionsPanelProps {
@@ -33,10 +47,16 @@ interface GitActionsPanelProps {
onPush: (force?: boolean) => void;
onCreatePR: (title: string, body: string) => void;
onMergePR: (prNumber: number) => void;
onPull: () => void;
onFetch: () => void;
onRebase: (targetBranch: string) => void;
isCommitting: boolean;
isPushing: boolean;
isCreatingPR: boolean;
isMerging: boolean;
isPulling: boolean;
isFetching: boolean;
isRebasing: boolean;
}
export function GitActionsPanel({
@@ -48,10 +68,16 @@ export function GitActionsPanel({
onPush,
onCreatePR,
onMergePR,
onPull,
onFetch,
onRebase,
isCommitting,
isPushing,
isCreatingPR,
isMerging,
isPulling,
isFetching,
isRebasing,
}: GitActionsPanelProps) {
void _agentId; // Reserved for future use
const [showCommitDialog, setShowCommitDialog] = useState(false);
@@ -61,6 +87,7 @@ export function GitActionsPanel({
const [prTitle, setPrTitle] = useState("");
const [prBody, setPrBody] = useState("");
const [mergePrNumber, setMergePrNumber] = useState("");
const [rebaseTargetBranch, setRebaseTargetBranch] = useState("");
const hasStagedChanges = (status?.staged_files.length ?? 0) > 0;
const hasUnpushedCommits = (status?.ahead ?? 0) > 0;
@@ -302,6 +329,88 @@ export function GitActionsPanel({
</DialogContent>
</Dialog>
{/* Pull Action */}
<Button
className="w-full justify-start"
variant="outline"
disabled={isPulling}
onClick={onPull}
>
{isPulling ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Pull from Remote
</Button>
{/* Fetch Action */}
<Button
className="w-full justify-start"
variant="outline"
disabled={isFetching}
onClick={onFetch}
>
{isFetching ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4 mr-2" />
)}
Fetch Remote
</Button>
{/* Rebase Action — destructive, requires confirmation */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="w-full justify-start"
variant="outline"
disabled={isRebasing}
>
{isRebasing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<GitGraph className="h-4 w-4 mr-2" />
)}
Rebase onto Remote
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="border-destructive bg-destructive/5">
<AlertDialogHeader>
<AlertDialogTitle>Rebase onto target branch?</AlertDialogTitle>
<AlertDialogDescription>
This will rewrite the commit history of branch{" "}
<strong>{status?.current_branch}</strong> by replaying commits
on top of the specified target branch. A force-push will be
required afterward. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="px-6 py-2 space-y-2">
<label className="text-sm font-medium">Target branch</label>
<Input
placeholder="Remote ref (e.g. origin/HEAD)"
value={rebaseTargetBranch}
onChange={(e) => setRebaseTargetBranch(e.target.value)}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setRebaseTargetBranch("")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={!rebaseTargetBranch.trim()}
onClick={() => {
onRebase(rebaseTargetBranch.trim());
setRebaseTargetBranch("");
}}
>
Rebase
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Status Summary */}
{status && (
<div className="pt-2 border-t text-xs text-muted-foreground space-y-1">
@@ -164,14 +164,15 @@ export function GitBranchPanel({
</h4>
<div className="space-y-0.5">
{localBranches.map((branch) => (
<button
<Button
key={branch.name}
onClick={() => !branch.is_current && onCheckout(branch.name)}
disabled={branch.is_current || isCheckingOut}
variant="ghost"
className={
"w-full flex items-center justify-between px-2 py-1.5 rounded text-sm text-left transition-colors " +
"w-full h-auto justify-between px-2 py-1.5 text-sm font-normal whitespace-normal " +
(branch.is_current
? "bg-primary/10 text-primary"
? "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary"
: "hover:bg-muted")
}
>
@@ -188,7 +189,7 @@ export function GitBranchPanel({
{branch.last_commit.slice(0, 7)}
</span>
)}
</button>
</Button>
))}
</div>
</div>
@@ -202,16 +203,17 @@ export function GitBranchPanel({
</h4>
<div className="space-y-0.5">
{remoteBranches.map((branch) => (
<button
<Button
key={branch.name}
onClick={() => onCheckout(branch.name)}
disabled={isCheckingOut}
className="w-full flex items-center justify-between px-2 py-1.5 rounded text-sm text-left transition-colors hover:bg-muted"
variant="ghost"
className="w-full h-auto justify-start px-2 py-1.5 text-sm font-normal whitespace-normal hover:bg-muted"
>
<span className="truncate font-mono text-xs text-muted-foreground">
{branch.name}
</span>
</button>
</Button>
))}
</div>
</div>
+56 -5
View File
@@ -29,6 +29,7 @@ import { GitDiffViewer } from "./git-diff-viewer";
import { GitActionsPanel } from "./git-actions-panel";
import { GitBranch, RefreshCw, FolderGit2 } from "lucide-react";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/api/client";
function GitBrowserContent() {
const router = useRouter();
@@ -49,7 +50,7 @@ function GitBrowserContent() {
const { data: unstagedDiff, isLoading: loadingUnstagedDiff } = useGitDiff(projectSlug, false, undefined, !!projectSlug);
// Git operations
const { commit, push, createBranch, checkout, createPR, mergePR } = useGitOperations();
const { commit, push, createBranch, checkout, createPR, mergePR, pull, fetch, rebase } = useGitOperations();
// Update URL params
const updateParams = useCallback(
@@ -115,7 +116,7 @@ function GitBrowserContent() {
const result = await commit.mutateAsync({
project_slug: projectSlug,
message,
task_id: taskId || "manual",
task_id: taskId || undefined,
agent_id: "ceo",
});
toast.success(`Committed: ${result.commit_hash.slice(0, 7)}`);
@@ -128,7 +129,7 @@ function GitBrowserContent() {
try {
const result = await push.mutateAsync({
project_slug: projectSlug,
task_id: taskId || "manual",
task_id: taskId || undefined,
agent_id: "ceo",
force,
});
@@ -142,7 +143,7 @@ function GitBrowserContent() {
try {
const result = await createPR.mutateAsync({
project_slug: projectSlug,
task_id: taskId || "manual",
task_id: taskId || undefined,
title,
body,
agent_id: "ceo",
@@ -165,7 +166,7 @@ function GitBrowserContent() {
const result = await mergePR.mutateAsync({
project_slug: projectSlug,
pr_number: prNumber,
task_id: taskId || "manual",
task_id: taskId || undefined,
agent_id: "ceo",
});
toast.success(`Merged PR #${result.pr_number}${result.target_branch}`);
@@ -174,6 +175,50 @@ function GitBrowserContent() {
}
};
const handlePull = async () => {
try {
const result = await pull.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
});
toast.success(`Pulled: now on ${result.current_branch}`);
} catch {
toast.error("Failed to pull from remote");
}
};
const handleFetch = async () => {
try {
const result = await fetch.mutateAsync({
project_slug: projectSlug,
task_id: taskId || undefined,
});
toast.success(`Fetched: now on ${result.current_branch}`);
} catch {
toast.error("Failed to fetch from remote");
}
};
const handleRebase = async (targetBranch: string) => {
try {
const result = await rebase.mutateAsync({
project_slug: projectSlug,
target_branch: targetBranch,
task_id: taskId || undefined,
agent_id: "ceo",
});
if (result.conflict) {
toast.warning(
`Rebase conflicts in: ${result.conflicted_files.join(", ") || "unknown files"}`
);
} else {
toast.success("Rebase completed successfully");
}
} catch (error) {
toast.error(getErrorMessage(error));
}
};
// Check offline
const isOffline = projectsError && (
projectsError.message?.includes("Network Error") ||
@@ -258,10 +303,16 @@ function GitBrowserContent() {
onPush={handlePush}
onCreatePR={handleCreatePR}
onMergePR={handleMergePR}
onPull={handlePull}
onFetch={handleFetch}
onRebase={handleRebase}
isCommitting={commit.isPending}
isPushing={push.isPending}
isCreatingPR={createPR.isPending}
isMerging={mergePR.isPending}
isPulling={pull.isPending}
isFetching={fetch.isPending}
isRebasing={rebase.isPending}
/>
</div>
+9 -8
View File
@@ -3,8 +3,10 @@
import { GitLogResponse, CommitInfo } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { GitCommit, User, Calendar } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
@@ -68,15 +70,14 @@ export function GitLogPanel({
<ScrollArea className="h-80">
<div className="p-4 space-y-0">
{log.commits.map((commit, index) => (
<button
<Button
key={commit.hash}
onClick={() => onSelectCommit?.(commit)}
className={
"w-full text-left p-3 rounded-lg transition-colors relative " +
(selectedHash === commit.hash
? "bg-primary/10"
: "hover:bg-muted")
}
variant="ghost"
className={cn(
"w-full h-auto justify-start text-left p-3 font-normal whitespace-normal relative",
selectedHash === commit.hash ? "bg-primary/10 hover:bg-primary/10" : ""
)}
>
{/* Timeline line */}
{index < log.commits.length - 1 && (
@@ -123,7 +124,7 @@ export function GitLogPanel({
</div>
</div>
</div>
</button>
</Button>
))}
</div>
</ScrollArea>
+6 -4
View File
@@ -4,6 +4,7 @@ import { Agent } from "@/types";
import { User } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { Button } from "@/components/ui/button";
interface AgentItemProps {
agent: Agent;
@@ -14,12 +15,13 @@ interface AgentItemProps {
export function AgentItem({ agent, isSelected, onClick, hasEntries }: AgentItemProps) {
return (
<button
<Button
onClick={onClick}
variant="ghost"
className={cn(
"w-full flex items-center gap-3 p-2 rounded-lg text-left transition-colors",
"w-full h-auto justify-start gap-3 p-2 font-normal whitespace-normal",
isSelected
? "bg-primary/10 border border-primary/30"
? "bg-primary/10 border border-primary/30 hover:bg-primary/10"
: "hover:bg-muted/50"
)}
>
@@ -37,6 +39,6 @@ export function AgentItem({ agent, isSelected, onClick, hasEntries }: AgentItemP
{agent.role.replace(/_/g, " ")}
</p>
</div>
</button>
</Button>
);
}
+2 -3
View File
@@ -1,7 +1,6 @@
"use client";
import { Agent, Team, AgentRole } from "@/types";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { AgentItem } from "./agent-item";
@@ -84,7 +83,7 @@ export function AgentList({
const grouped = groupByTeam(agents);
return (
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="overflow-y-auto max-h-[calc(100vh-200px)]">
<div className="p-2 space-y-4">
{Object.entries(grouped).map(([teamKey, teamAgents]) => {
if (teamAgents.length === 0) return null;
@@ -108,6 +107,6 @@ export function AgentList({
);
})}
</div>
</ScrollArea>
</div>
);
}
@@ -11,7 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw } from "lucide-react";
import { ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import {
DndContext,
@@ -64,6 +64,7 @@ export function KanbanBoard({
const updateTask = useUpdateTask();
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [pendingNotesAction, setPendingNotesAction] = useState<PendingNotesAction | null>(null);
const [activeColumnIndex, setActiveColumnIndex] = useState(0);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -293,7 +294,56 @@ export function KanbanBoard({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 overflow-x-auto pb-4">
{/* Mobile: single-column view with prev/next navigator (hidden on sm+) */}
<div className="sm:hidden">
<div className="flex items-center gap-2 mb-4">
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0"
onClick={() => setActiveColumnIndex((i) => Math.max(0, i - 1))}
disabled={activeColumnIndex === 0}
aria-label="Previous column"
>
<ChevronLeft className="h-5 w-5" />
</Button>
<p className="flex-1 text-center text-sm font-semibold">
<span className="text-muted-foreground font-normal text-xs mr-1">
{activeColumnIndex + 1}/{columns.length}
</span>
{columns[activeColumnIndex]?.title}
</p>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0"
onClick={() =>
setActiveColumnIndex((i) => Math.min(columns.length - 1, i + 1))
}
disabled={activeColumnIndex === columns.length - 1}
aria-label="Next column"
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
{columns[activeColumnIndex] && (
<KanbanColumn
key={columns[activeColumnIndex].id}
id={columns[activeColumnIndex].id}
title={columns[activeColumnIndex].title}
status={columns[activeColumnIndex].status}
tasks={tasksByStatus[columns[activeColumnIndex].status] || []}
color={columns[activeColumnIndex].color}
isLoading={isLoading}
onAction={handleAction}
showQaActions={showQaActions}
className="w-full sm:w-full"
/>
)}
</div>
{/* Desktop: horizontal scrolling layout (shown at sm+, i.e. >= 640px) */}
<div className="hidden sm:flex gap-4 overflow-x-auto pb-4">
{columns.map((col) => (
<KanbanColumn
key={col.id}
@@ -183,7 +183,7 @@ export function KanbanCard({ task, onAction, showQaActions, isDragging: isDraggi
<Button
variant="ghost"
size="sm"
className="h-7 text-muted-foreground hover:text-foreground"
className="max-sm:min-h-11 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
disabled={isBacklog}
>
@@ -210,7 +210,7 @@ export function KanbanCard({ task, onAction, showQaActions, isDragging: isDraggi
<Button
variant="ghost"
size="sm"
className="h-7 text-green-600 hover:text-green-700 hover:bg-green-50"
className="max-sm:min-h-11 text-green-600 hover:text-green-700 hover:bg-green-50"
onClick={(e) => {
e.stopPropagation();
onAction("pass-qa", task.id);
@@ -222,7 +222,7 @@ export function KanbanCard({ task, onAction, showQaActions, isDragging: isDraggi
<Button
variant="ghost"
size="sm"
className="h-7 text-red-600 hover:text-red-700 hover:bg-red-50"
className="max-sm:min-h-11 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={(e) => {
e.stopPropagation();
onAction("fail-qa", task.id);
@@ -238,7 +238,7 @@ export function KanbanCard({ task, onAction, showQaActions, isDragging: isDraggi
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
className="h-11 w-11"
onClick={(e) => {
e.stopPropagation();
onAction("move-forward", task.id);
@@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { KanbanCard } from "./kanban-card";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
interface KanbanColumnProps {
id: string;
@@ -16,6 +17,8 @@ interface KanbanColumnProps {
isLoading: boolean;
onAction?: (action: string, taskId: string) => void;
showQaActions?: boolean;
/** Extra Tailwind classes forwarded to the root element (e.g. w-full for mobile). */
className?: string;
}
export function KanbanColumn({
@@ -27,6 +30,7 @@ export function KanbanColumn({
isLoading,
onAction,
showQaActions,
className,
}: KanbanColumnProps) {
void _id; // Reserved for future use
const { setNodeRef, isOver } = useDroppable({
@@ -36,9 +40,12 @@ export function KanbanColumn({
return (
<div
ref={setNodeRef}
className={`flex flex-col rounded-lg p-3 w-72 shrink-0 sm:w-80 ${color} ${
isOver ? "ring-2 ring-primary ring-offset-2" : ""
}`}
className={cn(
"flex flex-col rounded-lg p-3 w-72 shrink-0 sm:w-80",
color,
isOver && "ring-2 ring-primary ring-offset-2",
className,
)}
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-sm text-gray-800 dark:text-gray-100">{title}</h3>
@@ -3,6 +3,8 @@
import { KBIndexType, KBStats } from "@/types";
import { FileText, MessageSquare, BookOpen, ChevronRight, AlertTriangle, Scale, GitBranch, ClipboardCheck, Lightbulb } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const categoryConfig: Record<KBIndexType, { label: string; description: string; icon: React.ReactNode }> = {
[KBIndexType.DOCUMENTATION]: {
@@ -90,17 +92,19 @@ export function KBCategoryNav({
const isSelected = selectedCategory === type;
return (
<button
<Button
key={type}
onClick={() => onSelectCategory(type)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left ${
variant="outline"
className={cn(
"w-full h-auto justify-start gap-3 p-3 font-normal whitespace-normal",
isSelected
? "bg-primary/10 border-primary"
: "hover:bg-muted/50 border-transparent hover:border-border"
}`}
)}
>
<div className="shrink-0">{config.icon}</div>
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{config.label}</span>
<span className="text-xs text-muted-foreground">
@@ -109,8 +113,8 @@ export function KBCategoryNav({
</div>
<p className="text-xs text-muted-foreground truncate">{config.description}</p>
</div>
<ChevronRight className={`h-4 w-4 text-muted-foreground shrink-0 transition-transform ${isSelected ? "rotate-90" : ""}`} />
</button>
<ChevronRight className={cn("h-4 w-4 text-muted-foreground shrink-0 transition-transform", isSelected && "rotate-90")} />
</Button>
);
})}
</div>
@@ -2,6 +2,7 @@
import { KBIndexType } from "@/types";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { FileText, MessageSquare, BookOpen, AlertTriangle, Scale, GitBranch, ClipboardCheck, Lightbulb } from "lucide-react";
const indexTypeConfig: Record<KBIndexType, { label: string; icon: React.ReactNode }> = {
@@ -36,12 +37,14 @@ export function KBFilters({ selectedTypes, onTypesChange }: KBFiltersProps) {
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter by type</span>
{selectedTypes.length > 0 && (
<button
<Button
variant="ghost"
size="sm"
onClick={() => onTypesChange([])}
className="text-xs text-muted-foreground hover:text-foreground"
className="h-auto py-0 px-1 text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
</Button>
)}
</div>
<div className="space-y-2">
@@ -64,12 +64,15 @@ export function KBSearchBar({
className="pl-9 pr-9"
/>
{localValue && (
<button
<Button
variant="ghost"
size="icon-sm"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</Button>
)}
</div>
{onSearch && (
@@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { ModelUsageSlice } from "@/types";
// Design-system chart tokens — resolves to theme-aware palette
const CHART_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
@@ -66,7 +66,7 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="Tokens" fill="var(--chart-2)" radius={[3, 3, 0, 0]} />
<Bar dataKey="Tokens" fill="var(--chart-1)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
@@ -60,12 +60,13 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
<TableRow key={product.id}>
<TableCell>
<div>
<button
<Button
onClick={() => setEditingProductId(product.id)}
className="font-medium hover:underline text-left"
variant="link"
className="h-auto p-0 font-medium text-foreground"
>
{product.name}
</button>
</Button>
<p className="text-xs text-muted-foreground font-mono">{product.slug}</p>
</div>
</TableCell>
@@ -129,12 +129,13 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
<TableRow key={project.id}>
<TableCell>
<div>
<button
<Button
onClick={() => setEditingProjectId(project.id)}
className="font-medium hover:underline text-left"
variant="link"
className="h-auto p-0 font-medium text-foreground"
>
{project.name}
</button>
</Button>
<p className="text-xs text-muted-foreground font-mono">
{project.slug}
</p>
@@ -42,6 +42,8 @@ import { toast } from "sonner";
import { AssignmentScope, ModelProvider } from "@/types";
import type { RoutingMode, SelfHostedTestResult } from "@/lib/api/providers";
import { SelfHostedSection } from "@/components/settings/self-hosted-section";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP.
// Hard-coded so Mix mode shows a stable 18-row picker without an extra
@@ -254,13 +256,13 @@ export function AIRoutingCard() {
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Ollama Cloud API key</Label>
{hasOllamaKey ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600">
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</span>
</Badge>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-600">
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</span>
</Badge>
)}
</div>
<div className="flex gap-2">
@@ -278,13 +280,13 @@ export function AIRoutingCard() {
</Button>
</div>
{hasOllamaKey ? (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={clearKey}
onChange={(e) => {
setClearKey(e.target.checked);
if (e.target.checked) setApiKey("");
onCheckedChange={(checked) => {
const next = checked === true;
setClearKey(next);
if (next) setApiKey("");
}}
/>
Clear the stored key
@@ -543,29 +545,27 @@ function ModeButton({
highlight?: boolean;
}) {
return (
<button
<Button
type="button"
variant="outline"
onClick={onClick}
disabled={disabled}
className={
"rounded-md border p-3 text-left transition-colors " +
(active || highlight
? "border-primary bg-primary/5"
: "hover:bg-muted") +
(disabled ? " cursor-not-allowed opacity-50" : "")
"h-auto flex-col items-start justify-start p-3 whitespace-normal " +
(active || highlight ? "border-primary bg-primary/5" : "")
}
>
<div className="flex items-center gap-2 text-sm font-medium">
<div className="flex w-full items-center gap-2 text-sm font-medium">
{icon}
<span>{label}</span>
{active ? (
<span className="ml-auto rounded-full bg-primary/15 px-2 py-0.5 text-xs text-primary">
<Badge className="ml-auto bg-primary/15 text-primary border-0 text-xs">
active
</span>
</Badge>
) : null}
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</button>
<p className="mt-1 text-xs text-muted-foreground font-normal">{description}</p>
</Button>
);
}
@@ -146,14 +146,14 @@ export function SelfHostedSection({
<Server className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">Self-Hosted LLM</Label>
{testResult?.ok === true && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600">
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<CheckCircle2 className="h-3 w-3" /> connected
</span>
</Badge>
)}
{testResult?.ok === false && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-600">
<Badge className="bg-red-500/10 text-red-600 border-0">
<XCircle className="h-3 w-3" /> error
</span>
</Badge>
)}
</div>
@@ -194,10 +194,12 @@ export function SelfHostedSection({
}
className="pr-10"
/>
<button
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowToken((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showToken ? "Hide token" : "Show token"}
>
{showToken ? (
@@ -205,7 +207,7 @@ export function SelfHostedSection({
) : (
<Eye className="h-4 w-4" />
)}
</button>
</Button>
</div>
<Button onClick={handleSave} disabled={saveConfig.isPending}>
{saveConfig.isPending ? "Saving…" : "Save"}
@@ -243,17 +245,17 @@ export function SelfHostedSection({
{/* Inline result badge */}
{testResult?.ok === true && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-400">
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-0 px-3 py-1 text-xs">
<CheckCircle2 className="h-3.5 w-3.5" />
Connected &mdash; {testResult.model_count ?? 0} model
{(testResult.model_count ?? 0) === 1 ? "" : "s"} available
</span>
</Badge>
)}
{testResult?.ok === false && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-3 py-1 text-xs font-medium text-red-700 dark:text-red-400">
<Badge className="bg-red-500/10 text-red-700 dark:text-red-400 border-0 px-3 py-1 text-xs">
<XCircle className="h-3.5 w-3.5" />
{testResult.error ?? "Connection failed"}
</span>
</Badge>
)}
</div>
@@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Search, X, Link2, Check } from "lucide-react";
import { Search, X, Link2 } from "lucide-react";
interface DependencySelectorProps {
selectedIds: string[];
@@ -114,22 +115,22 @@ export function DependencySelector({
{filteredTasks.map((task) => {
const isSelected = selectedIds.includes(task.id);
return (
<button
<Button
key={task.id}
type="button"
className={`w-full flex items-center gap-2 p-2 rounded-md text-left hover:bg-muted transition-colors ${
isSelected ? "bg-primary/10" : ""
variant="ghost"
className={`w-full h-auto justify-start gap-2 p-2 font-normal whitespace-normal ${
isSelected ? "bg-primary/10 hover:bg-primary/10" : ""
}`}
onClick={() => toggleTask(task.id)}
>
<div
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-primary border-primary" : "border-muted-foreground"
}`}
>
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<div className="flex-1 min-w-0">
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none shrink-0"
aria-hidden
/>
<div className="flex-1 min-w-0 text-left">
<p className="text-sm truncate">{task.title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono text-xs">
@@ -138,7 +139,7 @@ export function DependencySelector({
<span className="capitalize">{task.status.replace(/_/g, " ")}</span>
</div>
</div>
</button>
</Button>
);
})}
</div>
+9 -6
View File
@@ -180,15 +180,16 @@ function SortableHeader({ label, field, sortConfig, onSort, className }: Sortabl
return (
<TableHead className={className}>
<button
<Button
onClick={() => onSort(field)}
className="flex items-center gap-1 hover:text-foreground transition-colors -ml-2 px-2 py-1 rounded hover:bg-muted"
variant="ghost"
className="h-auto -ml-2 px-2 py-1 gap-1 font-normal"
>
{label}
{!isActive && <ArrowUpDown className="h-4 w-4 text-muted-foreground" />}
{direction === "asc" && <ArrowUp className="h-4 w-4" />}
{direction === "desc" && <ArrowDown className="h-4 w-4" />}
</button>
</Button>
</TableHead>
);
}
@@ -479,16 +480,18 @@ export function TaskTable({
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
>
{hasChildren ? (
<button
<Button
onClick={() => toggleExpand(task.id)}
className="p-0.5 hover:bg-muted rounded shrink-0"
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</button>
</Button>
) : (
<span className="w-5 shrink-0" />
)}
+66
View File
@@ -23,6 +23,12 @@ import type {
GitCreatePRResponse,
GitMergePRRequest,
GitMergePRResponse,
GitPullRequest,
GitPullResponse,
GitFetchRequest,
GitFetchResponse,
GitRebaseRequest,
GitRebaseResponse,
} from "@/types/git";
// =============================================================================
@@ -226,6 +232,60 @@ export function useMergePR() {
});
}
/**
* Pull latest changes from remote
*/
export function useGitPull() {
const queryClient = useQueryClient();
return useMutation<GitPullResponse, Error, GitPullRequest>({
mutationFn: (request) => gitApi.pull(request),
onSuccess: (_, variables) => {
// Invalidate status and log after pull
queryClient.invalidateQueries({ queryKey: gitKeys.status(variables.project_slug) });
queryClient.invalidateQueries({
queryKey: [...gitKeys.all, "log", variables.project_slug],
});
},
});
}
/**
* Fetch refs from remote without merging
*/
export function useGitFetch() {
const queryClient = useQueryClient();
return useMutation<GitFetchResponse, Error, GitFetchRequest>({
mutationFn: (request) => gitApi.fetch(request),
onSuccess: (_, variables) => {
// Invalidate status after fetch
queryClient.invalidateQueries({ queryKey: gitKeys.status(variables.project_slug) });
},
});
}
/**
* Rebase current branch onto remote (destructive rewrites history)
*/
export function useGitRebase() {
const queryClient = useQueryClient();
return useMutation<GitRebaseResponse, Error, GitRebaseRequest>({
mutationFn: (request) => gitApi.rebase(request),
onSuccess: (_, variables) => {
// Invalidate everything after rebase since history has changed
queryClient.invalidateQueries({ queryKey: gitKeys.status(variables.project_slug) });
queryClient.invalidateQueries({
queryKey: [...gitKeys.all, "log", variables.project_slug],
});
queryClient.invalidateQueries({
queryKey: [...gitKeys.all, "diff", variables.project_slug],
});
},
});
}
// =============================================================================
// Bundled Hook for Git Operations
// =============================================================================
@@ -240,6 +300,9 @@ export function useGitOperations() {
const checkout = useCheckout();
const createPR = useCreatePR();
const mergePR = useMergePR();
const pull = useGitPull();
const fetch = useGitFetch();
const rebase = useGitRebase();
return {
commit,
@@ -248,5 +311,8 @@ export function useGitOperations() {
checkout,
createPR,
mergePR,
pull,
fetch,
rebase,
};
}
+14 -1
View File
@@ -23,12 +23,14 @@ export const getBoardAgents = (agents: AgentDefinition[] | undefined | null) =>
(a) =>
// The CEO is the human operator, not a spawnable agent — exclude it even
// though its record carries team=board.
// MAIN_PM has its own dedicated section below Board, so exclude it here.
a.role !== AgentRole.CEO &&
a.role !== AgentRole.MAIN_PM &&
(a.team === Team.BOARD ||
a.role === AgentRole.HEAD_MARKETING ||
a.role === AgentRole.AUDITOR ||
a.role === AgentRole.PRODUCT_OWNER ||
a.role === AgentRole.MAIN_PM)
a.role === AgentRole.PR_REVIEWER)
);
export const getMainPm = (agents: AgentDefinition[] | undefined | null) =>
@@ -45,3 +47,14 @@ export const getUxAgents = (agents: AgentDefinition[] | undefined | null) =>
export const getMarketingAgents = (agents: AgentDefinition[] | undefined | null) =>
(agents ?? []).filter((a) => a.team === Team.MARKETING);
// On-demand agents (Prompter/Intake, Secretary) are not part of any standing
// cell team — they are spawned on request. Captured by inclusion of their
// explicit roles so new on-demand agents appear automatically when roles
// are added to the enum.
export const getOnDemandAgents = (agents: AgentDefinition[] | undefined | null) =>
(agents ?? []).filter(
(a) =>
a.role === AgentRole.PROMPTER ||
a.role === AgentRole.SECRETARY
);
+59 -1
View File
@@ -23,6 +23,12 @@ import type {
GitCreatePRResponse,
GitMergePRRequest,
GitMergePRResponse,
GitPullRequest,
GitPullResponse,
GitFetchRequest,
GitFetchResponse,
GitRebaseRequest,
GitRebaseResponse,
} from "@/types/git";
// =============================================================================
@@ -152,7 +158,7 @@ export const gitApi = {
if (isMockMode()) {
return {
commit_hash: "abc123456789abcdef",
message: `[${request.task_id.slice(0, 8)}] ${request.message}`,
message: `[${request.task_id?.slice(0, 8) ?? "manual"}] ${request.message}`,
files_changed: request.files?.length || 1,
insertions: 10,
deletions: 5,
@@ -239,4 +245,56 @@ export const gitApi = {
const { data } = await api.post<GitMergePRResponse>("/git/pr/merge", request);
return data;
},
/**
* Pull latest changes from remote
*/
pull: async (request: GitPullRequest): Promise<GitPullResponse> => {
if (isMockMode()) {
return {
current_branch: "feature/backend/abc12345",
has_changes: false,
staged_files: [],
unstaged_files: [],
untracked_files: [],
ahead: 0,
behind: 0,
};
}
const { data } = await api.post<GitPullResponse>("/git/pull", request);
return data;
},
/**
* Fetch refs from remote without merging
*/
fetch: async (request: GitFetchRequest): Promise<GitFetchResponse> => {
if (isMockMode()) {
return {
current_branch: "feature/backend/abc12345",
has_changes: false,
staged_files: [],
unstaged_files: [],
untracked_files: [],
ahead: 0,
behind: 0,
};
}
const { data } = await api.post<GitFetchResponse>("/git/fetch", request);
return data;
},
/**
* Rebase current branch onto target branch (destructive rewrites history)
*/
rebase: async (request: GitRebaseRequest): Promise<GitRebaseResponse> => {
if (isMockMode()) {
return {
conflict: false,
conflicted_files: [],
};
}
const { data } = await api.post<GitRebaseResponse>("/git/rebase", request);
return data;
},
};
+46 -4
View File
@@ -102,14 +102,14 @@ export interface GitMergePRResponse {
export interface GitCommitRequest {
project_slug: string;
message: string;
task_id: string;
task_id?: string;
agent_id: string;
files?: string[] | null;
}
export interface GitPushRequest {
project_slug: string;
task_id: string;
task_id?: string;
agent_id: string;
force?: boolean;
}
@@ -132,7 +132,7 @@ export interface GitCheckoutRequest {
export interface GitCreatePRRequest {
project_slug: string;
task_id: string;
task_id?: string;
title: string;
body: string;
agent_id: string;
@@ -143,7 +143,49 @@ export type MergeMethod = "merge" | "squash" | "rebase";
export interface GitMergePRRequest {
project_slug: string;
pr_number: number;
task_id: string;
task_id?: string;
merge_method?: MergeMethod;
agent_id: string;
}
export interface GitPullRequest {
project_slug: string;
task_id?: string;
}
export interface GitPullResponse {
current_branch: string;
has_changes: boolean;
staged_files: string[];
unstaged_files: string[];
untracked_files: string[];
ahead: number;
behind: number;
}
export interface GitFetchRequest {
project_slug: string;
task_id?: string;
}
export interface GitFetchResponse {
current_branch: string;
has_changes: boolean;
staged_files: string[];
unstaged_files: string[];
untracked_files: string[];
ahead: number;
behind: number;
}
export interface GitRebaseRequest {
project_slug: string;
target_branch: string;
task_id?: string;
agent_id?: string;
}
export interface GitRebaseResponse {
conflict: boolean;
conflicted_files: string[];
}
+3
View File
@@ -34,11 +34,14 @@ export enum AgentRole {
PRODUCT_OWNER = "product_owner",
HEAD_MARKETING = "head_marketing",
AUDITOR = "auditor",
PR_REVIEWER = "pr_reviewer",
MAIN_PM = "main_pm",
CELL_PM = "cell_pm",
DEVELOPER = "developer",
QA = "qa",
DOCUMENTER = "documenter",
PROMPTER = "prompter",
SECRETARY = "secretary",
}
export enum AgentState {
+153
View File
@@ -41,15 +41,22 @@ from roboco.api.schemas.git import (
GitCreatePRRequest,
GitCreatePRResponse,
GitDiffResponse,
GitFetchRequest,
GitFetchResponse,
GitLogResponse,
GitMergePRRequest,
GitMergePRResponse,
GitPullRequest,
GitPullResponse,
GitPushRequest,
GitPushResponse,
GitRebaseRequest,
GitRebaseResponse,
GitStatusResponse,
)
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError
from roboco.logging import get_logger
from roboco.models.base import AgentRole
from roboco.services.base import (
NotFoundError,
ServiceError,
@@ -58,6 +65,7 @@ from roboco.services.base import (
)
from roboco.services.git import get_git_service
from roboco.services.project import get_project_service
from roboco.services.task import get_task_service
logger = get_logger(__name__)
@@ -73,6 +81,16 @@ _LOG_FORMAT_PARTS = 5
# bubbling as 500 Internal Server Errors with no `detail`.
_TranslatableError = (ServiceError, GitError)
# Roles permitted to rebase branches via the /rebase endpoint.
# Rebase is a history-rewriting operation that should be authorised only by
# PM-level or CEO-level callers. Developers are intentionally excluded:
# they commit to their feature branch and let PMs/CEO manage integration
# rebases. This gate prevents developers from accidentally force-rewriting
# shared branch history.
_REBASE_ALLOWED_ROLES: frozenset[AgentRole] = frozenset(
{AgentRole.CEO, AgentRole.CELL_PM, AgentRole.MAIN_PM}
)
def _translate_error(e: ServiceError | GitError) -> HTTPException:
"""Translate service errors to HTTP exceptions."""
@@ -472,3 +490,138 @@ async def merge_pull_request(
merge_commit=merge_commit,
target_branch=target_branch,
)
@router.post("/pull", response_model=GitPullResponse)
async def pull_commits(
data: GitPullRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> GitPullResponse:
"""Pull latest changes from origin into the agent workspace."""
project_slug = await _resolve_project_slug(data.project_slug, db)
git_service = get_git_service(db)
try:
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
(
current_branch,
has_changes,
staged,
unstaged,
untracked,
ahead,
behind,
) = await git_service.pull(workspace)
except _TranslatableError as e:
raise _translate_error(e) from e
return GitPullResponse(
project_slug=project_slug,
current_branch=current_branch,
has_changes=has_changes,
staged_files=staged,
unstaged_files=unstaged,
untracked_files=untracked,
ahead=ahead,
behind=behind,
)
@router.post("/fetch", response_model=GitFetchResponse)
async def fetch_commits(
data: GitFetchRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> GitFetchResponse:
"""Fetch changes from origin without merging."""
project_slug = await _resolve_project_slug(data.project_slug, db)
git_service = get_git_service(db)
try:
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
(
current_branch,
has_changes,
staged,
unstaged,
untracked,
ahead,
behind,
) = await git_service.fetch(workspace)
except _TranslatableError as e:
raise _translate_error(e) from e
return GitFetchResponse(
project_slug=project_slug,
current_branch=current_branch,
has_changes=has_changes,
staged_files=staged,
unstaged_files=unstaged,
untracked_files=untracked,
ahead=ahead,
behind=behind,
)
@router.post("/rebase", response_model=GitRebaseResponse)
async def rebase_branch(
data: GitRebaseRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> GitRebaseResponse:
"""Rebase the current branch onto target_branch.
Role-gated: only CEO and PM roles (cell_pm, main_pm) may rebase branches.
Developers, QA, documenters, and other roles are rejected with 403.
If task_id is provided and the caller is not CEO, the task's assigned_to
is checked: if the task is not assigned to the calling agent, 403 is
returned (or 404 if the task does not exist).
On conflict: aborts the rebase and returns conflict=True with the
list of conflicted files. On success: returns conflict=False.
"""
if agent.role not in _REBASE_ALLOWED_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"REBASE_ROLE_RESTRICTED: Role '{agent.role}' is not permitted "
"to rebase. Only CEO and PM roles (cell_pm, main_pm) may use "
"this endpoint."
),
)
# Task ownership check: if a task_id is supplied and the caller is not CEO,
# ensure the task is assigned to the calling agent.
if data.task_id is not None and agent.role != AgentRole.CEO:
task_service = get_task_service(db)
task = await task_service.get(data.task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task not found: {data.task_id}",
)
if task.assigned_to != agent.agent_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
"REBASE_OWNERSHIP_RESTRICTED: This task is not assigned to "
"you. Only the task's assigned agent or CEO may rebase it."
),
)
project_slug = await _resolve_project_slug(data.project_slug, db)
git_service = get_git_service(db)
try:
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
conflict, conflicted_files = await git_service.rebase(
workspace, data.target_branch
)
except _TranslatableError as e:
raise _translate_error(e) from e
return GitRebaseResponse(
project_slug=project_slug,
conflict=conflict,
conflicted_files=conflicted_files,
)
-2
View File
@@ -228,7 +228,6 @@ async def _merge_pr_if_awaiting_pm_review(
pr_number=pre_task.pr_number,
task_id=task_id,
merge_method="squash",
agent_id=str(agent.agent_id),
),
)
except (ServiceError, GitError) as e:
@@ -1809,7 +1808,6 @@ async def approve_and_merge_task(
pr_number=task.pr_number,
task_id=task_id,
merge_method="squash",
agent_id=str(agent.agent_id),
),
)
except (ServiceError, GitError) as e:
+144 -11
View File
@@ -7,7 +7,7 @@ Request/response models for git operation endpoints.
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# =============================================================================
# STATUS
@@ -78,7 +78,6 @@ class GitCreateBranchRequest(BaseModel):
project_slug: str
task_id: UUID
branch_type: str = Field(..., pattern=r"^(feature|bug|chore|docs|hotfix)$")
agent_id: str
parent_branch: str | None = None
@@ -95,7 +94,6 @@ class GitCheckoutRequest(BaseModel):
project_slug: str
branch: str
agent_id: str
class GitCheckoutResponse(BaseModel):
@@ -129,8 +127,7 @@ class GitCommitRequest(BaseModel):
"""Request to create a commit."""
project_slug: str
task_id: UUID
agent_id: str
task_id: UUID | None = None
# Commit message fields
message: str = Field(
...,
@@ -168,8 +165,7 @@ class GitPushRequest(BaseModel):
"""Request to push commits."""
project_slug: str
task_id: UUID
agent_id: str
task_id: UUID | None = None
force: bool = False
@@ -191,8 +187,7 @@ class GitCreatePRRequest(BaseModel):
"""Request to create a pull request."""
project_slug: str
task_id: UUID
agent_id: str
task_id: UUID | None = None
# PR content (auto-generated from templates if not provided)
title: str | None = Field(None, description="PR title (auto-generated if not set)")
body: str | None = Field(None, description="PR body (auto-generated if not set)")
@@ -218,9 +213,8 @@ class GitMergePRRequest(BaseModel):
project_slug: str
pr_number: int
task_id: UUID
task_id: UUID | None = None
merge_method: str = Field(default="squash", pattern=r"^(merge|squash|rebase)$")
agent_id: str
class GitMergePRResponse(BaseModel):
@@ -230,3 +224,142 @@ class GitMergePRResponse(BaseModel):
merged: bool
merge_commit: str | None = None
target_branch: str
# =============================================================================
# PULL
# =============================================================================
class GitPullRequest(BaseModel):
"""Request to pull latest changes from origin."""
project_slug: str
task_id: UUID | None = None
class GitPullResponse(BaseModel):
"""Response from git pull — branch status after the pull."""
project_slug: str
current_branch: str
has_changes: bool
staged_files: list[str] = []
unstaged_files: list[str] = []
untracked_files: list[str] = []
ahead: int = 0
behind: int = 0
# =============================================================================
# FETCH
# =============================================================================
class GitFetchRequest(BaseModel):
"""Request to fetch changes from origin without merging."""
project_slug: str
task_id: UUID | None = None
class GitFetchResponse(BaseModel):
"""Response from git fetch — branch status after the fetch."""
project_slug: str
current_branch: str
has_changes: bool
staged_files: list[str] = []
unstaged_files: list[str] = []
untracked_files: list[str] = []
ahead: int = 0
behind: int = 0
# =============================================================================
# REBASE
# =============================================================================
class GitRebaseRequest(BaseModel):
"""Request to rebase the current branch onto a target branch."""
project_slug: str
task_id: UUID | None = None
target_branch: str
@field_validator("target_branch")
@classmethod
def _validate_target_branch(cls, v: str) -> str:
if v.startswith("-"):
raise ValueError(
"INVALID_TARGET_BRANCH: target_branch must not start with '-'"
)
if v in ("master", "main"):
raise ValueError(
f"PROTECTED_BRANCH: Cannot rebase onto '{v}'; "
"target_branch must not be 'master' or 'main'"
)
return v
class GitRebaseResponse(BaseModel):
"""Response from git rebase.
On success: conflict=False, conflicted_files=[].
On conflict: conflict=True, conflicted_files lists the unmerged paths;
the rebase has been aborted so the workspace is clean.
"""
project_slug: str
conflict: bool = False
conflicted_files: list[str] = []
# =============================================================================
# GATEWAY-LAYER LIGHTWEIGHT SCHEMAS
#
# These simpler schemas are used by the MCP gateway layer and services that
# don't need the full Git* request payload. All fields beyond project_slug
# are optional to allow callers that don't yet carry task context.
# =============================================================================
class PullRequest(BaseModel):
"""Lightweight pull request used by the gateway / MCP layer."""
project_slug: str
task_id: UUID | None = None
class FetchRequest(BaseModel):
"""Lightweight fetch request used by the gateway / MCP layer."""
project_slug: str
task_id: UUID | None = None
class RebaseRequest(BaseModel):
"""Lightweight rebase request used by the gateway / MCP layer.
Validates ``target_branch`` to prevent accidental rebases onto
protected branches or shell-injection via leading ``-``.
"""
project_slug: str
task_id: UUID | None = None
target_branch: str
@field_validator("target_branch")
@classmethod
def _validate_target_branch(cls, v: str) -> str:
if v.startswith("-"):
raise ValueError(
"INVALID_TARGET_BRANCH: target_branch must not start with '-'"
)
if v in ("master", "main"):
raise ValueError(
f"PROTECTED_BRANCH: Cannot rebase onto '{v}'; "
"target_branch must not be 'master' or 'main'"
)
return v
+268 -76
View File
@@ -530,10 +530,13 @@ class GitService(BaseService):
) -> tuple[str, str, int, int, int]:
"""Create a git commit with template-based message.
When ``request.task_id`` is ``None`` the traceability template is
skipped and a plain conventional-commit message is built instead
(``type(scope): description``). The git commit still happens; the
commit is just not linked to any task record.
Returns: (commit_hash, full_message, files_changed, insertions, deletions)
"""
task_id = request.task_id
# Stage files. Large changesets get the longer commit-timeout budget
# (see `git_commit_timeout_seconds`) — the same reason the gateway
# `commit()` adapter uses it.
@@ -544,30 +547,42 @@ class GitService(BaseService):
else:
await self._run_git(workspace, ["add", "-A"], timeout=commit_timeout)
# Get task info for commit template
task_service = get_task_service(self.session)
task = await task_service.get(task_id)
if request.task_id is not None:
# Get task info for commit template
task_service = get_task_service(self.session)
task = await task_service.get(request.task_id)
# Get root task ID (walk up hierarchy)
root_task_id = await get_root_task_id(task_id, task_service)
# Get root task ID (walk up hierarchy)
root_task_id = await get_root_task_id(request.task_id, task_service)
# Get session ID
session_id = self._get_primary_session_id(task)
# Get session ID
session_id = self._get_primary_session_id(task)
# Build commit message using template
commit_ctx = CommitContext(
task_id=str(task_id),
root_task_id=str(root_task_id),
agent_slug=str(agent_id),
session_id=session_id,
commit_type=request.commit_type,
scope=request.scope,
description=request.message,
body=request.body,
)
full_message = build_commit_message(
commit_ctx, settings.public_base_url.rstrip("/") + "/api"
)
# Build commit message using template
commit_ctx = CommitContext(
task_id=str(request.task_id),
root_task_id=str(root_task_id),
agent_slug=str(agent_id),
session_id=session_id,
commit_type=request.commit_type,
scope=request.scope,
description=request.message,
body=request.body,
)
full_message = build_commit_message(
commit_ctx, settings.public_base_url.rstrip("/") + "/api"
)
else:
# No task context — use plain conventional-commit format so the
# git op still proceeds without raising CommitMessageError.
type_scope = (
f"{request.commit_type}({request.scope})"
if request.scope
else request.commit_type
)
full_message = f"{type_scope}: {request.message}"
if request.body:
full_message = f"{full_message}\n\n{request.body}"
# Create commit with agent attribution
author = f"{agent_id} <{agent_id}@roboco.ai>"
@@ -680,12 +695,18 @@ class GitService(BaseService):
commit itself, and commit-to-task linking. Raises typed service
errors; the API layer translates them to HTTP status codes.
When ``data.task_id`` is ``None`` the ownership, assignment, and
branch-mismatch checks are skipped the git commit proceeds
unconditionally, and no commit-to-task linking is recorded.
Returns: (commit_hash, full_message, files_changed, insertions, deletions)
"""
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
await self._assert_on_task_branch(workspace, task.branch_name)
if data.task_id is not None:
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
await self._assert_on_task_branch(workspace, task.branch_name)
else:
workspace = await self.get_workspace(data.project_slug, agent_id)
(
commit_hash,
@@ -695,9 +716,10 @@ class GitService(BaseService):
deletions,
) = await self.create_commit(workspace, agent_id, data)
await self._link_commit_to_task(
data.task_id, commit_hash, data.message, agent_id
)
if data.task_id is not None:
await self._link_commit_to_task(
data.task_id, commit_hash, data.message, agent_id
)
return commit_hash, full_message, files_changed, insertions, deletions
@@ -1146,6 +1168,10 @@ class GitService(BaseService):
preconditions. Raises typed service errors; the API layer
translates them to HTTP status codes.
When ``data.task_id`` is ``None`` the ownership and branch-mismatch
checks are skipped the push proceeds unconditionally (force-push
role check still applies).
Returns: (branch, commits_pushed)
"""
if getattr(data, "force", False) and agent_role != AgentRole.CEO:
@@ -1159,10 +1185,12 @@ class GitService(BaseService):
),
)
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
await self._assert_on_task_branch(workspace, task.branch_name)
if data.task_id is not None:
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
await self._assert_on_task_branch(workspace, task.branch_name)
else:
workspace = await self.get_workspace(data.project_slug, agent_id)
return await self.push(workspace, getattr(data, "force", False))
@@ -1186,6 +1214,135 @@ class GitService(BaseService):
_branch, pushed = await self.push(workspace)
return pushed
# =========================================================================
# PULL / FETCH / REBASE METHODS
# =========================================================================
async def pull(
self, workspace: Path
) -> tuple[str, bool, list[str], list[str], list[str], int, int]:
"""Pull latest changes from origin and return post-pull status.
Safety gates (both raise :class:`ValidationError`):
1. **Dirty workspace** refuses to pull when there are any
uncommitted changes (staged, unstaged, or untracked). A pull
onto a dirty tree can produce unexpected merge conflicts or
silently clobber un-staged edits.
2. **Diverged branch** uses ``--ff-only`` so a diverged branch
is rejected rather than creating a merge commit. The agent
must rebase or reset before pulling.
Uses _network_git_timeout() because the operation talks to origin.
Returns: (current_branch, has_changes, staged, unstaged, untracked,
ahead, behind)
"""
# Gate 1: dirty workspace check
status_result = await self._run_git(
workspace, ["status", "--porcelain"], check=False
)
if status_result.stdout.strip():
raise ValidationError(
"DIRTY_WORKSPACE: Cannot pull with uncommitted changes. "
"Stage and commit (or stash) your changes before pulling."
)
token = await self._token_for_workspace(workspace)
# Gate 2: fast-forward only — raises if branches have diverged
pull_result = await self._run_git(
workspace,
["pull", "--ff-only"],
token=token,
timeout=_network_git_timeout(),
check=False,
)
if pull_result.returncode != 0:
stderr = (pull_result.stderr or "").lower()
if any(
kw in stderr
for kw in (
"not possible to fast-forward",
"diverged",
"fatal: not possible to fast",
"fast-forward",
)
):
raise ValidationError(
"DIVERGED_BRANCH: Branch has diverged from remote; "
"cannot fast-forward. Rebase your local commits onto "
"the remote tip with `git rebase origin/<branch>` "
"before pulling."
)
raise ValidationError(
f"PULL_FAILED: git pull --ff-only exited non-zero. "
f"stderr: {pull_result.stderr or '(none)'}"
)
return await self.get_status(workspace)
async def fetch(
self, workspace: Path
) -> tuple[str, bool, list[str], list[str], list[str], int, int]:
"""Fetch changes from origin without merging and return post-fetch status.
Uses _network_git_timeout() because the operation talks to origin.
Returns: (current_branch, has_changes, staged, unstaged, untracked,
ahead, behind)
"""
token = await self._token_for_workspace(workspace)
await self._run_git(
workspace,
["fetch", "origin"],
token=token,
timeout=_network_git_timeout(),
)
return await self.get_status(workspace)
async def rebase(
self, workspace: Path, target_branch: str
) -> tuple[bool, list[str]]:
"""Rebase the current branch onto target_branch.
Safety gate: raises :class:`ValidationError` if the HEAD branch or
``target_branch`` is ``master`` or ``main`` rebasing a protected
integration branch is never safe in automation.
On conflict (non-zero exit): captures unmerged files via
``git diff --name-only --diff-filter=U``, aborts the rebase to
restore a clean workspace, and returns ``(True, conflicted_files)``.
On success: returns ``(False, [])``.
"""
_PROTECTED = frozenset({"master", "main"})
if target_branch in _PROTECTED:
raise ValidationError(
f"REBASE_FORBIDDEN: Cannot rebase onto '{target_branch}'. "
"Rebasing onto 'master' or 'main' is not allowed in automation."
)
head_branch = await self.get_current_branch(workspace)
if head_branch in _PROTECTED:
raise ValidationError(
f"REBASE_FORBIDDEN: Cannot rebase '{head_branch}'. "
"Rebasing 'master' or 'main' is not allowed in automation."
)
result = await self._run_git(workspace, ["rebase", target_branch], check=False)
if result.returncode != 0:
conflict_result = await self._run_git(
workspace,
["diff", "--name-only", "--diff-filter=U"],
check=False,
)
conflicted_files = [
f.strip()
for f in conflict_result.stdout.strip().split("\n")
if f.strip()
]
await self._run_git(workspace, ["rebase", "--abort"], check=False)
return True, conflicted_files
return False, []
# =========================================================================
# PR METHODS
# =========================================================================
@@ -1542,26 +1699,37 @@ class GitService(BaseService):
) -> tuple[int, str, str, str, str]:
"""Create a pull request via the GitHub REST API.
When ``request.task_id`` is ``None`` the task lookup, target-branch
resolution, and template generation are skipped. The PR targets the
project's default branch and uses ``request.title`` / ``request.body``
directly (falling back to ``source_branch`` / ``""`` when absent).
Returns: (pr_number, pr_url, title, source_branch, target_branch)
"""
task_service = get_task_service(self.session)
task = await task_service.get(request.task_id)
if not task:
raise NotFoundError("Task", str(request.task_id))
source_branch = await self.get_current_branch(workspace)
default_branch = await self._project_default_branch(request.project_slug)
git_token = await self._get_project_token_or_raise(request.project_slug)
target_branch = await self._resolve_pr_target_branch(
request, task, default_branch
)
target_branch = await self._pr_base_on_remote(
workspace, target_branch, default_branch, git_token, request.task_id
)
pr_title, pr_body = await self._generate_pr_title_body(
request, task, source_branch, target_branch, request.task_id
)
if request.task_id is not None:
task_service = get_task_service(self.session)
task = await task_service.get(request.task_id)
if not task:
raise NotFoundError("Task", str(request.task_id))
target_branch = await self._resolve_pr_target_branch(
request, task, default_branch
)
target_branch = await self._pr_base_on_remote(
workspace, target_branch, default_branch, git_token, request.task_id
)
pr_title, pr_body = await self._generate_pr_title_body(
request, task, source_branch, target_branch, request.task_id
)
else:
# No task context: target the default branch directly, use provided
# title/body or fall back to minimal defaults.
target_branch = default_branch
pr_title = request.title or source_branch
pr_body = request.body or ""
owner, repo = self._parse_github_remote(workspace)
resp = await self._post_pr(
@@ -1932,9 +2100,14 @@ class GitService(BaseService):
) -> tuple[int, str, str, str, str]:
"""Preconditions + PR creation + state sync.
When ``data.task_id`` is ``None`` the ownership/state gate and the
post-creation task-state sync are both skipped the GitHub PR is still
created, but no task record is updated.
Returns: (pr_number, pr_url, title, source_branch, target_branch)
"""
await self._assert_pr_create_allowed(data.task_id, agent_id)
if data.task_id is not None:
await self._assert_pr_create_allowed(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
(
@@ -1945,7 +2118,8 @@ class GitService(BaseService):
target_branch,
) = await self.create_pull_request(workspace, data)
await self._record_pr_atomically(data.task_id, pr_number, pr_url)
if data.task_id is not None:
await self._record_pr_atomically(data.task_id, pr_number, pr_url)
return pr_number, pr_url, title, source_branch, target_branch
async def _call_merge_api(
@@ -2215,40 +2389,58 @@ class GitService(BaseService):
) -> tuple[str, str]:
"""Role-gated merge + work-session record + auto-complete.
When ``data.task_id`` is ``None`` the task lookup, role gate,
work-session update, and auto-complete are all skipped the GitHub PR
merge still happens using the caller-provided ``project_slug`` and
``pr_number``.
Returns: (target_branch, merge_commit)
"""
task_service = get_task_service(self.session)
work_session_service = get_work_session_service(self.session)
if data.task_id is not None:
task_service = get_task_service(self.session)
work_session_service = get_work_session_service(self.session)
task = await task_service.get(data.task_id)
if not task:
raise NotFoundError(resource_type="Task", resource_id=str(data.task_id))
self._assert_merge_role(self._status_value(task), agent_role)
task = await task_service.get(data.task_id)
if not task:
raise NotFoundError(resource_type="Task", resource_id=str(data.task_id))
self._assert_merge_role(self._status_value(task), agent_role)
# A coordination root has no project of its own, so the CEO's merge
# request can't carry a project_slug. Resolve the root's repo from its
# product server-side; non-root tasks keep the client-provided slug.
project_slug = data.project_slug
if task.project_id is None:
root_project = await self._project_for_task(task)
if root_project is not None:
project_slug = root_project.slug
# A coordination root has no project of its own, so the CEO's merge
# request can't carry a project_slug. Resolve the root's repo from
# its product server-side; non-root tasks keep the client slug.
project_slug = data.project_slug
if task.project_id is None:
root_project = await self._project_for_task(task)
if root_project is not None:
project_slug = root_project.slug
workspace = await self.get_workspace(project_slug, agent_id)
target_branch, merge_commit = await self.merge_pull_request(
workspace=workspace,
pr_number=data.pr_number,
merge_method=data.merge_method,
project_slug=project_slug,
)
workspace = await self.get_workspace(project_slug, agent_id)
target_branch, merge_commit = await self.merge_pull_request(
workspace=workspace,
pr_number=data.pr_number,
merge_method=data.merge_method,
project_slug=project_slug,
)
if task.work_session_id:
await work_session_service.merge_pr(
require_uuid(task.work_session_id), agent_id
if task.work_session_id:
await work_session_service.merge_pr(
require_uuid(task.work_session_id), agent_id
)
await self._auto_complete_on_merge(data.task_id, agent_id, agent_role)
await self.session.commit()
else:
# No task context — proceed directly to the merge without
# role/ownership checks or post-merge state transitions.
project_slug = data.project_slug
workspace = await self.get_workspace(project_slug, agent_id)
target_branch, merge_commit = await self.merge_pull_request(
workspace=workspace,
pr_number=data.pr_number,
merge_method=data.merge_method,
project_slug=project_slug,
)
await self._auto_complete_on_merge(data.task_id, agent_id, agent_role)
await self.session.commit()
return target_branch, merge_commit
# =========================================================================
-1
View File
@@ -1297,7 +1297,6 @@ class TaskService(BaseService):
task_id=require_uuid(task.id),
project_slug=project.slug,
branch_type="feature",
agent_id=str(agent_id),
parent_branch=parent_branch,
)
+315
View File
@@ -85,6 +85,59 @@ async def git_client(
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def pm_git_client(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
"""Like git_client but with CELL_PM role — required for the rebase endpoint."""
agent = AgentTable(
id=uuid4(),
name="PM",
slug=f"be-pm-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="GitProj",
slug=f"git-proj-{uuid4().hex[:6]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
app = FastAPI()
app.include_router(git_router, prefix="/api/git")
async def _override_db() -> AsyncGenerator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(
agent_id=cast("uuid.UUID", agent.id),
role=AgentRole.CELL_PM,
team=Team.BACKEND,
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "agent": agent, "project": project, "db": db_session}
app.dependency_overrides.clear()
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "developer"}
@@ -681,5 +734,267 @@ async def test_status_generic_service_error(git_client: dict) -> None:
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# pull
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pull_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.pull = AsyncMock(return_value=("main", False, [], [], [], 0, 0))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pull",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["current_branch"] == "main"
assert "ahead" in data
assert "behind" in data
assert "has_changes" in data
assert "staged_files" in data
assert "unstaged_files" in data
assert "untracked_files" in data
@pytest.mark.asyncio
async def test_pull_git_command_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.pull = AsyncMock(side_effect=GitCommandError("pull", "network error"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pull",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# fetch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fetch_success(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
_ahead = 2
_behind = 1
svc.fetch = AsyncMock(
return_value=("feature/x", True, ["a.py"], [], [], _ahead, _behind)
)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/fetch",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["current_branch"] == "feature/x"
assert data["ahead"] == _ahead
assert data["behind"] == _behind
assert data["has_changes"] is True
assert "staged_files" in data
assert "unstaged_files" in data
assert "untracked_files" in data
@pytest.mark.asyncio
async def test_fetch_git_command_error(git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.fetch = AsyncMock(side_effect=GitCommandError("fetch", "network error"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/fetch",
json={
"project_slug": git_client["project"].slug,
"task_id": str(uuid4()),
"agent_id": str(uuid4()),
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# rebase
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rebase_success(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.rebase = AsyncMock(return_value=(False, []))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/rebase",
json={
"project_slug": pm_git_client["project"].slug,
"target_branch": "develop",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["conflict"] is False
assert data["conflicted_files"] == []
@pytest.mark.asyncio
async def test_rebase_conflict(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.rebase = AsyncMock(return_value=(True, ["src/foo.py", "src/bar.py"]))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/rebase",
json={
"project_slug": pm_git_client["project"].slug,
"target_branch": "develop",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["conflict"] is True
assert data["conflicted_files"] == ["src/foo.py", "src/bar.py"]
@pytest.mark.asyncio
async def test_rebase_git_command_error(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc.rebase = AsyncMock(side_effect=GitCommandError("rebase", "fatal error"))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/rebase",
json={
"project_slug": pm_git_client["project"].slug,
"target_branch": "develop",
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
# ---------------------------------------------------------------------------
# task_id Optional — no 422 when task_id is omitted
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_commit_without_task_id_no_422(git_client: dict) -> None:
"""POST /commit without task_id must not return 422 (schema validation error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.commit_for_task = AsyncMock(
return_value=("abc123", "feat: add thing", 1, 5, 2)
)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/commit",
json={
"project_slug": git_client["project"].slug,
"agent_id": str(uuid4()),
"message": "add a new thing",
"commit_type": "feat",
},
headers=_HDR,
)
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_push_without_task_id_no_422(git_client: dict) -> None:
"""POST /push without task_id must not return 422 (schema validation error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.push_for_task = AsyncMock(return_value=("feature/x", 3))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/push",
json={
"project_slug": git_client["project"].slug,
},
headers=_HDR,
)
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_create_pr_without_task_id_no_422(git_client: dict) -> None:
"""POST /pr/create without task_id must not return 422 (schema validation error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.create_pr_for_task = AsyncMock(
return_value=(
7,
"https://github.com/x/y/pull/7",
"feat: add thing",
"feat/x",
"main",
)
)
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pr/create",
json={
"project_slug": git_client["project"].slug,
},
headers=_HDR,
)
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_merge_pr_without_task_id_no_422(git_client: dict) -> None:
"""POST /pr/merge without task_id must not return 422 (schema validation error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.merge_pr_for_task = AsyncMock(return_value=("main", "deadbeef"))
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/pr/merge",
json={
"project_slug": git_client["project"].slug,
"pr_number": 99,
},
headers=_HDR,
)
assert response.status_code != HTTPStatus.UNPROCESSABLE_ENTITY
assert response.status_code == HTTPStatus.OK
# Re-export to keep import alive (TC reorders imports)
_ = SimpleNamespace
@@ -0,0 +1,329 @@
"""Unit tests: task_id is Optional in git request schemas and service methods.
These tests verify:
- The four git schemas accept None task_id (no 422 on absent field).
- Existing callers that pass task_id still work (regression).
- The HTTP endpoints return 200 when task_id is omitted.
All tests run without a real database or git process.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.git import router as git_router
from roboco.api.schemas.git import (
GitCommitRequest,
GitCreatePRRequest,
GitMergePRRequest,
GitPushRequest,
)
from roboco.models.base import AgentRole, Team
from roboco.models.permissions import AgentContext
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, AsyncIterator
_HTTP_UNPROCESSABLE = 422
_HTTP_OK = 200
_AGENT_ID = uuid4()
_TASK_ID = uuid4()
# ---------------------------------------------------------------------------
# Fixtures — no real DB required
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def client() -> AsyncIterator[AsyncClient]:
"""FastAPI test client with mocked auth + DB; no Postgres needed."""
app = FastAPI()
app.include_router(git_router, prefix="/api/git")
async def _mock_db() -> AsyncGenerator:
yield AsyncMock() # DB session is never touched in these tests
async def _mock_agent() -> AgentContext:
return AgentContext(
agent_id=_AGENT_ID,
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
)
app.dependency_overrides[get_db] = _mock_db
app.dependency_overrides[get_agent_context] = _mock_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
_HDR = {"X-Agent-ID": str(_AGENT_ID), "X-Agent-Role": "developer"}
# ---------------------------------------------------------------------------
# Schema unit tests — direct Pydantic validation, no HTTP needed
# ---------------------------------------------------------------------------
def test_commit_request_task_id_optional() -> None:
"""GitCommitRequest accepts a missing task_id (defaults to None)."""
req = GitCommitRequest(
project_slug="roboco",
message="add something new here",
commit_type="feat",
)
assert req.task_id is None
def test_commit_request_task_id_present() -> None:
"""GitCommitRequest still accepts an explicit task_id (regression)."""
req = GitCommitRequest(
project_slug="roboco",
task_id=_TASK_ID,
message="add something new here",
commit_type="feat",
)
assert req.task_id == _TASK_ID
def test_push_request_task_id_optional() -> None:
"""GitPushRequest accepts a missing task_id (defaults to None)."""
req = GitPushRequest(project_slug="roboco")
assert req.task_id is None
def test_push_request_task_id_present() -> None:
"""GitPushRequest still accepts an explicit task_id (regression)."""
req = GitPushRequest(project_slug="roboco", task_id=_TASK_ID)
assert req.task_id == _TASK_ID
def test_create_pr_request_task_id_optional() -> None:
"""GitCreatePRRequest accepts a missing task_id (defaults to None)."""
req = GitCreatePRRequest(project_slug="roboco")
assert req.task_id is None
def test_create_pr_request_task_id_present() -> None:
"""GitCreatePRRequest still accepts an explicit task_id (regression)."""
req = GitCreatePRRequest(project_slug="roboco", task_id=_TASK_ID)
assert req.task_id == _TASK_ID
def test_merge_pr_request_task_id_optional() -> None:
"""GitMergePRRequest accepts a missing task_id (defaults to None)."""
req = GitMergePRRequest(project_slug="roboco", pr_number=42)
assert req.task_id is None
def test_merge_pr_request_task_id_present() -> None:
"""GitMergePRRequest still accepts an explicit task_id (regression)."""
req = GitMergePRRequest(project_slug="roboco", pr_number=42, task_id=_TASK_ID)
assert req.task_id == _TASK_ID
# ---------------------------------------------------------------------------
# HTTP endpoint tests — verify no 422 when task_id is absent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_commit_without_task_id_returns_200_not_422(
client: AsyncClient,
) -> None:
"""POST /commit without task_id must not return 422 (schema error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
svc = AsyncMock()
svc.commit_for_task = AsyncMock(
return_value=("deadbeef", "feat: add something", 1, 5, 2)
)
mock_svc.return_value = svc
response = await client.post(
"/api/git/commit",
json={
"project_slug": "roboco",
"agent_id": str(_AGENT_ID),
"message": "add something new",
"commit_type": "feat",
# task_id intentionally omitted
},
headers=_HDR,
)
assert response.status_code != _HTTP_UNPROCESSABLE, (
f"Expected no 422, got {response.status_code}: {response.text}"
)
assert response.status_code == _HTTP_OK
@pytest.mark.asyncio
async def test_push_without_task_id_returns_200_not_422(
client: AsyncClient,
) -> None:
"""POST /push without task_id must not return 422 (schema error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
svc = AsyncMock()
svc.push_for_task = AsyncMock(return_value=("feature/x", 3))
mock_svc.return_value = svc
response = await client.post(
"/api/git/push",
json={
"project_slug": "roboco",
# task_id intentionally omitted
},
headers=_HDR,
)
assert response.status_code != _HTTP_UNPROCESSABLE, (
f"Expected no 422, got {response.status_code}: {response.text}"
)
assert response.status_code == _HTTP_OK
@pytest.mark.asyncio
async def test_create_pr_without_task_id_returns_200_not_422(
client: AsyncClient,
) -> None:
"""POST /pr/create without task_id must not return 422 (schema error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
svc = AsyncMock()
svc.create_pr_for_task = AsyncMock(
return_value=(
7,
"https://github.com/x/y/pull/7",
"feat: add thing",
"feature/x",
"main",
)
)
mock_svc.return_value = svc
response = await client.post(
"/api/git/pr/create",
json={
"project_slug": "roboco",
# task_id intentionally omitted
},
headers=_HDR,
)
assert response.status_code != _HTTP_UNPROCESSABLE, (
f"Expected no 422, got {response.status_code}: {response.text}"
)
assert response.status_code == _HTTP_OK
@pytest.mark.asyncio
async def test_merge_pr_without_task_id_returns_200_not_422(
client: AsyncClient,
) -> None:
"""POST /pr/merge without task_id must not return 422 (schema error)."""
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
svc = AsyncMock()
svc.merge_pr_for_task = AsyncMock(return_value=("main", "deadbeef"))
mock_svc.return_value = svc
response = await client.post(
"/api/git/pr/merge",
json={
"project_slug": "roboco",
"pr_number": 99,
# task_id intentionally omitted
},
headers=_HDR,
)
assert response.status_code != _HTTP_UNPROCESSABLE, (
f"Expected no 422, got {response.status_code}: {response.text}"
)
assert response.status_code == _HTTP_OK
# ---------------------------------------------------------------------------
# Regression: existing callers that pass task_id still get 200
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_commit_with_task_id_still_works(client: AsyncClient) -> None:
"""POST /commit with task_id (regression) must still return 200."""
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
svc = AsyncMock()
svc.commit_for_task = AsyncMock(
return_value=("abc123", "fix(auth): correct thing", 2, 10, 3)
)
mock_svc.return_value = svc
response = await client.post(
"/api/git/commit",
json={
"project_slug": "roboco",
"task_id": str(_TASK_ID),
"agent_id": str(_AGENT_ID),
"message": "correct the auth flow",
"commit_type": "fix",
},
headers=_HDR,
)
assert response.status_code == _HTTP_OK
@pytest.mark.asyncio
async def test_push_with_task_id_still_works(client: AsyncClient) -> None:
"""POST /push with task_id (regression) must still return 200."""
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
svc = AsyncMock()
svc.push_for_task = AsyncMock(return_value=("feature/x", 2))
mock_svc.return_value = svc
response = await client.post(
"/api/git/push",
json={
"project_slug": "roboco",
"task_id": str(_TASK_ID),
},
headers=_HDR,
)
assert response.status_code == _HTTP_OK
@pytest.mark.asyncio
async def test_create_pr_with_task_id_still_works(client: AsyncClient) -> None:
"""POST /pr/create with task_id (regression) must still return 200."""
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
svc = AsyncMock()
svc.create_pr_for_task = AsyncMock(
return_value=(5, "https://github.com/x/y/pull/5", "T", "feat", "main")
)
mock_svc.return_value = svc
response = await client.post(
"/api/git/pr/create",
json={
"project_slug": "roboco",
"task_id": str(_TASK_ID),
},
headers=_HDR,
)
assert response.status_code == _HTTP_OK
@pytest.mark.asyncio
async def test_merge_pr_with_task_id_still_works(client: AsyncClient) -> None:
"""POST /pr/merge with task_id (regression) must still return 200."""
with patch("roboco.api.routes.git.get_git_service") as mock_svc:
svc = AsyncMock()
svc.merge_pr_for_task = AsyncMock(return_value=("main", "cafebabe"))
mock_svc.return_value = svc
response = await client.post(
"/api/git/pr/merge",
json={
"project_slug": "roboco",
"pr_number": 5,
"task_id": str(_TASK_ID),
},
headers=_HDR,
)
assert response.status_code == _HTTP_OK
@@ -63,8 +63,7 @@ def test_get_agent_image_registry_mode(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(orch.settings, "agent_image_registry", "ghcr.io/rennf93")
monkeypatch.setattr(orch.settings, "agent_image_tag", "0.5.0")
assert (
orch.get_agent_image("be-dev-1")
== "ghcr.io/rennf93/roboco-agent-dev-be:0.5.0"
orch.get_agent_image("be-dev-1") == "ghcr.io/rennf93/roboco-agent-dev-be:0.5.0"
)
assert (
orch.get_agent_image("pr-reviewer-1")
-2
View File
@@ -550,7 +550,6 @@ async def test_create_branch_idempotent_when_branch_already_exists() -> None:
project_slug="roboco-api",
task_id=uuid4(),
branch_type="feature",
agent_id=str(uuid4()),
parent_branch=None,
),
)
@@ -601,7 +600,6 @@ async def _run_create_branch_with_existing_branch(
project_slug="roboco-panel",
task_id=uuid4(),
branch_type="feature",
agent_id=str(uuid4()),
parent_branch=None,
),
)
+525
View File
@@ -0,0 +1,525 @@
"""Unit tests for GitService rebase conflict-state handling.
Pins the three critical control-flow branches of ``rebase_onto_base``:
1. **Success** the underlying ``git rebase`` exits 0 method returns a
non-conflict result dict and never calls ``git rebase --abort``.
2. **Conflict** ``git rebase`` exits non-zero method calls
``git diff --name-only --diff-filter=U`` to collect conflicted files,
calls ``git rebase --abort`` to restore the workspace, and returns a
conflict result dict.
3. **Resilience** both ``git rebase`` and ``git rebase --abort`` exit
non-zero (e.g. abort fails mid-stream). The method must still return
the conflict dict without propagating an exception, because both are
invoked with ``check=False``.
All tests mock ``_run_git`` at the service-method level using
``AsyncMock`` with a ``side_effect`` list so each awaited call consumes
the next pre-configured result in order.
Also covers the ``rebase()`` safety gate added by the git-schema cleanup
task: rebasing onto or from a protected branch (master/main) is rejected
with a service-layer ``ValidationError`` before any git command runs.
Also covers:
* ``pull()`` dirty-tree and diverged-branch ``ValidationError`` gates.
* ``pull()`` success path.
* ``GitRebaseRequest.target_branch`` Pydantic field validator.
* Route-level role gate: DEVELOPER 403, CELL_PM 200.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, call, patch
from uuid import uuid4
import pydantic
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.git import router as git_router
from roboco.api.schemas.git import GitRebaseRequest
from roboco.models.base import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services.base import ValidationError
from roboco.services.git import GitService
_HTTP_200 = 200
_HTTP_403 = 403
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_HEAD = "feature/backend/root--task"
_BASE = "feature/backend/root"
_WORKSPACE = Path("/tmp/fake-ws")
_TOKEN = "ghp_fake"
def _git_service() -> GitService:
"""Instantiate GitService without a real DB session."""
svc = GitService.__new__(GitService)
svc.log = MagicMock() # silence warning/info calls
return svc
def _result(returncode: int = 0, stdout: str = "", stderr: str = "") -> Any:
"""Minimal subprocess result stand-in."""
r = MagicMock()
r.returncode = returncode
r.stdout = stdout
r.stderr = stderr
return r
# ---------------------------------------------------------------------------
# Test 1 — success path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_success_path_returns_rebased_and_does_not_call_abort(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When git rebase exits 0 the method returns a non-conflict result and
never invokes ``git rebase --abort``.
Call sequence for the success path (rebase OK, 2 unique commits):
[0] fetch origin
[1] checkout HEAD branch
[2] reset --hard origin/HEAD
[3] rebase origin/BASE exits 0
[4] rev-list --count returns "2"
[5] push --force-with-lease pushes the rebased branch
"""
run = AsyncMock(
side_effect=[
_result(), # [0] fetch
_result(), # [1] checkout
_result(), # [2] reset
_result(), # [3] rebase ← success
_result(stdout="2\n"), # [4] rev-list
_result(), # [5] push
]
)
monkeypatch.setattr(GitService, "_run_git", run)
svc = _git_service()
result = await svc.rebase_onto_base(
_WORKSPACE,
head_branch=_HEAD,
base_branch=_BASE,
git_token=_TOKEN,
)
assert result == {"status": "rebased", "unique_commits": 2}
# Verify abort was never called
abort_call = call(_WORKSPACE, ["rebase", "--abort"], check=False)
assert abort_call not in run.call_args_list, (
"git rebase --abort must NOT be called on a clean rebase"
)
# ---------------------------------------------------------------------------
# Test 2 — conflict path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_conflict_path_calls_diff_then_abort_and_returns_conflict_files(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When git rebase exits non-zero the method:
* calls ``git diff --name-only --diff-filter=U`` to identify conflicted files,
* calls ``git rebase --abort`` to restore the workspace,
* returns ``{"status": "conflicts", "files": [<conflicted files>]}``.
Call sequence:
[0] fetch origin
[1] checkout HEAD branch
[2] reset --hard origin/HEAD
[3] rebase origin/BASE exits 1 (conflict)
[4] diff --name-only lists conflicted files
[5] rebase --abort exits 0
"""
run = AsyncMock(
side_effect=[
_result(), # [0] fetch
_result(), # [1] checkout
_result(), # [2] reset
_result(returncode=1), # [3] rebase ← conflict
_result(stdout="src/a.py\nsrc/b.py\n"), # [4] diff
_result(), # [5] rebase --abort
]
)
monkeypatch.setattr(GitService, "_run_git", run)
svc = _git_service()
result = await svc.rebase_onto_base(
_WORKSPACE,
head_branch=_HEAD,
base_branch=_BASE,
git_token=_TOKEN,
)
assert result == {"status": "conflicts", "files": ["src/a.py", "src/b.py"]}
# Verify the diff call was made with the correct flags
diff_call = call(
_WORKSPACE,
["diff", "--name-only", "--diff-filter=U"],
check=False,
)
assert diff_call in run.call_args_list, (
"git diff --name-only --diff-filter=U must be called to collect conflicted"
" files"
)
# Verify abort was called
abort_call = call(_WORKSPACE, ["rebase", "--abort"], check=False)
assert abort_call in run.call_args_list, (
"git rebase --abort must be called to restore the workspace after a conflict"
)
# ---------------------------------------------------------------------------
# Test 3 — resilience: both rebase and abort exit non-zero
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resilience_when_both_rebase_and_abort_fail_returns_conflict_no_exception(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When ``git rebase`` exits non-zero AND ``git rebase --abort`` also
exits non-zero, the method must still return a conflict result dict
without raising an exception.
Both are called with ``check=False`` so a non-zero exit code from
either command produces a result object (not a raised exception).
Call sequence:
[0] fetch origin
[1] checkout HEAD branch
[2] reset --hard origin/HEAD
[3] rebase origin/BASE exits 1 (conflict)
[4] diff --name-only lists conflicted files
[5] rebase --abort exits 1 (abort also fails)
"""
run = AsyncMock(
side_effect=[
_result(), # [0] fetch
_result(), # [1] checkout
_result(), # [2] reset
_result(returncode=1), # [3] rebase ← conflict
_result(stdout="src/conflict.py\n"), # [4] diff
_result(returncode=1), # [5] rebase --abort ← also fails
]
)
monkeypatch.setattr(GitService, "_run_git", run)
svc = _git_service()
# Must not raise even though both rebase and abort return non-zero
result = await svc.rebase_onto_base(
_WORKSPACE,
head_branch=_HEAD,
base_branch=_BASE,
git_token=_TOKEN,
)
assert result == {"status": "conflicts", "files": ["src/conflict.py"]}
# ---------------------------------------------------------------------------
# Safety gate tests for rebase() — protected-branch guard
# ---------------------------------------------------------------------------
# These test the service-layer ``rebase()`` method (the workspace-scoped API
# endpoint helper), NOT ``rebase_onto_base()`` (the internal gateway helper).
# The guard runs BEFORE any git command, so no ``_run_git`` mock is needed
# for target-branch cases; the head-branch case requires a stubbed
# ``get_current_branch``.
@pytest.mark.asyncio
async def test_rebase_raises_validation_error_when_target_is_master() -> None:
"""rebase() must raise ValidationError for target_branch='master'."""
svc = _git_service()
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
await svc.rebase(_WORKSPACE, "master")
@pytest.mark.asyncio
async def test_rebase_raises_validation_error_when_target_is_main() -> None:
"""rebase() must raise ValidationError for target_branch='main'."""
svc = _git_service()
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
await svc.rebase(_WORKSPACE, "main")
@pytest.mark.asyncio
async def test_rebase_raises_validation_error_when_head_branch_is_master(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""rebase() must raise ValidationError when HEAD is 'master'.
The target-branch check passes (we pass a safe target), but the
head-branch guard fires when get_current_branch returns 'master'.
"""
monkeypatch.setattr(
GitService,
"get_current_branch",
AsyncMock(return_value="master"),
)
svc = _git_service()
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
@pytest.mark.asyncio
async def test_rebase_raises_validation_error_when_head_branch_is_main(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""rebase() must raise ValidationError when HEAD is 'main'."""
monkeypatch.setattr(
GitService,
"get_current_branch",
AsyncMock(return_value="main"),
)
svc = _git_service()
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
# ---------------------------------------------------------------------------
# pull() safety-gate tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pull_raises_validation_error_on_dirty_tree(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""pull() raises ValidationError(DIRTY_WORKSPACE) when the tree is dirty.
The pre-flight ``git status --porcelain`` returns modified files, so pull
must reject immediately before any network call.
"""
monkeypatch.setattr(
GitService,
"_run_git",
AsyncMock(return_value=_result(stdout=" M dirty.py\n")),
)
svc = _git_service()
with pytest.raises(ValidationError, match="DIRTY_WORKSPACE"):
await svc.pull(_WORKSPACE)
@pytest.mark.asyncio
async def test_pull_raises_validation_error_on_diverged_branch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""pull() raises ValidationError(DIVERGED_BRANCH) when --ff-only fails.
The pre-flight status is clean, but ``git pull --ff-only`` exits non-zero
with a "not possible to fast-forward" message because the branch has
diverged from origin.
"""
monkeypatch.setattr(
GitService,
"_run_git",
AsyncMock(
side_effect=[
_result(stdout=""), # status --porcelain → clean
_result( # pull --ff-only → diverged
returncode=1,
stderr="fatal: Not possible to fast-forward, aborting.",
),
]
),
)
monkeypatch.setattr(
GitService, "_token_for_workspace", AsyncMock(return_value=None)
)
svc = _git_service()
with pytest.raises(ValidationError, match="DIVERGED_BRANCH"):
await svc.pull(_WORKSPACE)
@pytest.mark.asyncio
async def test_pull_success_returns_post_pull_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""pull() returns the post-pull status on a clean, fast-forwardable branch.
The pre-flight ``git status --porcelain`` is clean and ``git pull --ff-only``
succeeds, so pull() returns the post-pull ``get_status`` tuple.
"""
_post_pull: tuple[str, bool, list[str], list[str], list[str], int, int] = (
"feature/backend/task",
False,
[],
[],
[],
0,
0,
)
monkeypatch.setattr(
GitService,
"_run_git",
AsyncMock(side_effect=[_result(stdout=""), _result(returncode=0)]),
)
monkeypatch.setattr(
GitService, "_token_for_workspace", AsyncMock(return_value=None)
)
monkeypatch.setattr(GitService, "get_status", AsyncMock(return_value=_post_pull))
svc = _git_service()
result = await svc.pull(_WORKSPACE)
assert result == _post_pull
# ---------------------------------------------------------------------------
# GitRebaseRequest.target_branch field validator tests
# ---------------------------------------------------------------------------
def test_rebase_request_target_branch_dash_prefix_rejected() -> None:
"""GitRebaseRequest rejects target_branch that starts with '-'.
Branch names beginning with '-' are not valid git ref names and look
like CLI flags, so the schema validator rejects them with a clear error.
"""
with pytest.raises(pydantic.ValidationError, match="INVALID_TARGET_BRANCH"):
GitRebaseRequest(
project_slug="roboco",
target_branch="-bad-branch",
)
def test_rebase_request_target_branch_protected_name_rejected() -> None:
"""GitRebaseRequest rejects target_branch 'main' (a protected branch name)."""
with pytest.raises(pydantic.ValidationError, match="PROTECTED_BRANCH"):
GitRebaseRequest(
project_slug="roboco",
target_branch="main",
)
def test_rebase_request_target_branch_master_rejected() -> None:
"""GitRebaseRequest rejects target_branch 'master' (a protected branch name)."""
with pytest.raises(pydantic.ValidationError, match="PROTECTED_BRANCH"):
GitRebaseRequest(
project_slug="roboco",
target_branch="master",
)
def test_rebase_request_valid_target_branch_accepted() -> None:
"""GitRebaseRequest accepts a valid, non-protected target_branch."""
req = GitRebaseRequest(
project_slug="roboco",
target_branch="feature/backend/some-task",
)
assert req.target_branch == "feature/backend/some-task"
# ---------------------------------------------------------------------------
# Route-level tests: role gate on POST /rebase
# ---------------------------------------------------------------------------
async def _mock_db_generator() -> Any:
"""Async generator yielding a MagicMock as the database session.
FastAPI's original ``get_db`` is an async generator (uses ``yield``).
The override must also be a generator (or at least async) so FastAPI
handles the dependency lifecycle correctly.
"""
yield MagicMock()
def _build_git_app(agent_context: AgentContext) -> FastAPI:
"""Minimal FastAPI app with the git router and overridden agent context."""
app = FastAPI()
app.include_router(git_router, prefix="/git")
app.dependency_overrides[get_agent_context] = lambda: agent_context
app.dependency_overrides[get_db] = _mock_db_generator
return app
@pytest.mark.asyncio
async def test_rebase_endpoint_developer_gets_403() -> None:
"""POST /git/rebase returns HTTP 403 for a DEVELOPER-role agent.
The role gate fires before any service call, so no git service mock
is needed.
"""
agent = AgentContext(agent_id=uuid4(), role=AgentRole.DEVELOPER)
app = _build_git_app(agent)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/git/rebase",
json={
"project_slug": "roboco",
"target_branch": "feature/backend/some-task",
},
)
assert response.status_code == _HTTP_403
detail = response.json()["detail"]
assert "REBASE_ROLE_RESTRICTED" in detail
@pytest.mark.asyncio
async def test_rebase_endpoint_pm_gets_200() -> None:
"""POST /git/rebase returns HTTP 200 for a CELL_PM-role agent.
The role gate passes; no task_id is supplied so the ownership check
is skipped; project resolution and the git service are patched.
"""
agent = AgentContext(agent_id=uuid4(), role=AgentRole.CELL_PM)
app = _build_git_app(agent)
# Mock project service → returns a project with slug "roboco"
mock_project = MagicMock()
mock_project.slug = "roboco"
mock_project_svc = MagicMock()
mock_project_svc.get_by_slug = AsyncMock(return_value=mock_project)
# Mock git service → workspace + rebase succeed without conflict
mock_git_svc = MagicMock()
mock_git_svc.get_workspace = AsyncMock(return_value=Path("/tmp/fake-ws"))
mock_git_svc.rebase = AsyncMock(return_value=(False, []))
transport = ASGITransport(app=app)
with (
patch(
"roboco.api.routes.git.get_project_service", return_value=mock_project_svc
),
patch("roboco.api.routes.git.get_git_service", return_value=mock_git_svc),
):
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/git/rebase",
json={
"project_slug": "roboco",
"target_branch": "feature/backend/some-task",
},
)
assert response.status_code == _HTTP_200
body = response.json()
assert body["project_slug"] == "roboco"
assert body["conflict"] is False
assert body["conflicted_files"] == []