diff --git a/data/workflows.json b/data/workflows.json new file mode 100644 index 00000000..6d87952f --- /dev/null +++ b/data/workflows.json @@ -0,0 +1,69 @@ +[ + { + "id": "wf-oncall-triage", + "name": "On-call Alert Triage", + "description": "Ingest alerts, score severity, and assign to the correct owner.", + "enabled": true, + "createdAt": "2026-02-01T08:00:00.000Z", + "updatedAt": "2026-02-01T08:00:00.000Z", + "steps": [ + { "id": "step-1", "name": "Collect incoming alerts", "description": "Read PagerDuty and Slack alert feeds" }, + { "id": "step-2", "name": "Classify severity", "description": "Apply severity rules and confidence score" }, + { "id": "step-3", "name": "Route to service owner", "description": "Assign incident to the owning squad" }, + { "id": "step-4", "name": "Post timeline update", "description": "Publish initial status update in incident channel" } + ] + }, + { + "id": "wf-content-ops", + "name": "Content Ops Publishing", + "description": "Turn approved drafts into scheduled posts across channels.", + "enabled": true, + "createdAt": "2026-02-02T09:30:00.000Z", + "updatedAt": "2026-02-02T09:30:00.000Z", + "steps": [ + { "id": "step-1", "name": "Pull approved drafts", "description": "Fetch approved content from Notion" }, + { "id": "step-2", "name": "Generate social snippets", "description": "Create channel-specific excerpts" }, + { "id": "step-3", "name": "Schedule posts", "description": "Queue publication in social scheduler" } + ] + }, + { + "id": "wf-release-checklist", + "name": "Release Checklist Automation", + "description": "Automate pre-release quality checks and release notes generation.", + "enabled": false, + "createdAt": "2026-02-03T12:15:00.000Z", + "updatedAt": "2026-02-03T12:15:00.000Z", + "steps": [ + { "id": "step-1", "name": "Run CI test suite", "description": "Execute unit and integration tests" }, + { "id": "step-2", "name": "Summarize changes", "description": "Aggregate merged PR highlights" }, + { "id": "step-3", "name": "Draft release notes", "description": "Generate release notes markdown" }, + { "id": "step-4", "name": "Create release ticket", "description": "Open release ticket with checklist" }, + { "id": "step-5", "name": "Notify launch channel", "description": "Share release candidate status" } + ] + }, + { + "id": "wf-customer-escalation", + "name": "Customer Escalation Flow", + "description": "Detect high-risk tickets and escalate them to response leads.", + "enabled": true, + "createdAt": "2026-02-04T14:00:00.000Z", + "updatedAt": "2026-02-04T14:00:00.000Z", + "steps": [ + { "id": "step-1", "name": "Watch support inbox", "description": "Poll incoming support tickets" }, + { "id": "step-2", "name": "Identify risk signals", "description": "Flag churn and outage keywords" }, + { "id": "step-3", "name": "Escalate to response lead", "description": "Assign lead and start SLA timer" } + ] + }, + { + "id": "wf-data-quality", + "name": "Data Quality Guardrail", + "description": "Validate critical datasets before they are used for reporting.", + "enabled": false, + "createdAt": "2026-02-05T10:45:00.000Z", + "updatedAt": "2026-02-05T10:45:00.000Z", + "steps": [ + { "id": "step-1", "name": "Load daily extract", "description": "Read warehouse export" }, + { "id": "step-2", "name": "Run schema checks", "description": "Ensure required fields and types" } + ] + } +] diff --git a/src/app/api/agents/[handle]/route.ts b/src/app/api/agents/[handle]/route.ts index 526596cb..efd69f8f 100644 --- a/src/app/api/agents/[handle]/route.ts +++ b/src/app/api/agents/[handle]/route.ts @@ -51,7 +51,7 @@ export async function GET( return NextResponse.json( { agent: mergedAgent, - totalActivity: activity.total, + totalActivity: activity.items.length, }, { headers: { diff --git a/src/app/api/bounties/[id]/route.ts b/src/app/api/bounties/[id]/route.ts index b08be1b6..cfbf6e53 100644 --- a/src/app/api/bounties/[id]/route.ts +++ b/src/app/api/bounties/[id]/route.ts @@ -39,7 +39,7 @@ function validateTransitionBody( return { ok: true, value: { - action, + action: action as BountyAction, agentHandle, ...(notes ? { notes } : {}), }, diff --git a/src/app/api/bounties/route.ts b/src/app/api/bounties/route.ts index 3aabbb94..8e509ef1 100644 --- a/src/app/api/bounties/route.ts +++ b/src/app/api/bounties/route.ts @@ -117,7 +117,7 @@ function validateTransitionBody( ok: true, value: { bountyId, - action, + action: action as BountyAction, agentHandle, ...(notes ? { notes } : {}), }, diff --git a/src/app/api/skills/[slug]/route.ts b/src/app/api/skills/[slug]/route.ts index 34974de0..7389d5c3 100644 --- a/src/app/api/skills/[slug]/route.ts +++ b/src/app/api/skills/[slug]/route.ts @@ -45,7 +45,7 @@ export async function GET( ? reviews.reduce((sum, review) => sum + review.rating, 0) / reviewsCount : 0; - const allVersions = versionsData as SkillVersionEntry[]; + const allVersions = versionsData as unknown as SkillVersionEntry[]; const versions = allVersions.find((entry) => entry.slug === slug)?.versions ?? []; const compatibility = diff --git a/src/app/api/workflows/[id]/route.ts b/src/app/api/workflows/[id]/route.ts new file mode 100644 index 00000000..504cb7d4 --- /dev/null +++ b/src/app/api/workflows/[id]/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { normalizeSteps, readWorkflows, writeWorkflows } from "@/lib/server/workflowStore"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const workflows = await readWorkflows(); + const workflow = workflows.find((item) => item.id === id); + + if (!workflow) { + return NextResponse.json({ error: "Workflow not found" }, { status: 404 }); + } + + return NextResponse.json({ workflow }); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + const body = await request.json() as { + name?: unknown; + description?: unknown; + steps?: unknown; + enabled?: unknown; + }; + + const workflows = await readWorkflows(); + const index = workflows.findIndex((item) => item.id === id); + + if (index === -1) { + return NextResponse.json({ error: "Workflow not found" }, { status: 404 }); + } + + const current = workflows[index]; + const now = new Date().toISOString(); + + const next = { + ...current, + updatedAt: now, + }; + + if (typeof body.name === "string") { + const trimmed = body.name.trim(); + if (!trimmed) { + return NextResponse.json({ error: "name cannot be empty" }, { status: 400 }); + } + next.name = trimmed; + } + + if (typeof body.description === "string") { + next.description = body.description.trim(); + } + + if (body.steps !== undefined) { + const steps = normalizeSteps(body.steps); + if (steps.length === 0) { + return NextResponse.json({ error: "steps must be a non-empty array" }, { status: 400 }); + } + next.steps = steps; + } + + if (typeof body.enabled === "boolean") { + next.enabled = body.enabled; + } + + workflows[index] = next; + await writeWorkflows(workflows); + + return NextResponse.json({ workflow: next }); + } catch { + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + const workflows = await readWorkflows(); + const nextWorkflows = workflows.filter((item) => item.id !== id); + + if (nextWorkflows.length === workflows.length) { + return NextResponse.json({ error: "Workflow not found" }, { status: 404 }); + } + + await writeWorkflows(nextWorkflows); + + return new NextResponse(null, { status: 204 }); +} diff --git a/src/app/api/workflows/route.ts b/src/app/api/workflows/route.ts new file mode 100644 index 00000000..b8339edd --- /dev/null +++ b/src/app/api/workflows/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { + normalizeSteps, + readWorkflows, + writeWorkflows, + type WorkflowRecord, +} from "@/lib/server/workflowStore"; + +export async function GET(request: NextRequest) { + const search = request.nextUrl.searchParams.get("search")?.trim().toLowerCase() || ""; + + const workflows = await readWorkflows(); + const filtered = !search + ? workflows + : workflows.filter((workflow) => { + return ( + workflow.name.toLowerCase().includes(search) + || workflow.description.toLowerCase().includes(search) + || workflow.steps.some((step) => step.name.toLowerCase().includes(search)) + ); + }); + + return NextResponse.json({ workflows: filtered }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() as { + name?: unknown; + description?: unknown; + steps?: unknown; + }; + + const name = typeof body.name === "string" ? body.name.trim() : ""; + const description = typeof body.description === "string" ? body.description.trim() : ""; + const steps = normalizeSteps(body.steps); + + if (!name) { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + + if (steps.length === 0) { + return NextResponse.json({ error: "steps must be a non-empty array" }, { status: 400 }); + } + + const workflows = await readWorkflows(); + const now = new Date().toISOString(); + + const workflow: WorkflowRecord = { + id: `wf-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + name, + description, + steps, + enabled: true, + createdAt: now, + updatedAt: now, + }; + + workflows.unshift(workflow); + await writeWorkflows(workflows); + + return NextResponse.json({ workflow }, { status: 201 }); + } catch { + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); + } +} diff --git a/src/app/collections/[slug]/page.tsx b/src/app/collections/[slug]/page.tsx index c66c66f3..c009dde8 100644 --- a/src/app/collections/[slug]/page.tsx +++ b/src/app/collections/[slug]/page.tsx @@ -139,7 +139,7 @@ export default async function CollectionRoute(props: {
🧰 {s.name} - +
by {s.author}
{s.description}
diff --git a/src/app/governance/page.tsx b/src/app/governance/page.tsx index 4ebbf607..c55779b2 100644 --- a/src/app/governance/page.tsx +++ b/src/app/governance/page.tsx @@ -39,7 +39,7 @@ export const metadata: Metadata = { }; export default function GovernancePage() { - const data = governanceFramework as FrameworkData; + const data = governanceFramework as unknown as FrameworkData; return (
diff --git a/src/app/mcp/[slug]/mcp-detail-client.tsx b/src/app/mcp/[slug]/mcp-detail-client.tsx index 30578c94..54af9c0c 100644 --- a/src/app/mcp/[slug]/mcp-detail-client.tsx +++ b/src/app/mcp/[slug]/mcp-detail-client.tsx @@ -96,11 +96,13 @@ export function McpDetailClient({ slug, initialServer }: { slug: string; initial const response = await fetch(`/api/mcp/${encodeURIComponent(slug)}/install`, { method: "POST" }); const json = (await response.json()) as { installs?: number }; - if (typeof json.installs === "number") { + const installCount = json.installs; + + if (typeof installCount === "number") { setPayload((prev) => ({ server: prev?.server ?? activeServer, health: prev?.health ?? health ?? UNKNOWN_HEALTH, - installs: json.installs, + installs: installCount, })); } diff --git a/src/app/setup/SetupWizardClient.tsx b/src/app/setup/SetupWizardClient.tsx index 40f0bc04..20851202 100644 --- a/src/app/setup/SetupWizardClient.tsx +++ b/src/app/setup/SetupWizardClient.tsx @@ -91,6 +91,10 @@ function HostDetectionStep({ onHostChange: (host: HostId) => void; }) { const detectedHint = useMemo(() => { + if (typeof window === "undefined" || typeof navigator === "undefined") { + return "Auto-detect hint: host detection runs after the page loads."; + } + const userAgent = navigator.userAgent.toLowerCase(); const platform = navigator.platform.toLowerCase(); @@ -370,7 +374,7 @@ function VerificationStep({ report }: { report: VerificationReport }) { } function copyText(text: string) { - if (navigator?.clipboard?.writeText) { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { void navigator.clipboard.writeText(text); } } diff --git a/src/app/workflows/[id]/page.tsx b/src/app/workflows/[id]/page.tsx index 8cab2ea5..d09588cc 100644 --- a/src/app/workflows/[id]/page.tsx +++ b/src/app/workflows/[id]/page.tsx @@ -1,330 +1,210 @@ -import { notFound } from "next/navigation"; +/* eslint-disable react/no-unescaped-entities */ +"use client"; + import Link from "next/link"; -import { getWorkflows, getWorkflowById, getSkills } from "@/lib/data"; +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft, Loader2, Save, Trash2 } from "lucide-react"; + import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Breadcrumbs } from "@/components/breadcrumbs"; -import { ArrowRight, Check, Clock, Layers, Zap, ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { parseStepsFromText, stepsToText, type Workflow } from "@/lib/workflows"; + +export default function WorkflowDetailPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + + const id = params?.id || ""; + + const [workflow, setWorkflow] = useState(null); + const [stepsText, setStepsText] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const run = async () => { + if (!id) return; + + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/workflows/${id}`, { cache: "no-store" }); + if (!response.ok) { + throw new Error("Workflow not found"); + } + + const payload = await response.json() as { workflow: Workflow }; + setWorkflow(payload.workflow); + setStepsText(stepsToText(payload.workflow.steps)); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load workflow"); + } finally { + setLoading(false); + } + }; + + void run(); + }, [id]); + + const stepCount = useMemo( + () => parseStepsFromText(stepsText).length, + [stepsText], + ); -export function generateStaticParams() { - return getWorkflows().map((workflow) => ({ id: workflow.id })); -} - -export function generateMetadata({ params }: { params: { id: string } }) { - const workflow = getWorkflowById(params.id); - if (!workflow) return { title: "Workflow Not Found" }; - - return { - title: `${workflow.name} Workflow — forAgents.dev`, - description: workflow.description, - openGraph: { - title: `${workflow.name} Workflow — forAgents.dev`, - description: workflow.description, - url: `https://foragents.dev/workflows/${workflow.id}`, - siteName: "forAgents.dev", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: `${workflow.name} Workflow — forAgents.dev`, - description: workflow.description, - }, + const save = async () => { + if (!workflow) return; + + setSaving(true); + setError(null); + + try { + const response = await fetch(`/api/workflows/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: workflow.name, + description: workflow.description, + steps: parseStepsFromText(stepsText), + enabled: workflow.enabled, + }), + }); + + const payload = await response.json().catch(() => ({})) as { error?: string; workflow?: Workflow }; + + if (!response.ok) { + throw new Error(payload.error || "Failed to save workflow"); + } + + if (payload.workflow) { + setWorkflow(payload.workflow); + setStepsText(stepsToText(payload.workflow.steps)); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save workflow"); + } finally { + setSaving(false); + } }; -} - -const difficultyColors = { - beginner: "bg-green-500/10 text-green-400 border-green-500/20", - intermediate: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", - advanced: "bg-red-500/10 text-red-400 border-red-500/20", -}; - -const categoryEmoji: Record = { - development: "💻", - marketing: "📢", - analytics: "📊", - devops: "🚀", - security: "🔒", - hr: "👥", -}; - -export default async function WorkflowDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const workflow = getWorkflowById(id); - if (!workflow) notFound(); - - const allSkills = getSkills(); - const skillMap = new Map(allSkills.map((s) => [s.slug, s])); - - // Map required skills to actual skill objects - const requiredSkillObjects = workflow.requiredSkills - .map((slug) => skillMap.get(slug)) - .filter((s) => s !== undefined); + const remove = async () => { + setDeleting(true); + setError(null); + + try { + const response = await fetch(`/api/workflows/${id}`, { method: "DELETE" }); + if (!response.ok) { + throw new Error("Failed to delete workflow"); + } + router.push("/workflows"); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete workflow"); + setDeleting(false); + } + }; - const automatedStepsCount = workflow.steps.filter((s) => s.automated).length; - const automationPercentage = Math.round( - (automatedStepsCount / workflow.steps.length) * 100 - ); + if (loading) { + return
Loading workflow...
; + } + + if (!workflow) { + return ( +
+

{error || "Workflow not found."}

+ +
+ ); + } return ( -
- {/* Breadcrumbs */} -
- +
+
+ + + + {workflow.enabled ? "Enabled" : "Disabled"} +
- {/* Header */} -
-
-
-
- -
-
- {categoryEmoji[workflow.category] || "📦"} -
-
-

- {workflow.name} -

- - {workflow.difficulty} - -
-

{workflow.description}

- - {/* Meta */} -
-
- - {workflow.estimatedTime} -
- • -
- - {workflow.steps.length} steps -
- • -
- - {automationPercentage}% automated -
- • -
- Category: - - {workflow.category} - -
-
- - {/* Tags */} -
- {workflow.tags.map((tag) => ( - - {tag} - - ))} -
-
+ + + Edit workflow + Make changes and save them to the workflow API. + + +
+ + setWorkflow({ ...workflow, name: e.target.value })} + />
-
-
- - - {/* Main Content */} -
-
- {/* Left Column: Steps */} -
-
-

Workflow Steps

-

- Follow these steps to implement the {workflow.name.toLowerCase()} workflow. - {automatedStepsCount > 0 && ( - <> {automatedStepsCount} out of {workflow.steps.length} steps can be fully automated. - )} -

-
+
+ +