feat: Graphify build worker endpoints + UI trigger

- POST /api/aos/instances/[id]/graph-build: trigger or get build command
- POST /api/aos/instances/[id]/graph-status: worker callback (ok/error)
- GET /api/aos/graphify-health: check if Graphify service is up
- Instance detail: 'Graph neu erstellen' button (visible when demo_active/active)
- Graceful degradation: if GRAPHIFY_URL not set, returns build command for external worker
This commit is contained in:
Z User committed 2026-10-28 19:38:28 +00:00
1 parent 7d94fe53ad
commit 25599a5516
4 files changed
+292

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { createGraphifyClientFromEnv } from '@/lib/graphify'
export const dynamic = 'force-dynamic'
// GET /api/aos/graphify-health
// Checks if the Graphify MCP HTTP service is configured and reachable.
// Used by the instance detail UI to show whether graphify is available.
export async function GET() {
const url = process.env.GRAPHIFY_URL
if (!url) {
return NextResponse.json({
configured: false,
ok: false,
message: 'Graphify not configured (GRAPHIFY_URL missing)',
})
}
const client = createGraphifyClientFromEnv()
if (!client) {
return NextResponse.json({ configured: true, ok: false, message: 'client init failed' })
}
const health = await client.health()
return NextResponse.json({
configured: true,
ok: health.ok,
url,
version: health.version,
message: health.ok ? 'Graphify service is up' : 'Graphify service unreachable',
})
}
@@ -0,0 +1,142 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { buildGraphCommand, graphPathForInstance } from '@/lib/graphify'
export const dynamic = 'force-dynamic'
export const maxDuration = 600 // 10 min — graph build can take a while
// POST /api/aos/instances/[id]/graph-build
// Triggers a Graphify graph build for this instance's codebase.
// Can be called by:
// 1. The AOS UI (admin "Graph neu erstellen" button)
// 2. An external worker (cron job) that polls for queued graphs
// 3. The provisioning hook (fire-and-forget)
//
// The actual build runs via the Graphify CLI in a subprocess. In production,
// this should be delegated to a worker container that has Graphify installed.
// For dev/demo: we shell out directly (requires graphify installed on host).
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const instance = await db.instance.findUnique({
where: { id },
include: { license: true },
})
if (!instance) {
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
}
if (instance.status === 'deleted' || instance.status === 'deleting') {
return NextResponse.json({ error: 'cannot build graph for deleted instance' }, { status: 400 })
}
// Parse metadata for git repo info
const meta = instance.metadata ? JSON.parse(instance.metadata) as { gitRepo?: string; gitBranch?: string } : {}
const gitRepo = meta.gitRepo ?? process.env.INSTANCE_GIT_REPO ?? 'https://github.com/cetinmustafa83/restov1'
const gitBranch = meta.gitBranch ?? process.env.INSTANCE_GIT_BRANCH ?? 'main'
const graphPath = graphPathForInstance(instance.id)
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'graphify.build.start',
step: 'graphify',
result: 'pending',
details: JSON.stringify({ gitRepo, gitBranch, graphPath }),
},
})
// If Graphify service is not configured, return the command for an
// external worker to execute. This allows the build to be picked up
// by a separate worker container.
if (!process.env.GRAPHIFY_URL) {
const cmd = buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath })
return NextResponse.json({
ok: true,
status: 'queued',
message: 'Graphify service not configured — build command generated for external worker.',
graphPath,
command: cmd,
gitRepo,
gitBranch,
})
}
// Graphify is configured — check if the MCP HTTP service is reachable
const { createGraphifyClientFromEnv } = await import('@/lib/graphify')
const client = createGraphifyClientFromEnv()
if (!client) {
return NextResponse.json({ error: 'Graphify client init failed' }, { status: 500 })
}
const health = await client.health()
if (!health.ok) {
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'graphify.build.error',
step: 'graphify',
result: 'error',
details: JSON.stringify({ error: 'Graphify service unreachable' }),
},
})
return NextResponse.json({
ok: false,
error: 'Graphify service unreachable',
command: buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath }),
}, { status: 503 })
}
// The MCP HTTP service is query-only. The actual build must be done
// via the CLI in a worker. We return the command so the caller (or a
// cron job) can execute it.
const cmd = buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath })
return NextResponse.json({
ok: true,
status: 'command-ready',
message: 'Graphify service is up. Execute the build command in a worker.',
graphPath,
command: cmd,
gitRepo,
gitBranch,
graphifyVersion: health.version,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// GET — returns the current graph build status for this instance
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const events = await db.instanceEvent.findMany({
where: { instanceId: id, type: { startsWith: 'graphify.' } },
orderBy: { createdAt: 'desc' },
take: 10,
})
const buildStarted = events.some((e) => e.type === 'graphify.build.start' || e.type === 'graphify.build.queued')
const buildDone = events.some((e) => e.type === 'graphify.build.ok')
const buildFailed = events.some((e) => e.type === 'graphify.build.error')
let status: 'none' | 'queued' | 'building' | 'ready' | 'failed'
if (buildDone) status = 'ready'
else if (buildFailed) status = 'failed'
else if (buildStarted) status = 'building'
else status = 'none'
return NextResponse.json({
status,
events: events.map((e) => ({
type: e.type,
result: e.result,
details: e.details,
createdAt: e.createdAt,
})),
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/instances/[id]/graph-status
// Called by the graph build worker after it finishes (success or failure).
// Body: { status: 'ok' | 'error', stats?: {...}, error?: '...' }
//
// This is the callback endpoint that completes the graph build lifecycle:
// 1. AOS queues build (graphify.build.queued)
// 2. Worker picks up, calls POST /graph-build, gets the command
// 3. Worker executes graphify CLI
// 4. Worker calls this endpoint to report success/failure
// 5. AOS records graphify.build.ok / graphify.build.error
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await req.json().catch(() => ({}))
const status = body.status === 'ok' ? 'ok' : 'error'
const instance = await db.instance.findUnique({ where: { id } })
if (!instance) {
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
}
if (status === 'ok') {
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'graphify.build.ok',
step: 'graphify',
result: 'ok',
details: JSON.stringify({
stats: body.stats ?? null,
graphPath: body.graphPath ?? null,
builtAt: new Date().toISOString(),
}),
},
})
await db.auditLog.create({
data: {
actor: 'graphify-worker',
action: 'graphify.build.ok',
entity: 'Instance',
entityId: id,
after: JSON.stringify({ stats: body.stats }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, status: 'ok' })
} else {
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'graphify.build.error',
step: 'graphify',
result: 'error',
details: JSON.stringify({ error: body.error ?? 'unknown build error' }),
},
})
await db.instance.update({
where: { id },
data: {
lastError: `graphify build failed: ${body.error ?? 'unknown'}`,
lastErrorAt: new Date(),
},
})
return NextResponse.json({ ok: false, status: 'error', error: body.error })
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+43
View File
@@ -19,6 +19,7 @@ import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
CircleDashed, CircleDashed,
Network,
} from 'lucide-react' } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch' import { useFetch } from '@/hooks/use-fetch'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
@@ -898,6 +899,7 @@ function InstanceDetailSheet({
</Button> </Button>
</div> </div>
) : ( ) : (
<>
<ActionsRow <ActionsRow
status={status} status={status}
busyAction={busyAction} busyAction={busyAction}
@@ -907,6 +909,47 @@ function InstanceDetailSheet({
onRetry={() => handleAction('retry', 'Provisioning wiederholen')} onRetry={() => handleAction('retry', 'Provisioning wiederholen')}
onDelete={() => setConfirmingDelete(true)} onDelete={() => setConfirmingDelete(true)}
/> />
{['demo_active', 'active'].includes(status) && (
<Button
variant="outline"
size="sm"
className="w-full gap-1.5"
disabled={busyAction !== null}
onClick={async () => {
setBusyAction('graph-build')
const t = toast.loading('Graphify-Graph wird erstellt…')
try {
const r = await fetch(`/api/aos/instances/${instance.id}/graph-build`, { method: 'POST' })
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'build failed')
if (res.status === 'queued') {
toast.info('Graphify nicht konfiguriert — Build-Kommando generiert.', {
id: t,
description: 'Worker muss den Build ausführen.',
duration: 6000,
})
} else if (res.status === 'command-ready') {
toast.success('Graphify-Service erreichbar. Build-Kommando bereit.', {
id: t,
description: 'Worker führt den Build aus.',
duration: 6000,
})
} else {
toast.success('Graph-Build gestartet', { id: t })
}
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Build fehlgeschlagen', { id: t })
} finally {
setBusyAction(null)
}
}}
>
<Network className="size-3.5" />
Graph neu erstellen
</Button>
)}
</>
)} )}
</section> </section>
</div> </div>