mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
|
},
|
||||||
|
};
|
||||||
139
archon-ui-main/src/features/agent-work-orders/types/index.ts
Normal file
139
archon-ui-main/src/features/agent-work-orders/types/index.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
archon-ui-main/src/pages/AgentWorkOrderDetailPage.tsx
Normal file
14
archon-ui-main/src/pages/AgentWorkOrderDetailPage.tsx
Normal 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 };
|
||||||
14
archon-ui-main/src/pages/AgentWorkOrdersPage.tsx
Normal file
14
archon-ui-main/src/pages/AgentWorkOrdersPage.tsx
Normal 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 };
|
||||||
@@ -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(
|
async def create_agent_work_order(
|
||||||
request: CreateAgentWorkOrderRequest,
|
request: CreateAgentWorkOrderRequest,
|
||||||
) -> AgentWorkOrderResponse:
|
) -> 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
|
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:
|
async def get_agent_work_order(agent_work_order_id: str) -> AgentWorkOrder:
|
||||||
"""Get agent work order by ID"""
|
"""Get agent work order by ID"""
|
||||||
logger.info("agent_work_order_get_started", agent_work_order_id=agent_work_order_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
|
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(
|
async def list_agent_work_orders(
|
||||||
status: AgentWorkOrderStatus | None = None,
|
status: AgentWorkOrderStatus | None = None,
|
||||||
) -> list[AgentWorkOrder]:
|
) -> 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
|
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(
|
async def send_prompt_to_agent(
|
||||||
agent_work_order_id: str,
|
agent_work_order_id: str,
|
||||||
request: AgentPromptRequest,
|
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:
|
async def get_git_progress(agent_work_order_id: str) -> GitProgressSnapshot:
|
||||||
"""Get git progress for a work order"""
|
"""Get git progress for a work order"""
|
||||||
logger.info("git_progress_get_started", agent_work_order_id=agent_work_order_id)
|
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
|
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(
|
async def get_agent_work_order_logs(
|
||||||
agent_work_order_id: str,
|
agent_work_order_id: str,
|
||||||
limit: int = 100,
|
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:
|
async def get_agent_work_order_steps(agent_work_order_id: str) -> StepHistory:
|
||||||
"""Get step execution history for a work order
|
"""Get step execution history for a work order
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user