From dd62dbe71a6118eb459c154ebf827a60ca856fd0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 8 Jun 2026 00:37:24 -0400 Subject: [PATCH 01/35] feat(examples/02-shipwright): autonomous code-agent demo with shadcn UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end demo of the Shipwright pattern (customer pastes a GitHub issue → agent reads repo, plans, edits, tests, opens PR). Built on standard shadcn components with a stub agent so iteration on the UX is fast; swap in the real SWE-AF library by replacing one file. ## Backend (examples/02-shipwright/) - `migrations/00001_shipwright.sql` — `shipwright_tasks` + `shipwright_steps` with RLS policies; auto-touch trigger on `updated_at`. - `agents/shipwright/` — AgentField agent. - `Agent(node_id="shipwright-v2")` + `@app.reasoner execute_task` - Stub simulates 7 steps × asyncio.sleep totaling ~10s with deterministic output (status / summary / diff_preview / steps). - `__capabilities__` reasoner with harness + MCP runner probe. - Dockerfile mirrors the sample-agent pattern (NODE_ID + PORT + AGENT_CALLBACK_URL env). - `handlers/` — Python FastAPI workload-module sidecar. - Routes: POST /tasks, GET /tasks, GET /tasks/{id}, POST /tasks/{id}/cancel, GET /stats. - Tenant-bound DB connection via `SET LOCAL app.tenant_id`. - `_drive_task` runs in `asyncio.create_task` so POST returns immediately; persists task row + 7 step rows on completion. - `docker-compose.yml` — compose overlay with shipwright-migrate, shipwright-agent, shipwright-api services. ## Frontend (apps/customer-app/) - `(app)/shipwright/page.tsx` — list page with queue table + new-task form. shadcn Card / Table / Badge / Field / Input / Textarea / Button. Polls /tasks every 1.5s; status badges color-coded (queued / running / completed / failed / cancelled); durations + relative timestamps render live. - `(app)/shipwright/[id]/page.tsx` — detail with step timeline, summary card (duration / steps / status), monospace diff preview. Live-polls every 1s until terminal state. - `api/customer/shipwright/[...path]/route.ts` — proxy to the workload-module sidecar; reads session via `auth.api.getSession`, resolves `lookupCustomerContext(email)` → forwards `x-af-stack-tenant-id` + `x-af-stack-user-id` headers. - `components/layout/customer-sidebar.tsx` — replaced "Code Helper" nav entry with "Shipwright" (Hammer icon). ## Verified E2E Captured screenshots in `dashboard-screenshots/`: - shipwright-with-sidebar.png — sidebar nav, Shipwright active - shipwright-form-filled.png — new-task form filled - shipwright-queue-mixed.png — list showing Running + Completed - shipwright-detail-completed.png — full task detail with steps, summary, diff preview - shipwright-agent-logs.txt — agent log lines from the 3 runs ## Gotchas baked into the README + code comments 1. Don't name a reasoner `run` — collides with `Agent.run()` (the decorator overrides the method; agent exits immediately). 2. AgentField caches reasoner metadata keyed on node_id. After renaming a reasoner, bump the node_id to force fresh registration. 3. Reasoner payload shape: `{"input": {"payload": {...}}}` for signatures like `async def f(payload: dict)`. 4. Result shape: runtime returns `{"result": {...}}`, not `output`. ## Swapping in real SWE-AF Replace `agents/shipwright/main.py` with the real SWE-AF agent. Keep the node_id + reasoner name + RunResult schema. Everything else (UI, workload module, DB, polling) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shipwright/[id]/_task-detail-view.tsx | 357 +++++++++++ .../src/app/(app)/shipwright/[id]/page.tsx | 20 + .../_components/shipwright-view.tsx | 311 ++++++++++ .../src/app/(app)/shipwright/page.tsx | 29 + .../customer/shipwright/[...path]/route.ts | 85 +++ .../components/layout/customer-sidebar.tsx | 4 +- .../shipwright-agent-logs.txt | 20 + .../shipwright-detail-completed.png | Bin 0 -> 329655 bytes .../shipwright-detail-running.png | Bin 0 -> 189333 bytes dashboard-screenshots/shipwright-empty.png | Bin 0 -> 150025 bytes .../shipwright-form-filled.png | Bin 0 -> 170288 bytes .../shipwright-queue-mixed.png | Bin 0 -> 176142 bytes .../shipwright-queue-running.png | Bin 0 -> 165830 bytes .../shipwright-with-sidebar.png | Bin 0 -> 108771 bytes examples/02-shipwright/README.md | 186 +++--- .../agents/shipwright/Dockerfile | 25 + .../02-shipwright/agents/shipwright/main.py | 567 ++++-------------- .../agents/shipwright/requirements.txt | 6 +- examples/02-shipwright/docker-compose.yml | 83 +-- examples/02-shipwright/handlers/Dockerfile | 17 + examples/02-shipwright/handlers/handler.py | 357 +++++++++++ .../02-shipwright/handlers/requirements.txt | 5 + .../migrations/00001_shipwright.sql | 86 +++ 23 files changed, 1578 insertions(+), 580 deletions(-) create mode 100644 apps/customer-app/src/app/(app)/shipwright/[id]/_task-detail-view.tsx create mode 100644 apps/customer-app/src/app/(app)/shipwright/[id]/page.tsx create mode 100644 apps/customer-app/src/app/(app)/shipwright/_components/shipwright-view.tsx create mode 100644 apps/customer-app/src/app/(app)/shipwright/page.tsx create mode 100644 apps/customer-app/src/app/api/customer/shipwright/[...path]/route.ts create mode 100644 dashboard-screenshots/shipwright-agent-logs.txt create mode 100644 dashboard-screenshots/shipwright-detail-completed.png create mode 100644 dashboard-screenshots/shipwright-detail-running.png create mode 100644 dashboard-screenshots/shipwright-empty.png create mode 100644 dashboard-screenshots/shipwright-form-filled.png create mode 100644 dashboard-screenshots/shipwright-queue-mixed.png create mode 100644 dashboard-screenshots/shipwright-queue-running.png create mode 100644 dashboard-screenshots/shipwright-with-sidebar.png create mode 100644 examples/02-shipwright/agents/shipwright/Dockerfile create mode 100644 examples/02-shipwright/handlers/Dockerfile create mode 100644 examples/02-shipwright/handlers/handler.py create mode 100644 examples/02-shipwright/handlers/requirements.txt create mode 100644 examples/02-shipwright/migrations/00001_shipwright.sql diff --git a/apps/customer-app/src/app/(app)/shipwright/[id]/_task-detail-view.tsx b/apps/customer-app/src/app/(app)/shipwright/[id]/_task-detail-view.tsx new file mode 100644 index 00000000..a1c1742e --- /dev/null +++ b/apps/customer-app/src/app/(app)/shipwright/[id]/_task-detail-view.tsx @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: Apache-2.0 + +"use client" + +import { useCallback, useEffect, useState } from "react" + +import Link from "next/link" +import { + AlertCircle, + ArrowLeft, + CheckCircle2, + Clock, + ExternalLink, + Hammer, + Loader2, + XCircle, +} from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Skeleton } from "@/components/ui/skeleton" + +type Step = { + idx: number + title: string + status: "pending" | "running" | "completed" | "failed" + detail: string | null + started_at: string | null + finished_at: string | null +} + +type TaskDetail = { + id: string + title: string + issue_url: string + description: string + status: "queued" | "running" | "completed" | "failed" | "cancelled" + result_summary: string | null + diff_preview: string | null + error: string | null + created_at: string + started_at: string | null + finished_at: string | null + steps: Step[] +} + +const POLL_MS = 1000 + +function StatusBadge({ status }: { status: TaskDetail["status"] }) { + if (status === "queued") { + return ( + + + Queued + + ) + } + if (status === "running") { + return ( + + + Running + + ) + } + if (status === "completed") { + return ( + + + Completed + + ) + } + if (status === "failed") { + return ( + + + Failed + + ) + } + return ( + + + Cancelled + + ) +} + +function StepIcon({ status }: { status: Step["status"] }) { + if (status === "running") + return + if (status === "completed") + return + if (status === "failed") return + return +} + +function duration(task: TaskDetail): string { + if (!task.started_at) return "—" + const end = task.finished_at ? new Date(task.finished_at).getTime() : Date.now() + const ms = Math.max(0, end - new Date(task.started_at).getTime()) + if (ms < 1000) return `${ms}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${(ms / 60_000).toFixed(1)}m` +} + +export function TaskDetailView({ taskId }: { taskId: string }) { + const [task, setTask] = useState(null) + const [error, setError] = useState(null) + const [cancelling, setCancelling] = useState(false) + + const refresh = useCallback(async () => { + try { + const res = await fetch(`/api/customer/shipwright/tasks/${taskId}`, { + cache: "no-store", + }) + if (res.status === 404) { + setError("Task not found") + return + } + if (!res.ok) { + setError(`Server returned ${res.status}`) + return + } + const data = (await res.json()) as TaskDetail + setTask(data) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : "Network error") + } + }, [taskId]) + + useEffect(() => { + refresh() + const id = setInterval(() => { + // Stop polling once we reach a terminal state. + setTask((current) => { + const done = + current?.status === "completed" || + current?.status === "failed" || + current?.status === "cancelled" + if (!done) { + refresh() + } + return current + }) + }, POLL_MS) + return () => clearInterval(id) + }, [refresh]) + + const cancelTask = useCallback(async () => { + setCancelling(true) + try { + const res = await fetch(`/api/customer/shipwright/tasks/${taskId}/cancel`, { + method: "POST", + }) + if (res.ok) refresh() + } finally { + setCancelling(false) + } + }, [taskId, refresh]) + + if (error) { + return ( + + + + {error} +
+ + + +
+
+
+ ) + } + + if (!task) { + return + } + + const canCancel = task.status === "queued" || task.status === "running" + + return ( +
+
+ + + +
+ + {canCancel ? ( + + ) : null} +
+
+ + + +
+ + Shipwright task +
+ + {task.title} + + + + {task.issue_url} + +
+ {task.description ? ( + +

+ {task.description} +

+
+ ) : null} +
+ + + + Progress + + + {task.steps.length === 0 ? ( +
+ {task.status === "queued" ? ( + <> + + Waiting for an agent to pick this up… + + ) : ( + <> + + Agent is working — first step coming soon. + + )} +
+ ) : ( +
    + {task.steps.map((step, i) => ( +
  1. +
    + + {i < task.steps.length - 1 ? ( +
    + ) : null} +
    +
    +
    +

    + {step.title} +

    + + Step {step.idx} + +
    + {step.detail ? ( +

    + {step.detail} +

    + ) : null} +
    +
  2. + ))} +
+ )} +
+
+ + {task.result_summary ? ( + + + Summary + + +

{task.result_summary}

+ +
+
+
Duration
+
+ {duration(task)} +
+
+
+
Steps
+
+ {task.steps.length} +
+
+
+
Status
+
{task.status}
+
+
+
+
+ ) : null} + + {task.diff_preview ? ( + + + Diff preview + + + +
+                {task.diff_preview}
+              
+
+
+
+ ) : null} + + {task.error ? ( + + + + Error + + + +
+              {task.error}
+            
+
+
+ ) : null} +
+ ) +} diff --git a/apps/customer-app/src/app/(app)/shipwright/[id]/page.tsx b/apps/customer-app/src/app/(app)/shipwright/[id]/page.tsx new file mode 100644 index 00000000..ad118299 --- /dev/null +++ b/apps/customer-app/src/app/(app)/shipwright/[id]/page.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { Suspense } from "react" +import { TaskDetailView } from "./_task-detail-view" +import { Skeleton } from "@/components/ui/skeleton" + +export default async function ShipwrightDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + return ( +
+ }> + + +
+ ) +} diff --git a/apps/customer-app/src/app/(app)/shipwright/_components/shipwright-view.tsx b/apps/customer-app/src/app/(app)/shipwright/_components/shipwright-view.tsx new file mode 100644 index 00000000..7972c259 --- /dev/null +++ b/apps/customer-app/src/app/(app)/shipwright/_components/shipwright-view.tsx @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: Apache-2.0 + +"use client" + +import { useCallback, useEffect, useState } from "react" + +import Link from "next/link" +import { + AlertCircle, + CheckCircle2, + Clock, + Loader2, + Plus, + XCircle, +} from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Textarea } from "@/components/ui/textarea" +import { toast } from "sonner" + +type TaskSummary = { + id: string + title: string + issue_url: string + status: "queued" | "running" | "completed" | "failed" | "cancelled" + created_at: string + started_at: string | null + finished_at: string | null +} + +const POLL_MS = 1500 +const STATUS_LABEL: Record = { + queued: "Queued", + running: "Running", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", +} + +function StatusBadge({ status }: { status: TaskSummary["status"] }) { + if (status === "queued") { + return ( + + + {STATUS_LABEL[status]} + + ) + } + if (status === "running") { + return ( + + + {STATUS_LABEL[status]} + + ) + } + if (status === "completed") { + return ( + + + {STATUS_LABEL[status]} + + ) + } + if (status === "failed") { + return ( + + + {STATUS_LABEL[status]} + + ) + } + return ( + + + {STATUS_LABEL[status]} + + ) +} + +function formatRelative(when: string | null | undefined): string { + if (!when) return "—" + const d = new Date(when) + const diff = Date.now() - d.getTime() + if (diff < 5_000) return "just now" + if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago` + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago` + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago` + return d.toLocaleDateString() +} + +function durationMs(task: TaskSummary): string { + if (!task.started_at) return "—" + const end = task.finished_at ? new Date(task.finished_at).getTime() : Date.now() + const ms = Math.max(0, end - new Date(task.started_at).getTime()) + if (ms < 1000) return `${ms}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${(ms / 60_000).toFixed(1)}m` +} + +export function ShipwrightView() { + const [tasks, setTasks] = useState(null) + const [creating, setCreating] = useState(false) + const [issueUrl, setIssueUrl] = useState("") + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + + const refresh = useCallback(async () => { + try { + const res = await fetch("/api/customer/shipwright/tasks", { + cache: "no-store", + }) + if (!res.ok) return + const data = (await res.json()) as TaskSummary[] + setTasks(data) + } catch { + // swallow; next tick will retry + } + }, []) + + useEffect(() => { + refresh() + const id = setInterval(refresh, POLL_MS) + return () => clearInterval(id) + }, [refresh]) + + const submit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!issueUrl.trim() || !title.trim()) return + setCreating(true) + try { + const res = await fetch("/api/customer/shipwright/tasks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + issue_url: issueUrl.trim(), + title: title.trim(), + description: description.trim(), + }), + }) + if (!res.ok) { + const detail = await res.text() + toast.error("Could not queue task", { description: detail.slice(0, 120) }) + return + } + setIssueUrl("") + setTitle("") + setDescription("") + toast.success("Task queued") + refresh() + } finally { + setCreating(false) + } + }, + [issueUrl, title, description, refresh], + ) + + return ( +
+ + + + Queued tasks + {tasks ? ( + + {tasks.length} + + ) : null} + + + + {!tasks ? ( +
+ + + +
+ ) : tasks.length === 0 ? ( +
+ No tasks yet. Submit a GitHub issue on the right to get started. +
+ ) : ( + + + + Title + Status + Duration + Created + Open + + + + {tasks.map((t) => ( + + + + {t.title} + + + + + + + {durationMs(t)} + + + {formatRelative(t.created_at)} + + + + + + + + ))} + +
+ )} +
+
+ + + + New task + + +
+ + + GitHub issue URL + setIssueUrl(e.target.value)} + required + /> + + Public issue, or one in a repo your token can read. + + + + Title + setTitle(e.target.value)} + required + maxLength={200} + /> + + + Notes (optional) +