fix: resolve agent work orders api routing and defensive coding

- add trailing slashes to agent-work-orders endpoints to prevent FastAPI mount() redirects
- add defensive null check for repository_url in detail view
- fix backend routes to use relative paths with app.mount()
- resolves ERR_NAME_NOT_RESOLVED when accessing agent work orders
This commit is contained in:
Rasmus Widing
2025-10-17 09:52:58 +03:00
parent 6fe9c110e2
commit edf3a51fa5
15 changed files with 1685 additions and 7 deletions

View File

@@ -0,0 +1,237 @@
/**
* CreateWorkOrderDialog Component
*
* Modal dialog for creating new agent work orders with form validation.
* Includes repository URL, sandbox type, user request, and command selection.
*/
import { zodResolver } from "@hookform/resolvers/zod";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/features/ui/primitives/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/features/ui/primitives/dialog";
import { useCreateWorkOrder } from "../hooks/useAgentWorkOrderQueries";
import type { WorkflowStep } from "../types";
const workOrderSchema = z.object({
repository_url: z.string().url("Must be a valid URL"),
sandbox_type: z.enum(["git_branch", "git_worktree"]),
user_request: z.string().min(10, "Request must be at least 10 characters"),
github_issue_number: z.string().optional(),
});
type WorkOrderFormData = z.infer<typeof workOrderSchema>;
interface CreateWorkOrderDialogProps {
/** Whether dialog is open */
open: boolean;
/** Callback when dialog should close */
onClose: () => void;
/** Callback when work order is created */
onSuccess?: (workOrderId: string) => void;
}
const ALL_COMMANDS: WorkflowStep[] = ["create-branch", "planning", "execute", "commit", "create-pr"];
const COMMAND_LABELS: Record<WorkflowStep, string> = {
"create-branch": "Create Branch",
planning: "Planning",
execute: "Execute",
commit: "Commit",
"create-pr": "Create PR",
"prp-review": "PRP Review",
};
export function CreateWorkOrderDialog({ open, onClose, onSuccess }: CreateWorkOrderDialogProps) {
const [selectedCommands, setSelectedCommands] = useState<WorkflowStep[]>(ALL_COMMANDS);
const createWorkOrder = useCreateWorkOrder();
const formId = useId();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<WorkOrderFormData>({
resolver: zodResolver(workOrderSchema),
defaultValues: {
sandbox_type: "git_branch",
},
});
const handleClose = () => {
reset();
setSelectedCommands(ALL_COMMANDS);
onClose();
};
const onSubmit = async (data: WorkOrderFormData) => {
createWorkOrder.mutate(
{
...data,
selected_commands: selectedCommands,
github_issue_number: data.github_issue_number || null,
},
{
onSuccess: (result) => {
handleClose();
onSuccess?.(result.agent_work_order_id);
},
},
);
};
const toggleCommand = (command: WorkflowStep) => {
setSelectedCommands((prev) => (prev.includes(command) ? prev.filter((c) => c !== command) : [...prev, command]));
};
const setPreset = (preset: "full" | "planning" | "no-pr") => {
switch (preset) {
case "full":
setSelectedCommands(ALL_COMMANDS);
break;
case "planning":
setSelectedCommands(["create-branch", "planning"]);
break;
case "no-pr":
setSelectedCommands(["create-branch", "planning", "execute", "commit"]);
break;
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Agent Work Order</DialogTitle>
<DialogDescription>Configure and launch a new AI-driven development workflow</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor={`${formId}-repository_url`} className="block text-sm font-medium text-gray-300 mb-2">
Repository URL *
</label>
<input
id={`${formId}-repository_url`}
type="text"
{...register("repository_url")}
placeholder="https://github.com/username/repo"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
{errors.repository_url && <p className="mt-1 text-sm text-red-400">{errors.repository_url.message}</p>}
</div>
<div>
<label htmlFor={`${formId}-sandbox_type`} className="block text-sm font-medium text-gray-300 mb-2">
Sandbox Type *
</label>
<select
id={`${formId}-sandbox_type`}
{...register("sandbox_type")}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="git_branch">Git Branch</option>
<option value="git_worktree">Git Worktree</option>
</select>
</div>
<div>
<label htmlFor={`${formId}-user_request`} className="block text-sm font-medium text-gray-300 mb-2">
User Request *
</label>
<textarea
id={`${formId}-user_request`}
{...register("user_request")}
rows={4}
placeholder="Describe the work you want the AI agent to perform..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 resize-none"
/>
{errors.user_request && <p className="mt-1 text-sm text-red-400">{errors.user_request.message}</p>}
</div>
<div>
<label htmlFor={`${formId}-github_issue_number`} className="block text-sm font-medium text-gray-300 mb-2">
GitHub Issue Number (optional)
</label>
<input
id={`${formId}-github_issue_number`}
type="text"
{...register("github_issue_number")}
placeholder="123"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-300">Workflow Commands</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPreset("full")}
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
>
Full
</button>
<button
type="button"
onClick={() => setPreset("planning")}
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
>
Planning Only
</button>
<button
type="button"
onClick={() => setPreset("no-pr")}
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
>
No PR
</button>
</div>
</div>
<div className="space-y-2">
{ALL_COMMANDS.map((command) => (
<label
key={command}
className="flex items-center gap-3 p-3 bg-gray-800 border border-gray-700 rounded-lg hover:border-gray-600 cursor-pointer"
>
<input
type="checkbox"
checked={selectedCommands.includes(command)}
onChange={() => toggleCommand(command)}
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
/>
<span className="text-gray-300">{COMMAND_LABELS[command]}</span>
</label>
))}
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={handleClose} disabled={createWorkOrder.isPending}>
Cancel
</Button>
<Button type="submit" disabled={createWorkOrder.isPending || selectedCommands.length === 0}>
{createWorkOrder.isPending ? "Creating..." : "Create Work Order"}
</Button>
</DialogFooter>
</form>
{createWorkOrder.isError && (
<div className="mt-4 p-3 bg-red-900 bg-opacity-30 border border-red-700 rounded text-sm text-red-300">
Failed to create work order. Please try again.
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,112 @@
/**
* StepHistoryTimeline Component
*
* Displays a vertical timeline of step execution history with status,
* duration, and error messages.
*/
import { formatDistanceToNow } from "date-fns";
import type { StepExecutionResult } from "../types";
interface StepHistoryTimelineProps {
/** Array of executed steps */
steps: StepExecutionResult[];
/** Current phase being executed */
currentPhase: string | null;
}
const STEP_LABELS: Record<string, string> = {
"create-branch": "Create Branch",
planning: "Planning",
execute: "Execute",
commit: "Commit",
"create-pr": "Create PR",
"prp-review": "PRP Review",
};
export function StepHistoryTimeline({ steps, currentPhase }: StepHistoryTimelineProps) {
if (steps.length === 0) {
return <div className="text-center py-8 text-gray-400">No steps executed yet</div>;
}
const formatDuration = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
return (
<div className="space-y-4">
{steps.map((step, index) => {
const isLast = index === steps.length - 1;
const isCurrent = currentPhase === step.step;
const timeAgo = formatDistanceToNow(new Date(step.timestamp), {
addSuffix: true,
});
return (
<div key={`${step.step}-${step.timestamp}`} className="flex gap-4">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${
step.success ? "bg-green-500 border-green-400" : "bg-red-500 border-red-400"
} ${isCurrent ? "animate-pulse" : ""}`}
>
{step.success ? (
<span className="text-white text-sm"></span>
) : (
<span className="text-white text-sm"></span>
)}
</div>
{!isLast && (
<div className={`w-0.5 flex-1 min-h-[40px] ${step.success ? "bg-green-500" : "bg-red-500"}`} />
)}
</div>
<div className="flex-1 pb-4">
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="text-white font-semibold">{STEP_LABELS[step.step] || step.step}</h4>
<p className="text-sm text-gray-400 mt-1">{step.agent_name}</p>
</div>
<div className="text-right">
<div
className={`text-xs font-medium px-2 py-1 rounded ${
step.success
? "bg-green-900 bg-opacity-30 text-green-400"
: "bg-red-900 bg-opacity-30 text-red-400"
}`}
>
{formatDuration(step.duration_seconds)}
</div>
<p className="text-xs text-gray-500 mt-1">{timeAgo}</p>
</div>
</div>
{step.output && (
<div className="mt-3 p-3 bg-gray-900 bg-opacity-50 rounded border border-gray-700">
<p className="text-sm text-gray-300 font-mono whitespace-pre-wrap">
{step.output.length > 500 ? `${step.output.substring(0, 500)}...` : step.output}
</p>
</div>
)}
{step.error_message && (
<div className="mt-3 p-3 bg-red-900 bg-opacity-30 border border-red-700 rounded">
<p className="text-sm text-red-300 font-mono whitespace-pre-wrap">{step.error_message}</p>
</div>
)}
{step.session_id && <div className="mt-2 text-xs text-gray-500">Session: {step.session_id}</div>}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,115 @@
/**
* WorkOrderCard Component
*
* Displays a summary card for a single work order with status badge,
* repository info, and key metadata.
*/
import { formatDistanceToNow } from "date-fns";
import type { AgentWorkOrder } from "../types";
interface WorkOrderCardProps {
/** Work order to display */
workOrder: AgentWorkOrder;
/** Callback when card is clicked */
onClick?: () => void;
}
const STATUS_STYLES: Record<AgentWorkOrder["status"], { bg: string; text: string; label: string }> = {
pending: {
bg: "bg-gray-700",
text: "text-gray-300",
label: "Pending",
},
running: {
bg: "bg-blue-600",
text: "text-blue-100",
label: "Running",
},
completed: {
bg: "bg-green-600",
text: "text-green-100",
label: "Completed",
},
failed: {
bg: "bg-red-600",
text: "text-red-100",
label: "Failed",
},
};
export function WorkOrderCard({ workOrder, onClick }: WorkOrderCardProps) {
const statusStyle = STATUS_STYLES[workOrder.status];
const repoName = workOrder.repository_url.split("/").slice(-2).join("/");
const timeAgo = formatDistanceToNow(new Date(workOrder.created_at), {
addSuffix: true,
});
return (
<div
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-4 hover:border-blue-500 transition-all cursor-pointer"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white truncate">{repoName}</h3>
<p className="text-sm text-gray-400 mt-1">{timeAgo}</p>
</div>
<div className={`px-3 py-1 rounded-full text-xs font-medium ${statusStyle.bg} ${statusStyle.text} ml-3`}>
{statusStyle.label}
</div>
</div>
{workOrder.current_phase && (
<div className="mb-2">
<p className="text-sm text-gray-300">
Phase: <span className="text-blue-400">{workOrder.current_phase}</span>
</p>
</div>
)}
{workOrder.git_branch_name && (
<div className="mb-2">
<p className="text-sm text-gray-300">
Branch: <span className="text-cyan-400 font-mono text-xs">{workOrder.git_branch_name}</span>
</p>
</div>
)}
{workOrder.github_pull_request_url && (
<div className="mb-2">
<a
href={workOrder.github_pull_request_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-400 hover:text-blue-300 underline"
onClick={(e) => e.stopPropagation()}
>
View Pull Request
</a>
</div>
)}
{workOrder.error_message && (
<div className="mt-2 p-2 bg-red-900 bg-opacity-30 border border-red-700 rounded text-xs text-red-300">
{workOrder.error_message.length > 100
? `${workOrder.error_message.substring(0, 100)}...`
: workOrder.error_message}
</div>
)}
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
{workOrder.git_commit_count > 0 && <span>{workOrder.git_commit_count} commits</span>}
{workOrder.git_files_changed > 0 && <span>{workOrder.git_files_changed} files changed</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
/**
* WorkOrderList Component
*
* Displays a filterable list of agent work orders with status filters and search.
*/
import { useMemo, useState } from "react";
import { useWorkOrders } from "../hooks/useAgentWorkOrderQueries";
import type { AgentWorkOrderStatus } from "../types";
import { WorkOrderCard } from "./WorkOrderCard";
interface WorkOrderListProps {
/** Callback when a work order card is clicked */
onWorkOrderClick?: (workOrderId: string) => void;
}
const STATUS_OPTIONS: Array<{
value: AgentWorkOrderStatus | "all";
label: string;
}> = [
{ value: "all", label: "All" },
{ value: "pending", label: "Pending" },
{ value: "running", label: "Running" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
];
export function WorkOrderList({ onWorkOrderClick }: WorkOrderListProps) {
const [statusFilter, setStatusFilter] = useState<AgentWorkOrderStatus | "all">("all");
const [searchQuery, setSearchQuery] = useState("");
const queryFilter = statusFilter === "all" ? undefined : statusFilter;
const { data: workOrders, isLoading, isError } = useWorkOrders(queryFilter);
const filteredWorkOrders = useMemo(() => {
if (!workOrders) return [];
return workOrders.filter((wo) => {
const matchesSearch =
searchQuery === "" ||
wo.repository_url.toLowerCase().includes(searchQuery.toLowerCase()) ||
wo.agent_work_order_id.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
});
}, [workOrders, searchQuery]);
if (isLoading) {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div
key={`skeleton-${
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton loading
i
}`}
className="h-40 bg-gray-800 bg-opacity-50 rounded-lg animate-pulse"
/>
))}
</div>
);
}
if (isError) {
return (
<div className="text-center py-12">
<p className="text-red-400">Failed to load work orders</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by repository or ID..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as AgentWorkOrderStatus | "all")}
className="w-full sm:w-auto px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{filteredWorkOrders.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400">{searchQuery ? "No work orders match your search" : "No work orders found"}</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredWorkOrders.map((workOrder) => (
<WorkOrderCard
key={workOrder.agent_work_order_id}
workOrder={workOrder}
onClick={() => onWorkOrderClick?.(workOrder.agent_work_order_id)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
/**
* WorkOrderProgressBar Component
*
* Displays visual progress of a work order through its workflow steps.
* Shows 5 steps with visual indicators for pending, running, success, and failed states.
*/
import type { StepExecutionResult, WorkflowStep } from "../types";
interface WorkOrderProgressBarProps {
/** Array of executed steps */
steps: StepExecutionResult[];
/** Current phase/step being executed */
currentPhase: string | null;
}
const WORKFLOW_STEPS: WorkflowStep[] = ["create-branch", "planning", "execute", "commit", "create-pr"];
const STEP_LABELS: Record<WorkflowStep, string> = {
"create-branch": "Create Branch",
planning: "Planning",
execute: "Execute",
commit: "Commit",
"create-pr": "Create PR",
"prp-review": "PRP Review",
};
export function WorkOrderProgressBar({ steps, currentPhase }: WorkOrderProgressBarProps) {
const getStepStatus = (stepName: WorkflowStep): "pending" | "running" | "success" | "failed" => {
const stepResult = steps.find((s) => s.step === stepName);
if (!stepResult) {
return currentPhase === stepName ? "running" : "pending";
}
return stepResult.success ? "success" : "failed";
};
const getStepStyles = (status: string): string => {
switch (status) {
case "success":
return "bg-green-500 border-green-400 text-white";
case "failed":
return "bg-red-500 border-red-400 text-white";
case "running":
return "bg-blue-500 border-blue-400 text-white animate-pulse";
default:
return "bg-gray-700 border-gray-600 text-gray-400";
}
};
const getConnectorStyles = (status: string): string => {
switch (status) {
case "success":
return "bg-green-500";
case "failed":
return "bg-red-500";
case "running":
return "bg-blue-500";
default:
return "bg-gray-700";
}
};
return (
<div className="w-full py-4">
<div className="flex items-center justify-between">
{WORKFLOW_STEPS.map((step, index) => {
const status = getStepStatus(step);
const isLast = index === WORKFLOW_STEPS.length - 1;
return (
<div key={step} className="flex items-center flex-1">
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full border-2 flex items-center justify-center font-semibold transition-all ${getStepStyles(status)}`}
>
{status === "success" ? (
<span></span>
) : status === "failed" ? (
<span></span>
) : status === "running" ? (
<span className="text-sm"></span>
) : (
<span className="text-xs">{index + 1}</span>
)}
</div>
<div className="mt-2 text-xs text-center text-gray-300 max-w-[80px]">{STEP_LABELS[step]}</div>
</div>
{!isLast && <div className={`flex-1 h-1 mx-2 transition-all ${getConnectorStyles(status)}`} />}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
/**
* Tests for Agent Work Order Query Hooks
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentWorkOrderKeys } from "../useAgentWorkOrderQueries";
vi.mock("../../services/agentWorkOrdersService", () => ({
agentWorkOrdersService: {
listWorkOrders: vi.fn(),
getWorkOrder: vi.fn(),
getStepHistory: vi.fn(),
createWorkOrder: vi.fn(),
},
}));
vi.mock("@/features/shared/config/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
realtime: 3_000,
frequent: 5_000,
normal: 30_000,
rare: 300_000,
static: Number.POSITIVE_INFINITY,
},
}));
vi.mock("@/features/shared/hooks/useSmartPolling", () => ({
useSmartPolling: vi.fn(() => 3000),
}));
describe("agentWorkOrderKeys", () => {
it("should generate correct query keys", () => {
expect(agentWorkOrderKeys.all).toEqual(["agent-work-orders"]);
expect(agentWorkOrderKeys.lists()).toEqual(["agent-work-orders", "list"]);
expect(agentWorkOrderKeys.list("running")).toEqual(["agent-work-orders", "list", "running"]);
expect(agentWorkOrderKeys.list(undefined)).toEqual(["agent-work-orders", "list", undefined]);
expect(agentWorkOrderKeys.details()).toEqual(["agent-work-orders", "detail"]);
expect(agentWorkOrderKeys.detail("wo-123")).toEqual(["agent-work-orders", "detail", "wo-123"]);
expect(agentWorkOrderKeys.stepHistory("wo-123")).toEqual(["agent-work-orders", "detail", "wo-123", "steps"]);
});
});
describe("useWorkOrders", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch work orders without filter", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrders } = await import("../useAgentWorkOrderQueries");
const mockWorkOrders = [
{
agent_work_order_id: "wo-1",
status: "running",
},
];
vi.mocked(agentWorkOrdersService.listWorkOrders).mockResolvedValue(mockWorkOrders as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrders(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.listWorkOrders).toHaveBeenCalledWith(undefined);
expect(result.current.data).toEqual(mockWorkOrders);
});
it("should fetch work orders with status filter", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrders } = await import("../useAgentWorkOrderQueries");
const mockWorkOrders = [
{
agent_work_order_id: "wo-1",
status: "completed",
},
];
vi.mocked(agentWorkOrdersService.listWorkOrders).mockResolvedValue(mockWorkOrders as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrders("completed"), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.listWorkOrders).toHaveBeenCalledWith("completed");
expect(result.current.data).toEqual(mockWorkOrders);
});
});
describe("useWorkOrder", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch single work order", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockWorkOrder = {
agent_work_order_id: "wo-123",
status: "running",
};
vi.mocked(agentWorkOrdersService.getWorkOrder).mockResolvedValue(mockWorkOrder as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrder("wo-123"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.getWorkOrder).toHaveBeenCalledWith("wo-123");
expect(result.current.data).toEqual(mockWorkOrder);
});
it("should not fetch when id is undefined", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrder } = await import("../useAgentWorkOrderQueries");
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrder(undefined), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(agentWorkOrdersService.getWorkOrder).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});
});
describe("useStepHistory", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch step history", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStepHistory } = await import("../useAgentWorkOrderQueries");
const mockHistory = {
agent_work_order_id: "wo-123",
steps: [
{
step: "create-branch",
success: true,
},
],
};
vi.mocked(agentWorkOrdersService.getStepHistory).mockResolvedValue(mockHistory as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStepHistory("wo-123"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.getStepHistory).toHaveBeenCalledWith("wo-123");
expect(result.current.data).toEqual(mockHistory);
});
it("should not fetch when workOrderId is undefined", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStepHistory } = await import("../useAgentWorkOrderQueries");
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStepHistory(undefined), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(agentWorkOrdersService.getStepHistory).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});
});
describe("useCreateWorkOrder", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
},
});
vi.clearAllMocks();
});
it("should create work order and invalidate queries", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useCreateWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch" as const,
user_request: "Test",
};
const mockCreated = {
agent_work_order_id: "wo-new",
...mockRequest,
status: "pending" as const,
};
vi.mocked(agentWorkOrdersService.createWorkOrder).mockResolvedValue(mockCreated as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useCreateWorkOrder(), { wrapper });
result.current.mutate(mockRequest);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.createWorkOrder).toHaveBeenCalledWith(mockRequest);
expect(result.current.data).toEqual(mockCreated);
});
});

View File

@@ -0,0 +1,120 @@
/**
* TanStack Query Hooks for Agent Work Orders
*
* This module provides React hooks for fetching and mutating agent work orders.
* Follows the pattern established in useProjectQueries.ts
*/
import { type UseQueryResult, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
import { useSmartPolling } from "@/features/shared/hooks/useSmartPolling";
import { agentWorkOrdersService } from "../services/agentWorkOrdersService";
import type { AgentWorkOrder, AgentWorkOrderStatus, CreateAgentWorkOrderRequest, StepHistory } from "../types";
/**
* Query key factory for agent work orders
* Provides consistent query keys for cache management
*/
export const agentWorkOrderKeys = {
all: ["agent-work-orders"] as const,
lists: () => [...agentWorkOrderKeys.all, "list"] as const,
list: (filter: AgentWorkOrderStatus | undefined) => [...agentWorkOrderKeys.lists(), filter] as const,
details: () => [...agentWorkOrderKeys.all, "detail"] as const,
detail: (id: string) => [...agentWorkOrderKeys.details(), id] as const,
stepHistory: (id: string) => [...agentWorkOrderKeys.detail(id), "steps"] as const,
};
/**
* Hook to fetch list of agent work orders, optionally filtered by status
*
* @param statusFilter - Optional status to filter work orders
* @returns Query result with work orders array
*/
export function useWorkOrders(statusFilter?: AgentWorkOrderStatus): UseQueryResult<AgentWorkOrder[], Error> {
return useQuery({
queryKey: agentWorkOrderKeys.list(statusFilter),
queryFn: () => agentWorkOrdersService.listWorkOrders(statusFilter),
staleTime: STALE_TIMES.frequent,
});
}
/**
* Hook to fetch a single agent work order with smart polling
* Automatically polls while work order is pending or running
*
* @param id - Work order ID (undefined disables query)
* @returns Query result with work order data
*/
export function useWorkOrder(id: string | undefined): UseQueryResult<AgentWorkOrder, Error> {
const refetchInterval = useSmartPolling({
baseInterval: 3000,
enabled: true,
});
return useQuery({
queryKey: id ? agentWorkOrderKeys.detail(id) : DISABLED_QUERY_KEY,
queryFn: () => (id ? agentWorkOrdersService.getWorkOrder(id) : Promise.reject(new Error("No ID provided"))),
enabled: !!id,
staleTime: STALE_TIMES.instant,
refetchInterval: (query) => {
const data = query.state.data as AgentWorkOrder | undefined;
if (data?.status === "running" || data?.status === "pending") {
return refetchInterval;
}
return false;
},
});
}
/**
* Hook to fetch step execution history for a work order with smart polling
* Automatically polls until workflow completes
*
* @param workOrderId - Work order ID (undefined disables query)
* @returns Query result with step history
*/
export function useStepHistory(workOrderId: string | undefined): UseQueryResult<StepHistory, Error> {
const refetchInterval = useSmartPolling({
baseInterval: 3000,
enabled: true,
});
return useQuery({
queryKey: workOrderId ? agentWorkOrderKeys.stepHistory(workOrderId) : DISABLED_QUERY_KEY,
queryFn: () =>
workOrderId ? agentWorkOrdersService.getStepHistory(workOrderId) : Promise.reject(new Error("No ID provided")),
enabled: !!workOrderId,
staleTime: STALE_TIMES.instant,
refetchInterval: (query) => {
const history = query.state.data as StepHistory | undefined;
const lastStep = history?.steps[history.steps.length - 1];
if (lastStep?.step === "create-pr" && lastStep?.success) {
return false;
}
return refetchInterval;
},
});
}
/**
* Hook to create a new agent work order
* Automatically invalidates work order lists on success
*
* @returns Mutation object with mutate function
*/
export function useCreateWorkOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: CreateAgentWorkOrderRequest) => agentWorkOrdersService.createWorkOrder(request),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: agentWorkOrderKeys.lists() });
queryClient.setQueryData(agentWorkOrderKeys.detail(data.agent_work_order_id), data);
},
onError: (error) => {
console.error("Failed to create work order:", error);
},
});
}

View File

@@ -0,0 +1,158 @@
/**
* Tests for Agent Work Orders Service
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as apiClient from "@/features/shared/api/apiClient";
import type { AgentWorkOrder, CreateAgentWorkOrderRequest, StepHistory } from "../../types";
import { agentWorkOrdersService } from "../agentWorkOrdersService";
vi.mock("@/features/shared/api/apiClient", () => ({
callAPIWithETag: vi.fn(),
}));
describe("agentWorkOrdersService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockWorkOrder: AgentWorkOrder = {
agent_work_order_id: "wo-123",
repository_url: "https://github.com/test/repo",
sandbox_identifier: "sandbox-abc",
git_branch_name: "feature/test",
agent_session_id: "session-xyz",
sandbox_type: "git_branch",
github_issue_number: null,
status: "running",
current_phase: "planning",
created_at: "2025-01-15T10:00:00Z",
updated_at: "2025-01-15T10:05:00Z",
github_pull_request_url: null,
git_commit_count: 0,
git_files_changed: 0,
error_message: null,
};
describe("createWorkOrder", () => {
it("should create a work order successfully", async () => {
const request: CreateAgentWorkOrderRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch",
user_request: "Add new feature",
};
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockWorkOrder);
const result = await agentWorkOrdersService.createWorkOrder(request);
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders", {
method: "POST",
body: JSON.stringify(request),
});
expect(result).toEqual(mockWorkOrder);
});
it("should throw error on creation failure", async () => {
const request: CreateAgentWorkOrderRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch",
user_request: "Add new feature",
};
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("Creation failed"));
await expect(agentWorkOrdersService.createWorkOrder(request)).rejects.toThrow("Creation failed");
});
});
describe("listWorkOrders", () => {
it("should list all work orders without filter", async () => {
const mockList: AgentWorkOrder[] = [mockWorkOrder];
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockList);
const result = await agentWorkOrdersService.listWorkOrders();
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders");
expect(result).toEqual(mockList);
});
it("should list work orders with status filter", async () => {
const mockList: AgentWorkOrder[] = [mockWorkOrder];
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockList);
const result = await agentWorkOrdersService.listWorkOrders("running");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders?status=running");
expect(result).toEqual(mockList);
});
it("should throw error on list failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("List failed"));
await expect(agentWorkOrdersService.listWorkOrders()).rejects.toThrow("List failed");
});
});
describe("getWorkOrder", () => {
it("should get a work order by ID", async () => {
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockWorkOrder);
const result = await agentWorkOrdersService.getWorkOrder("wo-123");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/wo-123");
expect(result).toEqual(mockWorkOrder);
});
it("should throw error on get failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("Not found"));
await expect(agentWorkOrdersService.getWorkOrder("wo-123")).rejects.toThrow("Not found");
});
});
describe("getStepHistory", () => {
it("should get step history for a work order", async () => {
const mockHistory: StepHistory = {
agent_work_order_id: "wo-123",
steps: [
{
step: "create-branch",
agent_name: "Branch Agent",
success: true,
output: "Branch created",
error_message: null,
duration_seconds: 5,
session_id: "session-1",
timestamp: "2025-01-15T10:00:00Z",
},
{
step: "planning",
agent_name: "Planning Agent",
success: true,
output: "Plan created",
error_message: null,
duration_seconds: 30,
session_id: "session-2",
timestamp: "2025-01-15T10:01:00Z",
},
],
};
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockHistory);
const result = await agentWorkOrdersService.getStepHistory("wo-123");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/wo-123/steps");
expect(result).toEqual(mockHistory);
});
it("should throw error on step history failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("History failed"));
await expect(agentWorkOrdersService.getStepHistory("wo-123")).rejects.toThrow("History failed");
});
});
});

View File

@@ -0,0 +1,59 @@
/**
* Agent Work Orders API Service
*
* This service handles all API communication for agent work orders.
* It follows the pattern established in projectService.ts
*/
import { callAPIWithETag } from "@/features/shared/api/apiClient";
import type { AgentWorkOrder, AgentWorkOrderStatus, CreateAgentWorkOrderRequest, StepHistory } from "../types";
export const agentWorkOrdersService = {
/**
* Create a new agent work order
*
* @param request - The work order creation request
* @returns Promise resolving to the created work order
* @throws Error if creation fails
*/
async createWorkOrder(request: CreateAgentWorkOrderRequest): Promise<AgentWorkOrder> {
return await callAPIWithETag<AgentWorkOrder>("/api/agent-work-orders/", {
method: "POST",
body: JSON.stringify(request),
});
},
/**
* List all agent work orders, optionally filtered by status
*
* @param statusFilter - Optional status to filter by
* @returns Promise resolving to array of work orders
* @throws Error if request fails
*/
async listWorkOrders(statusFilter?: AgentWorkOrderStatus): Promise<AgentWorkOrder[]> {
const params = statusFilter ? `?status=${statusFilter}` : "";
return await callAPIWithETag<AgentWorkOrder[]>(`/api/agent-work-orders/${params}`);
},
/**
* Get a single agent work order by ID
*
* @param id - The work order ID
* @returns Promise resolving to the work order
* @throws Error if work order not found or request fails
*/
async getWorkOrder(id: string): Promise<AgentWorkOrder> {
return await callAPIWithETag<AgentWorkOrder>(`/api/agent-work-orders/${id}`);
},
/**
* Get the complete step execution history for a work order
*
* @param id - The work order ID
* @returns Promise resolving to the step history
* @throws Error if work order not found or request fails
*/
async getStepHistory(id: string): Promise<StepHistory> {
return await callAPIWithETag<StepHistory>(`/api/agent-work-orders/${id}/steps`);
},
};

View File

@@ -0,0 +1,139 @@
/**
* Agent Work Orders Type Definitions
*
* This module defines TypeScript interfaces and types for the Agent Work Orders feature.
* These types mirror the backend models from python/src/agent_work_orders/models.py
*/
/**
* Status of an agent work order
* - pending: Work order created but not started
* - running: Work order is currently executing
* - completed: Work order finished successfully
* - failed: Work order encountered an error
*/
export type AgentWorkOrderStatus = "pending" | "running" | "completed" | "failed";
/**
* Available workflow steps for agent work orders
* Each step represents a command that can be executed
*/
export type WorkflowStep = "create-branch" | "planning" | "execute" | "commit" | "create-pr" | "prp-review";
/**
* Type of git sandbox for work order execution
* - git_branch: Uses standard git branches
* - git_worktree: Uses git worktree for isolation
*/
export type SandboxType = "git_branch" | "git_worktree";
/**
* Agent Work Order entity
* Represents a complete AI-driven development workflow
*/
export interface AgentWorkOrder {
/** Unique identifier for the work order */
agent_work_order_id: string;
/** URL of the git repository to work on */
repository_url: string;
/** Unique identifier for the sandbox instance */
sandbox_identifier: string;
/** Name of the git branch created for this work order (null if not yet created) */
git_branch_name: string | null;
/** ID of the agent session executing this work order (null if not started) */
agent_session_id: string | null;
/** Type of sandbox being used */
sandbox_type: SandboxType;
/** GitHub issue number associated with this work order (optional) */
github_issue_number: string | null;
/** Current status of the work order */
status: AgentWorkOrderStatus;
/** Current workflow phase/step being executed (null if not started) */
current_phase: string | null;
/** Timestamp when work order was created */
created_at: string;
/** Timestamp when work order was last updated */
updated_at: string;
/** URL of the created pull request (null if not yet created) */
github_pull_request_url: string | null;
/** Number of commits made during execution */
git_commit_count: number;
/** Number of files changed during execution */
git_files_changed: number;
/** Error message if work order failed (null if successful or still running) */
error_message: string | null;
}
/**
* Request payload for creating a new agent work order
*/
export interface CreateAgentWorkOrderRequest {
/** URL of the git repository to work on */
repository_url: string;
/** Type of sandbox to use for execution */
sandbox_type: SandboxType;
/** User's natural language request describing the work to be done */
user_request: string;
/** Optional array of specific commands to execute (defaults to all if not provided) */
selected_commands?: WorkflowStep[];
/** Optional GitHub issue number to associate with this work order */
github_issue_number?: string | null;
}
/**
* Result of a single step execution within a workflow
*/
export interface StepExecutionResult {
/** The workflow step that was executed */
step: WorkflowStep;
/** Name of the agent that executed this step */
agent_name: string;
/** Whether the step completed successfully */
success: boolean;
/** Output/result from the step execution (null if no output) */
output: string | null;
/** Error message if step failed (null if successful) */
error_message: string | null;
/** How long the step took to execute (in seconds) */
duration_seconds: number;
/** Agent session ID for this step execution (null if not tracked) */
session_id: string | null;
/** Timestamp when step was executed */
timestamp: string;
}
/**
* Complete history of all steps executed for a work order
*/
export interface StepHistory {
/** The work order ID this history belongs to */
agent_work_order_id: string;
/** Array of all executed steps in chronological order */
steps: StepExecutionResult[];
}

View File

@@ -0,0 +1,45 @@
/**
* AgentWorkOrdersView Component
*
* Main view for displaying and managing agent work orders.
* Combines the work order list with create dialog.
*/
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/features/ui/primitives/button";
import { CreateWorkOrderDialog } from "../components/CreateWorkOrderDialog";
import { WorkOrderList } from "../components/WorkOrderList";
export function AgentWorkOrdersView() {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const navigate = useNavigate();
const handleWorkOrderClick = (workOrderId: string) => {
navigate(`/agent-work-orders/${workOrderId}`);
};
const handleCreateSuccess = (workOrderId: string) => {
navigate(`/agent-work-orders/${workOrderId}`);
};
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Agent Work Orders</h1>
<p className="text-gray-400">Create and monitor AI-driven development workflows</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>Create Work Order</Button>
</div>
<WorkOrderList onWorkOrderClick={handleWorkOrderClick} />
<CreateWorkOrderDialog
open={isCreateDialogOpen}
onClose={() => setIsCreateDialogOpen(false)}
onSuccess={handleCreateSuccess}
/>
</div>
);
}

View File

@@ -0,0 +1,188 @@
/**
* WorkOrderDetailView Component
*
* Detailed view of a single agent work order showing progress, step history,
* and full metadata.
*/
import { formatDistanceToNow } from "date-fns";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "@/features/ui/primitives/button";
import { StepHistoryTimeline } from "../components/StepHistoryTimeline";
import { WorkOrderProgressBar } from "../components/WorkOrderProgressBar";
import { useStepHistory, useWorkOrder } from "../hooks/useAgentWorkOrderQueries";
export function WorkOrderDetailView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: workOrder, isLoading: isLoadingWorkOrder, isError: isErrorWorkOrder } = useWorkOrder(id);
const { data: stepHistory, isLoading: isLoadingSteps, isError: isErrorSteps } = useStepHistory(id);
if (isLoadingWorkOrder || isLoadingSteps) {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-800 rounded w-1/3" />
<div className="h-40 bg-gray-800 rounded" />
<div className="h-60 bg-gray-800 rounded" />
</div>
</div>
);
}
if (isErrorWorkOrder || isErrorSteps || !workOrder || !stepHistory) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<p className="text-red-400 mb-4">Failed to load work order</p>
<Button onClick={() => navigate("/agent-work-orders")}>Back to List</Button>
</div>
</div>
);
}
// Extract repository name from URL with fallback
const repoName = workOrder.repository_url
? workOrder.repository_url.split("/").slice(-2).join("/")
: "Unknown Repository";
const timeAgo = formatDistanceToNow(new Date(workOrder.created_at), {
addSuffix: true,
});
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate("/agent-work-orders")} className="mb-4">
Back to List
</Button>
<h1 className="text-3xl font-bold text-white mb-2">{repoName}</h1>
<p className="text-gray-400">Created {timeAgo}</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-white mb-4">Workflow Progress</h2>
<WorkOrderProgressBar steps={stepHistory.steps} currentPhase={workOrder.current_phase} />
</div>
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-white mb-4">Step History</h2>
<StepHistoryTimeline steps={stepHistory.steps} currentPhase={workOrder.current_phase} />
</div>
</div>
<div className="space-y-6">
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-white mb-4">Details</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-400">Status</p>
<p
className={`text-lg font-semibold ${
workOrder.status === "completed"
? "text-green-400"
: workOrder.status === "failed"
? "text-red-400"
: workOrder.status === "running"
? "text-blue-400"
: "text-gray-400"
}`}
>
{workOrder.status.charAt(0).toUpperCase() + workOrder.status.slice(1)}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Sandbox Type</p>
<p className="text-white">{workOrder.sandbox_type}</p>
</div>
<div>
<p className="text-sm text-gray-400">Repository</p>
<a
href={workOrder.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline break-all"
>
{workOrder.repository_url}
</a>
</div>
{workOrder.git_branch_name && (
<div>
<p className="text-sm text-gray-400">Branch</p>
<p className="text-white font-mono text-sm">{workOrder.git_branch_name}</p>
</div>
)}
{workOrder.github_pull_request_url && (
<div>
<p className="text-sm text-gray-400">Pull Request</p>
<a
href={workOrder.github_pull_request_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline break-all"
>
View PR
</a>
</div>
)}
{workOrder.github_issue_number && (
<div>
<p className="text-sm text-gray-400">GitHub Issue</p>
<p className="text-white">#{workOrder.github_issue_number}</p>
</div>
)}
<div>
<p className="text-sm text-gray-400">Work Order ID</p>
<p className="text-white font-mono text-xs break-all">{workOrder.agent_work_order_id}</p>
</div>
{workOrder.agent_session_id && (
<div>
<p className="text-sm text-gray-400">Session ID</p>
<p className="text-white font-mono text-xs break-all">{workOrder.agent_session_id}</p>
</div>
)}
</div>
</div>
{workOrder.error_message && (
<div className="bg-red-900 bg-opacity-30 border border-red-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-red-300 mb-4">Error</h2>
<p className="text-sm text-red-300 font-mono whitespace-pre-wrap">{workOrder.error_message}</p>
</div>
)}
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-white mb-4">Statistics</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-400">Commits</p>
<p className="text-white text-lg font-semibold">{workOrder.git_commit_count}</p>
</div>
<div>
<p className="text-sm text-gray-400">Files Changed</p>
<p className="text-white text-lg font-semibold">{workOrder.git_files_changed}</p>
</div>
<div>
<p className="text-sm text-gray-400">Steps Completed</p>
<p className="text-white text-lg font-semibold">
{stepHistory.steps.filter((s) => s.success).length} / {stepHistory.steps.length}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
/**
* AgentWorkOrderDetailPage Component
*
* Route wrapper for the agent work order detail view.
* Delegates to WorkOrderDetailView for actual implementation.
*/
import { WorkOrderDetailView } from "@/features/agent-work-orders/views/WorkOrderDetailView";
function AgentWorkOrderDetailPage() {
return <WorkOrderDetailView />;
}
export { AgentWorkOrderDetailPage };

View File

@@ -0,0 +1,14 @@
/**
* AgentWorkOrdersPage Component
*
* Route wrapper for the agent work orders feature.
* Delegates to AgentWorkOrdersView for actual implementation.
*/
import { AgentWorkOrdersView } from "@/features/agent-work-orders/views/AgentWorkOrdersView";
function AgentWorkOrdersPage() {
return <AgentWorkOrdersView />;
}
export { AgentWorkOrdersPage };

View File

@@ -48,7 +48,7 @@ orchestrator = WorkflowOrchestrator(
)
@router.post("/agent-work-orders", status_code=201)
@router.post("/", status_code=201)
async def create_agent_work_order(
request: CreateAgentWorkOrderRequest,
) -> AgentWorkOrderResponse:
@@ -121,7 +121,7 @@ async def create_agent_work_order(
raise HTTPException(status_code=500, detail=f"Failed to create work order: {e}") from e
@router.get("/agent-work-orders/{agent_work_order_id}")
@router.get("/{agent_work_order_id}")
async def get_agent_work_order(agent_work_order_id: str) -> AgentWorkOrder:
"""Get agent work order by ID"""
logger.info("agent_work_order_get_started", agent_work_order_id=agent_work_order_id)
@@ -167,7 +167,7 @@ async def get_agent_work_order(agent_work_order_id: str) -> AgentWorkOrder:
raise HTTPException(status_code=500, detail=f"Failed to get work order: {e}") from e
@router.get("/agent-work-orders")
@router.get("/")
async def list_agent_work_orders(
status: AgentWorkOrderStatus | None = None,
) -> list[AgentWorkOrder]:
@@ -210,7 +210,7 @@ async def list_agent_work_orders(
raise HTTPException(status_code=500, detail=f"Failed to list work orders: {e}") from e
@router.post("/agent-work-orders/{agent_work_order_id}/prompt")
@router.post("/{agent_work_order_id}/prompt")
async def send_prompt_to_agent(
agent_work_order_id: str,
request: AgentPromptRequest,
@@ -235,7 +235,7 @@ async def send_prompt_to_agent(
}
@router.get("/agent-work-orders/{agent_work_order_id}/git-progress")
@router.get("/{agent_work_order_id}/git-progress")
async def get_git_progress(agent_work_order_id: str) -> GitProgressSnapshot:
"""Get git progress for a work order"""
logger.info("git_progress_get_started", agent_work_order_id=agent_work_order_id)
@@ -283,7 +283,7 @@ async def get_git_progress(agent_work_order_id: str) -> GitProgressSnapshot:
raise HTTPException(status_code=500, detail=f"Failed to get git progress: {e}") from e
@router.get("/agent-work-orders/{agent_work_order_id}/logs")
@router.get("/{agent_work_order_id}/logs")
async def get_agent_work_order_logs(
agent_work_order_id: str,
limit: int = 100,
@@ -311,7 +311,7 @@ async def get_agent_work_order_logs(
}
@router.get("/agent-work-orders/{agent_work_order_id}/steps")
@router.get("/{agent_work_order_id}/steps")
async def get_agent_work_order_steps(agent_work_order_id: str) -> StepHistory:
"""Get step execution history for a work order