Add beads: Skill v4 epic (1bg), Quality gates (n1h), Brainstorm epics (jq5, 2e6), memory nodes
This commit is contained in:
parent
80c3d06284
commit
835018c183
6 changed files with 2189 additions and 136 deletions
125
src/app/api/beads/[id]/comments/[commentId]/comment-mutation.ts
Normal file
125
src/app/api/beads/[id]/comments/[commentId]/comment-mutation.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { deleteCommentViaDolt, updateCommentViaDolt } from '../../../../../../lib/read-interactions';
|
||||
|
||||
export interface RouteParams {
|
||||
id: string;
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
interface PatchBody {
|
||||
projectRoot?: unknown;
|
||||
text?: unknown;
|
||||
}
|
||||
|
||||
interface CommentMutationDeps {
|
||||
updateComment: (projectRoot: string, commentId: number, text: string) => Promise<boolean>;
|
||||
deleteComment: (projectRoot: string, commentId: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const defaultDeps: CommentMutationDeps = {
|
||||
updateComment: updateCommentViaDolt,
|
||||
deleteComment: deleteCommentViaDolt,
|
||||
};
|
||||
|
||||
function parseCommentId(raw: string): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error('commentId must be a positive integer.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseProjectRoot(raw: unknown): string {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
throw new Error('projectRoot is required.');
|
||||
}
|
||||
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function parseCommentText(raw: unknown): string {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
throw new Error('text is required.');
|
||||
}
|
||||
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function badRequest(message: string): NextResponse {
|
||||
return NextResponse.json({ ok: false, error: { message } }, { status: 400 });
|
||||
}
|
||||
|
||||
function notFound(message: string): NextResponse {
|
||||
return NextResponse.json({ ok: false, error: { message } }, { status: 404 });
|
||||
}
|
||||
|
||||
function serverError(message: string): NextResponse {
|
||||
return NextResponse.json({ ok: false, error: { message } }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function handlePatchCommentRequest(
|
||||
request: Request,
|
||||
params: RouteParams,
|
||||
deps: CommentMutationDeps = defaultDeps,
|
||||
): Promise<NextResponse> {
|
||||
let body: PatchBody;
|
||||
try {
|
||||
body = (await request.json()) as PatchBody;
|
||||
} catch {
|
||||
return badRequest('Invalid JSON body.');
|
||||
}
|
||||
|
||||
let projectRoot: string;
|
||||
let commentId: number;
|
||||
let text: string;
|
||||
|
||||
try {
|
||||
projectRoot = parseProjectRoot(body.projectRoot);
|
||||
commentId = parseCommentId(params.commentId);
|
||||
text = parseCommentText(body.text);
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Invalid request.');
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await deps.updateComment(projectRoot, commentId, text);
|
||||
if (!updated) {
|
||||
return notFound('Comment not found.');
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, id: params.id, commentId });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to update comment:', error);
|
||||
return serverError('Failed to update comment.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteCommentRequest(
|
||||
request: Request,
|
||||
params: RouteParams,
|
||||
deps: CommentMutationDeps = defaultDeps,
|
||||
): Promise<NextResponse> {
|
||||
let projectRoot: string;
|
||||
let commentId: number;
|
||||
|
||||
try {
|
||||
projectRoot = parseProjectRoot(new URL(request.url).searchParams.get('projectRoot'));
|
||||
commentId = parseCommentId(params.commentId);
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Invalid request.');
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await deps.deleteComment(projectRoot, commentId);
|
||||
if (!deleted) {
|
||||
return notFound('Comment not found.');
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, id: params.id, commentId });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to delete comment:', error);
|
||||
return serverError('Failed to delete comment.');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,141 +1,18 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { deleteCommentViaDolt, updateCommentViaDolt } from '../../../../../../lib/read-interactions';
|
||||
import type { NextResponse } from 'next/server';
|
||||
import { handleDeleteCommentRequest, handlePatchCommentRequest, type RouteParams } from './comment-mutation';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface RouteParams {
|
||||
id: string;
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
interface PatchBody {
|
||||
projectRoot?: unknown;
|
||||
text?: unknown;
|
||||
}
|
||||
|
||||
interface CommentMutationDeps {
|
||||
updateComment: (projectRoot: string, commentId: number, text: string) => Promise<boolean>;
|
||||
deleteComment: (projectRoot: string, commentId: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const defaultDeps: CommentMutationDeps = {
|
||||
updateComment: updateCommentViaDolt,
|
||||
deleteComment: deleteCommentViaDolt,
|
||||
};
|
||||
|
||||
function parseCommentId(raw: string): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error('commentId must be a positive integer.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseProjectRoot(raw: unknown): string {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
throw new Error('projectRoot is required.');
|
||||
}
|
||||
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function parseCommentText(raw: unknown): string {
|
||||
if (typeof raw !== 'string' || !raw.trim()) {
|
||||
throw new Error('text is required.');
|
||||
}
|
||||
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function badRequest(message: string): NextResponse {
|
||||
return NextResponse.json({ ok: false, error: { message } }, { status: 400 });
|
||||
}
|
||||
|
||||
function notFound(message: string): NextResponse {
|
||||
return NextResponse.json({ ok: false, error: { message } }, { status: 404 });
|
||||
}
|
||||
|
||||
function serverError(message: string): NextResponse {
|
||||
return NextResponse.json({ ok: false, error: { message } }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function handlePatchCommentRequest(
|
||||
request: Request,
|
||||
params: RouteParams,
|
||||
deps: CommentMutationDeps,
|
||||
): Promise<NextResponse> {
|
||||
let body: PatchBody;
|
||||
try {
|
||||
body = (await request.json()) as PatchBody;
|
||||
} catch {
|
||||
return badRequest('Invalid JSON body.');
|
||||
}
|
||||
|
||||
let projectRoot: string;
|
||||
let commentId: number;
|
||||
let text: string;
|
||||
|
||||
try {
|
||||
projectRoot = parseProjectRoot(body.projectRoot);
|
||||
commentId = parseCommentId(params.commentId);
|
||||
text = parseCommentText(body.text);
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Invalid request.');
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await deps.updateComment(projectRoot, commentId, text);
|
||||
if (!updated) {
|
||||
return notFound('Comment not found.');
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, id: params.id, commentId });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to update comment:', error);
|
||||
return serverError('Failed to update comment.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteCommentRequest(
|
||||
request: Request,
|
||||
params: RouteParams,
|
||||
deps: CommentMutationDeps,
|
||||
): Promise<NextResponse> {
|
||||
let projectRoot: string;
|
||||
let commentId: number;
|
||||
|
||||
try {
|
||||
projectRoot = parseProjectRoot(new URL(request.url).searchParams.get('projectRoot'));
|
||||
commentId = parseCommentId(params.commentId);
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Invalid request.');
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await deps.deleteComment(projectRoot, commentId);
|
||||
if (!deleted) {
|
||||
return notFound('Comment not found.');
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, id: params.id, commentId });
|
||||
} catch (error) {
|
||||
console.error('[API] Failed to delete comment:', error);
|
||||
return serverError('Failed to delete comment.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<RouteParams> },
|
||||
): Promise<NextResponse> {
|
||||
return handlePatchCommentRequest(request, await params, defaultDeps);
|
||||
return handlePatchCommentRequest(request, await params);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<RouteParams> },
|
||||
): Promise<NextResponse> {
|
||||
return handleDeleteCommentRequest(request, await params, defaultDeps);
|
||||
return handleDeleteCommentRequest(request, await params);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Background,
|
||||
MarkerType,
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import dagre from 'dagre';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { AgentArchetype } from '../../lib/types-swarm';
|
||||
|
|
@ -37,10 +38,22 @@ export interface WorkflowGraphProps {
|
|||
const NODE_WIDTH = 320;
|
||||
const NODE_HEIGHT = 150;
|
||||
|
||||
function layoutDagre(nodes: Node<GraphNodeData>[], edges: Edge[]): Node<GraphNodeData>[] {
|
||||
type LayoutDirection = 'LR' | 'TB';
|
||||
type LayoutDensity = 'normal' | 'compact';
|
||||
|
||||
function layoutDagre(
|
||||
nodes: Node<GraphNodeData>[],
|
||||
edges: Edge[],
|
||||
direction: LayoutDirection,
|
||||
density: LayoutDensity,
|
||||
): Node<GraphNodeData>[] {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
dagreGraph.setGraph({ rankdir: 'LR' });
|
||||
dagreGraph.setGraph({
|
||||
rankdir: direction,
|
||||
ranksep: density === 'compact' ? 70 : 120,
|
||||
nodesep: density === 'compact' ? 35 : 70,
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
|
|
@ -77,6 +90,8 @@ function WorkflowGraphInner({
|
|||
assignMode = false,
|
||||
}: WorkflowGraphProps) {
|
||||
const { fitView } = useReactFlow();
|
||||
const [layoutDirection, setLayoutDirection] = useState<LayoutDirection>('LR');
|
||||
const [layoutDensity, setLayoutDensity] = useState<LayoutDensity>('normal');
|
||||
|
||||
// Use the extracted hook for all graph analysis
|
||||
const {
|
||||
|
|
@ -95,6 +110,9 @@ function WorkflowGraphInner({
|
|||
return { nodes: [] as Node<GraphNodeData>[], edges: [] as Edge[] };
|
||||
}
|
||||
|
||||
const sourcePosition = layoutDirection === 'TB' ? Position.Bottom : Position.Right;
|
||||
const targetPosition = layoutDirection === 'TB' ? Position.Top : Position.Left;
|
||||
|
||||
const baseNodes: Node<GraphNodeData>[] = visibleBeads.map((issue) => {
|
||||
let matchedArchetype: AgentArchetype | undefined;
|
||||
if (archetypes && issue.assignee) {
|
||||
|
|
@ -130,8 +148,8 @@ function WorkflowGraphInner({
|
|||
onViewTelemetry: onViewTelemetry,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
type: 'flowNode',
|
||||
};
|
||||
});
|
||||
|
|
@ -187,10 +205,10 @@ function WorkflowGraphInner({
|
|||
}
|
||||
|
||||
return {
|
||||
nodes: layoutDagre(baseNodes, graphEdges),
|
||||
nodes: layoutDagre(baseNodes, graphEdges, layoutDirection, layoutDensity),
|
||||
edges: graphEdges,
|
||||
};
|
||||
}, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode, onSelect, onViewInSocial, onAssignMode, onViewTelemetry]);
|
||||
}, [beads, hideClosed, selectedId, signalById, actionableNodeIds, cycleNodeIdSet, chainNodeIds, blockerTooltipMap, archetypes, assignMode, onSelect, onViewInSocial, onAssignMode, onViewTelemetry, layoutDirection, layoutDensity]);
|
||||
|
||||
const nodeTypes: NodeTypes = useMemo(
|
||||
() => ({
|
||||
|
|
@ -220,7 +238,11 @@ function WorkflowGraphInner({
|
|||
fitView({ padding: 0.3, duration: 200 });
|
||||
}, 50);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [fitView, flowModel.nodes.length]);
|
||||
}, [fitView, flowModel.nodes.length, layoutDirection, layoutDensity]);
|
||||
|
||||
const handleFitToScreen = useCallback(() => {
|
||||
fitView({ padding: 0.24, duration: 240 });
|
||||
}, [fitView]);
|
||||
|
||||
return (
|
||||
<div className={`relative h-full min-h-[24rem] overflow-hidden rounded-2xl border border-white/5 bg-[radial-gradient(circle_at_50%_50%,rgba(15,23,42,0.4),rgba(5,8,15,0.8))] shadow-inner ${className}`}>
|
||||
|
|
@ -245,6 +267,66 @@ function WorkflowGraphInner({
|
|||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute right-3 top-3 z-10 flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutDirection('LR')}
|
||||
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
|
||||
layoutDirection === 'LR'
|
||||
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
Horizontal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutDirection('TB')}
|
||||
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
|
||||
layoutDirection === 'TB'
|
||||
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
Vertical
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutDensity('compact')}
|
||||
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
|
||||
layoutDensity === 'compact'
|
||||
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
Compact
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutDensity('normal')}
|
||||
className={`rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
|
||||
layoutDensity === 'normal'
|
||||
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
Normal
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFitToScreen}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-tertiary)] px-3 py-1.5 text-xs font-semibold text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-primary)]"
|
||||
aria-label="Fit graph to screen"
|
||||
title="Fit graph to screen"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
<ReactFlow
|
||||
className="workflow-graph-flow"
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { runBdCommand } from './bridge';
|
||||
import { getDoltConnection } from './dolt-client';
|
||||
import type { ResultSetHeader } from 'mysql2';
|
||||
|
||||
export interface BeadInteraction {
|
||||
id: string;
|
||||
|
|
@ -36,3 +38,54 @@ export async function readInteractionsViaBd(projectRoot: string, beadId: string)
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function asNonEmptyProjectRoot(projectRoot: string): string {
|
||||
if (typeof projectRoot !== 'string' || !projectRoot.trim()) {
|
||||
throw new Error('projectRoot is required.');
|
||||
}
|
||||
|
||||
return projectRoot.trim();
|
||||
}
|
||||
|
||||
function asValidCommentId(commentId: number): number {
|
||||
if (!Number.isInteger(commentId) || commentId <= 0) {
|
||||
throw new Error('commentId must be a positive integer.');
|
||||
}
|
||||
|
||||
return commentId;
|
||||
}
|
||||
|
||||
function asNonEmptyCommentText(text: string): string {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('text is required.');
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
export async function updateCommentViaDolt(projectRoot: string, commentId: number, text: string): Promise<boolean> {
|
||||
const root = asNonEmptyProjectRoot(projectRoot);
|
||||
const id = asValidCommentId(commentId);
|
||||
const nextText = asNonEmptyCommentText(text);
|
||||
|
||||
const pool = await getDoltConnection(root);
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
'UPDATE comments SET text = ? WHERE id = ?',
|
||||
[nextText, id],
|
||||
);
|
||||
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
export async function deleteCommentViaDolt(projectRoot: string, commentId: number): Promise<boolean> {
|
||||
const root = asNonEmptyProjectRoot(projectRoot);
|
||||
const id = asValidCommentId(commentId);
|
||||
|
||||
const pool = await getDoltConnection(root);
|
||||
const [result] = await pool.execute<ResultSetHeader>(
|
||||
'DELETE FROM comments WHERE id = ?',
|
||||
[id],
|
||||
);
|
||||
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue