Add beads: Skill v4 epic (1bg), Quality gates (n1h), Brainstorm epics (jq5, 2e6), memory nodes

This commit is contained in:
zenchantlive 2026-03-01 22:56:18 -08:00
parent 80c3d06284
commit 835018c183
6 changed files with 2189 additions and 136 deletions

View 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.');
}
}

View file

@ -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);
}

View file

@ -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}

View file

@ -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;
}