feat: multi-project support, real-data APIs, backlog & PRD panels#3
feat: multi-project support, real-data APIs, backlog & PRD panels#3RBKunnela wants to merge 5 commits intoSynkraAI:mainfrom
Conversation
- Add project-registry with async resolveProjectRoot() supporting ?project= query param, projects.json registry, and env var fallback - Refactor 13 API routes to eliminate duplicate getProjectRoot() functions - Add GET /api/projects and POST /api/projects/register endpoints - Make GitHub API route project-aware (scoped to resolved project dir) - Add new dashboard pages: context, insights, plans, prds, qa, roadmap - Add backlog, plans, and PRD panel components - Remove CODEOWNERS (not needed for private fork) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Private fork — no external contributor approval needed. Simplified README to focus on features and API docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hitecture docs - Enhanced PRD API to scan architecture docs (core-architecture.md, docs/architecture/*.md) - Converted PRD detail route to catch-all [...slug] for nested paths - Replaced plain <pre> text with MarkdownRenderer + Mermaid diagram support - Added category badges (PRD vs Architecture) with distinct icons and colors - Updated CodeRabbit config with PRD and architecture doc review rules - Fixed systemd ExecStartPre fuser command (wrap in bash -c for || true) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The QA view had no case statement in the page switch, falling through to the default PlaceholderView. Added import and case 'qa' to render the existing QAMetricsPanel component which was fully built but never connected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Warning Ignoring CodeRabbit configuration file changes. For security, only the configuration from the base branch is applied for open source repositories. 📝 WalkthroughWalkthroughIntroduces multi-project support with a project registry, new dashboard pages (roadmap, plans, PRDs, QA metrics, insights, context), comprehensive API routes for data aggregation and content management, centralized API URL routing via basePath, a new terminal streaming hook for real-time logs, and updates caching strategies via middleware. Refactors project root resolution across existing routes to use a shared resolver. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User
participant Dashboard as Dashboard UI
participant Middleware as Middleware
participant API as API Routes
participant Registry as Project Registry
participant FS as File System
User->>Dashboard: Request data (e.g., /api/context)
Dashboard->>Middleware: HTTP Request
Middleware->>Middleware: Check cache headers<br/>(skip for _next/static)
Middleware->>API: Apply no-cache headers
API->>Registry: resolveProjectRoot(request)
Registry->>Registry: Check ?project param
Registry->>FS: Load projects.json (cached)
FS-->>Registry: Registry data
Registry-->>API: Project root path
API->>FS: Scan project files<br/>(rules, agents, configs, etc.)
FS-->>API: File content
API->>API: Aggregate & transform data
API-->>Dashboard: JSON response
Dashboard-->>User: Rendered content
sequenceDiagram
participant Client as Client
participant SSE as SSE Endpoint<br/>(/api/logs)
participant Agent as Agent Process
participant FileSystem as Log File
Client->>SSE: Connect EventSource<br/>(agentId, since params)
SSE->>FileSystem: Read agent log file
FileSystem-->>SSE: Recent log lines
SSE->>Client: Send initial lines
Agent->>FileSystem: Write new log output
FileSystem-->>SSE: File update detected
SSE->>Client: Stream new line events
Client->>Client: Update terminal display
loop Reconnection Logic
alt Connection drops
Client->>SSE: Attempt reconnect<br/>(max 5 attempts)
else Max attempts exceeded
SSE-->>Client: Final error event
end
end
Client->>SSE: Disconnect/close
SSE->>SSE: Cleanup resources
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@pedroavalerio @PedroValerio — Hi Pedro, this PR is ready for your review. It includes all the dashboard improvements we've been working on: multi-project support, real-data API routes, Backlog panel, PRD panel with Mermaid rendering, QA Metrics fix, and CodeRabbit config. No credentials, tokens, or secrets are included — fully audited. All sensitive config is read from environment variables at runtime. Please take a look when you get a chance. Thanks! |
There was a problem hiding this comment.
Actionable comments posted: 3
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/components/ui/markdown-renderer.tsx (1)
64-99:⚠️ Potential issue | 🟠 MajorMermaid components are incorrectly wrapped in
<pre>tags.When the
coderenderer returns a<Mermaid />component forlanguage-mermaidblocks, theprerenderer receives this component as its child and attempts to match it by checkingchild?.props?.className === 'language-mermaid'. However, the Mermaid component does not have a className prop matching that value, so the check fails and the diagram gets wrapped in a<pre>tag anyway. Additionally, the exact equality check on line 67 is brittle sincerehype-highlightcan add extra classes.Use
isValidElement()to safely check if the child is a React element, then check if its type is the Mermaid component directly. Also use.includes('language-mermaid')on line 67 to handle potential extra classes added by rehype-highlight.Suggested fix
-import ReactMarkdown from 'react-markdown'; +import { isValidElement } from 'react'; +import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import type { Components } from 'react-markdown'; import { Mermaid } from '@/components/mermaidcn/mermaid'; @@ code: ({ className, children }) => { const isBlock = className?.includes('language-'); - if (className === 'language-mermaid') { + if (className?.includes('language-mermaid')) { const chartText = String(children).replace(/\n$/, ''); return ( <Mermaid @@ }, pre: ({ children }) => { - const child = children as React.ReactElement<{ className?: string }>; - if (child?.props?.className === 'language-mermaid') { + const child = isValidElement(children) ? children : null; + if (child?.type === Mermaid) { return <>{children}</>; } return (🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/ui/markdown-renderer.tsx` around lines 64 - 99, The pre renderer is incorrectly wrapping Mermaid diagrams because it checks child?.props?.className === 'language-mermaid' and doesn't verify the child is a React element; update the pre renderer to first use React.isValidElement(child) and then either check the element type === Mermaid to catch components returned by the code renderer or check child.props.className?.includes('language-mermaid') to handle extra classes from rehype-highlight; adjust the conditional in pre (and keep the code renderer that returns <Mermaid />) so Mermaid components are returned unwrapped (return <>children</>) when the child is a Mermaid element.src/components/qa/QAMetricsPanel.tsx (1)
157-163:⚠️ Potential issue | 🟠 MajorDon't advance
lastUpdatedon a failed fetch.When
/api/qa/metricsreturns non-2xx, this path keeps the oldmetricsvalue but still updateslastUpdated, so the footer looks like a successful refresh. Throw on!response.okor gatesetLastUpdated()behind the successful parse.🕒 Suggested fix
} else { const response = await fetch(apiUrl('/api/qa/metrics')); - if (response.ok) { - const data = await response.json(); - setMetrics(data); - } + if (!response.ok) { + throw new Error(`QA metrics request failed with ${response.status}`); + } + const data = await response.json(); + setMetrics(data); } setLastUpdated(new Date());🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/qa/QAMetricsPanel.tsx` around lines 157 - 163, The fetch handler in QAMetricsPanel.tsx updates setLastUpdated even when the fetch failed; modify the fetch logic (e.g., inside your fetchMetrics function) to only advance setLastUpdated after a successful response parse — either throw when response.ok is false (before parsing) or move setLastUpdated(new Date()) into the success branch immediately after setMetrics(data); ensure you reference the existing fetch call to apiUrl('/api/qa/metrics'), response.ok check, setMetrics, and setLastUpdated when applying the change.src/app/api/stories/[id]/route.ts (1)
149-156:⚠️ Potential issue | 🔴 CriticalThese story handlers can hit the wrong repository.
GET/PUT/DELETE all ignore the incoming request when resolving the project root. Reads will come from the default project, and the write paths can update or archive a story in the wrong repo.
Suggested fix
export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const { id } = await params; - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request); const storiesDir = path.join(projectRoot, 'docs', 'stories'); ... export async function PUT( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const { id } = await params; const body = await request.json() as UpdateStoryRequest; - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request); const storiesDir = path.join(projectRoot, 'docs', 'stories'); ... export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const { id } = await params; - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request); const storiesDir = path.join(projectRoot, 'docs', 'stories');Also applies to: 190-198, 309-316
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/stories/`[id]/route.ts around lines 149 - 156, The GET/PUT/DELETE handlers for stories call resolveProjectRoot() without using the incoming request, so they may resolve the default repo instead of the repo implied by the request; update each handler (GET, PUT, DELETE in src/app/api/stories/[id]/route.ts) to derive the project root from the request (use the NextRequest argument instead of ignoring it) before reading/writing stories, e.g., extract repo/project-identifying info from the request (headers, host, or path) and pass that into resolveProjectRoot or a new resolveProjectRootFromRequest helper, then use that projectRoot for path.join when locating the 'docs/stories' directory and for any archive/update operations so reads/writes target the correct repository.src/components/insights/InsightsPanel.tsx (1)
131-174:⚠️ Potential issue | 🟡 MinorGuard trend math for zero and unchanged baselines.
If the previous velocity is
0, this rendersInfinity%/NaN%. And when the error rate is unchanged, the badge still shows an upward trend. Both edge cases need explicit handling.Suggested fix
- const velocityChange = ((data.velocity.current - data.velocity.previous) / data.velocity.previous * 100).toFixed(0); + const velocityChange = + data.velocity.previous > 0 + ? (((data.velocity.current - data.velocity.previous) / data.velocity.previous) * 100).toFixed(0) + : null; const errorChange = data.errorRate.previous - data.errorRate.current; @@ - trendValue={`${velocityChange}% vs last week`} + trendValue={velocityChange ? `${velocityChange}% vs last week` : 'No prior-week baseline'} @@ - trend={errorChange > 0 ? 'down' : 'up'} - trendValue={`${Math.abs(errorChange)}% ${errorChange > 0 ? 'decrease' : 'increase'}`} + trend={errorChange === 0 ? 'stable' : errorChange > 0 ? 'down' : 'up'} + trendValue={errorChange === 0 ? 'No change' : `${Math.abs(errorChange)}% ${errorChange > 0 ? 'decrease' : 'increase'}`}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/insights/InsightsPanel.tsx` around lines 131 - 174, The velocity and error trend math needs guards: update the velocityChange computation (variable velocityChange) to check if data.velocity.previous is 0 or falsy and handle that case (e.g., set velocityChange to null or a safe string like '—' and set a corresponding trend prop to 'flat' or omit percent) so you don't divide by zero; likewise change errorChange logic (variable errorChange) to detect zero change and pass trend='flat' and trendValue='no change' to the MetricCard when data.errorRate.current === data.errorRate.previous; make these checks where velocityChange and errorChange are computed and when you build the MetricCard props (title "Velocity" and "Error Rate") so the UI shows sensible values instead of Infinity/NaN or an incorrect up arrow.
🟠 Major comments (30)
.gitignore-42-43 (1)
42-43:⚠️ Potential issue | 🟠 MajorRemove
server/bun.lockfrom.gitignoreto maintain reproducible installs.The
server/directory is a standalone Bun-managed package with its ownpackage.jsonand dev dependencies (@types/bun). The README documents runningbun installto set up the server. Ignoring the Bun lockfile will allow dependency versions to drift between dev environments, CI, and production deploys. Keepserver/bun.locktracked.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.gitignore around lines 42 - 43, Remove the server/bun.lock entry from .gitignore so the Bun lockfile is version-controlled; edit the .gitignore file and delete the line "server/bun.lock" (leave other entries like "server/node_modules/" intact) to ensure reproducible installs for the server package.src/components/stories/StoryCreateModal.tsx-121-121 (1)
121-121:⚠️ Potential issue | 🟠 MajorPreserve the active project when posting a new story.
This request drops the dashboard's
?project=selection. On a non-default project page, creates will fall back to the resolver's default project unless the current project id is appended here.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/stories/StoryCreateModal.tsx` at line 121, The POST to apiUrl('/api/stories') currently drops the dashboard ?project selection; update the request to preserve the active project by including the current project id when creating a story. Either append the project query param to the fetch URL (e.g. apiUrl(`/api/stories?project=${encodeURIComponent(activeProjectId)}`) using the component's activeProject/activeProjectId prop/state) or include a project field in the JSON body (e.g. body: JSON.stringify({ ..., project: activeProjectId })). Make this change inside StoryCreateModal where response = await fetch(apiUrl('/api/stories'), ...) is called and ensure you reference the existing active project variable (activeProject, activeProjectId, or currentProject) available in the component.next.config.ts-6-6 (1)
6-6:⚠️ Potential issue | 🟠 MajorDrive
basePathfrom the same env source as the client helper.The codebase introduces env-driven base-path support in
src/lib/api.ts(line 8), butnext.config.ts(line 6) hard-codesbasePath: '/aiox-dashboard'. When a deployment overridesNEXT_PUBLIC_BASE_PATH, the API helper generates URLs with the custom prefix while Next.js routing remains fixed at/aiox-dashboard, causing a routing mismatch.Suggested fix
import type { NextConfig } from "next"; +const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '/aiox-dashboard'; + const nextConfig: NextConfig = { // Externalize native modules that can't be bundled serverExternalPackages: ['chokidar'], - basePath: '/aiox-dashboard', + basePath,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@next.config.ts` at line 6, The hard-coded basePath in next.config.ts should be driven from the same env var used by the client helper; replace the literal '/aiox-dashboard' with a value derived from process.env.NEXT_PUBLIC_BASE_PATH (fallbacking to '' or undefined if not set) so Next.js routing matches the URLs generated by src/lib/api.ts; update the basePath assignment in next.config.ts to read NEXT_PUBLIC_BASE_PATH and ensure it normalizes empty/undefined values consistently with the client helper.src/lib/api.ts-8-15 (1)
8-15:⚠️ Potential issue | 🟠 MajorUse nullish coalescing to allow root-path deployments.
The
||operator treats an explicit empty string as falsy, preventingNEXT_PUBLIC_BASE_PATH=''from configuring a root deployment. Switch to??(nullish coalescing) to distinguish between "not set" and "set to empty string".Suggested fix
-const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '/aiox-dashboard'; +const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '/aiox-dashboard').replace(/\/$/, ''); @@ export function apiUrl(path: string): string { - return `${BASE_PATH}${path}`; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${BASE_PATH}${normalizedPath}`; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/api.ts` around lines 8 - 15, The BASE_PATH constant uses the || operator which treats an explicit empty string as unset; change its initialization to use the nullish coalescing operator (??) so NEXT_PUBLIC_BASE_PATH='' is respected for root-path deployments (update the BASE_PATH declaration where it's defined and leave apiUrl(path: string) unchanged so that apiUrl uses the corrected BASE_PATH).src/middleware.ts-21-25 (1)
21-25:⚠️ Potential issue | 🟠 MajorNarrow matcher to pages and APIs only; exclude Next.js optimized assets and public files.
The current matcher
/((?!_next/static|favicon.ico).*)is too broad. Verification confirms it catches:
/_next/image(Next.js image optimization)/manifest.json,/robots.txt(static metadata)- All files in
public/(SVGs, icons, etc.)All these paths receive
Cache-Control: no-cache, no-store, must-revalidate, defeating browser/CDN caching for immutable assets and degrading performance.The matcher should exclude at minimum:
_next/static,_next/image,favicon.ico,public/, and metadata files (manifest.json,robots.txt,sitemap.xml). Alternatively, use an allowlist approach matching only/api/**,/(.*\.)?html, and/pagepatterns instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/middleware.ts` around lines 21 - 25, The middleware config.matcher is too broad (export const config -> matcher) and is matching Next.js optimized assets and public metadata; narrow it to only target pages and API routes by replacing the current negative-match regex with either an allowlist style matcher that explicitly includes "/api/:path*", page routes (e.g. root and page paths) and HTML routes, or by updating the regex to exclude "_next/static", "_next/image", "favicon.ico", "manifest.json", "robots.txt", "sitemap.xml" and any public asset extensions; update the matcher value accordingly so middleware runs only for API and page requests, not for static or public files.src/components/backlog/BacklogPanel.tsx-101-105 (1)
101-105:⚠️ Potential issue | 🟠 MajorKeep filtered stories attached to a stable epic group.
Filtering epics separately from stories means a story can still match the filters while its epic gets dropped. In that path you create
epic: nullgroups and then key/collapse them withgroup-${idx}, so epic-owned stories end up rendered as “Unassigned Stories” and can inherit the wrong expansion state after a sort/filter change. Carry the referencedepicId(or the resolved epic from the full epic list) throughEpicGroupand use that as the stable render/state key.Also applies to: 137-149, 335-341
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/backlog/BacklogPanel.tsx` around lines 101 - 105, filteredEpics is created by filtering epics separately from stories which allows stories to reference epics that were dropped, leading to epic: null groups and unstable keys like group-${idx}; fix this by carrying the referenced epicId (or resolving the epic object from the full epics list) into the EpicGroup props and use that epicId/resolved epic as the stable React key and collapse/expansion identifier instead of relying on group-${idx} or null; update the code paths that build EpicGroup (the mapping that uses filteredEpics and the story grouping logic around EpicGroup) so they resolve epic via epicId (or lookup in the original epics array) before rendering and pass that stable id/object through for keys and expansion state (also apply same change to the other instances noted at the lines around 137-149 and 335-341).src/components/backlog/BacklogPanel.tsx-624-640 (1)
624-640:⚠️ Potential issue | 🟠 MajorLabel the filter comboboxes.
These selects only have adjacent text, so assistive tech won't announce which control is “Status”, “Priority”, or “Agent”. Please wire an accessible name with
<label htmlFor>or at leastaria-label.♿ Minimal fix
function FilterSelect({ label, value, onChange, options }: FilterSelectProps) { return ( <div className="flex items-center gap-1.5"> <span className="text-detail text-text-muted">{label}:</span> <select + aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} className="text-sm bg-transparent border px-2 py-1 text-text-secondary focus:outline-none focus:border-gold" style={{ borderColor: 'var(--border-subtle)' }} >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/backlog/BacklogPanel.tsx` around lines 624 - 640, The select elements in FilterSelect lack an accessible name; update the FilterSelect component to associate a label with the select by generating a stable id (e.g., derive from the label prop or accept an id prop) and using a <label htmlFor={id}> wrapping or adjacent to the visible label text, and set the select's id to that id; alternatively, if you prefer not to add a <label>, add an aria-label or aria-labelledby on the select using the label text (ensure uniqueness), so Screen Readers can announce controls like "Status", "Priority", or "Agent" — modify the FilterSelect function to create/use this id and wire it to both the label and the select (or add aria-label) accordingly.src/components/qa/QAMetricsPanel.tsx-100-112 (1)
100-112:⚠️ Potential issue | 🟠 MajorFix the stacked-bar math.
Line 110 already scales each column by
total / maxVal. Lines 106-107 divide bymaxValagain, so any non-max day only fills part of its own column and the pass/fail split is understated. Compute the inner segment heights fromtotalinstead.📊 Suggested fix
function TrendBar({ data }: { data: Array<{ date: string; passed: number; failed: number }> }) { const maxVal = Math.max(...data.map(d => d.passed + d.failed), 1); return ( <div className="flex items-end gap-1 h-20"> {data.map((d) => { const total = d.passed + d.failed; - const passHeight = (d.passed / maxVal) * 100; - const failHeight = (d.failed / maxVal) * 100; + const passHeight = total === 0 ? 0 : (d.passed / total) * 100; + const failHeight = total === 0 ? 0 : (d.failed / total) * 100; return ( <div key={d.date} className="flex-1 flex flex-col items-center gap-0.5"> <div className="w-full flex flex-col-reverse" style={{ height: `${(total / maxVal) * 100}%`, minHeight: '2px' }}> <div className="w-full bg-green-500 rounded-t-sm" style={{ height: `${passHeight}%` }} /> {failHeight > 0 && <div className="w-full bg-red-500 rounded-t-sm" style={{ height: `${failHeight}%` }} />}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/qa/QAMetricsPanel.tsx` around lines 100 - 112, In TrendBar, the inner segment heights are incorrectly divided by maxVal; change passHeight and failHeight to be relative to the day's total (e.g., passHeight = total ? (d.passed / total) * 100 : 0 and failHeight = total ? (d.failed / total) * 100 : 0) while keeping the outer column height as (total / maxVal) * 100%; update the computations in the TrendBar function where passHeight and failHeight are calculated and ensure you guard against total === 0 to avoid NaN.src/app/api/status/route.ts-120-123 (1)
120-123:⚠️ Potential issue | 🟠 MajorAccept request parameter to support
?project=scoping.The
resolveProjectRoot()function accepts an optional request parameter to check the?project=query parameter, but the/api/statusGET handler doesn't accept a request, so it cannot resolve projects dynamically. Add the request parameter to the handler and pass it toresolveProjectRoot():export async function GET(request: NextRequest) { try { const statusFilePath = path.join(await resolveProjectRoot(request), STATUS_FILE_NAME);This aligns with
/api/github/route.ts, which correctly passes request to enable project scoping.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/status/route.ts` around lines 120 - 123, The GET handler currently lacks a request parameter, so update the function signature export async function GET(request: NextRequest) to accept the incoming request and pass that request into resolveProjectRoot by calling await resolveProjectRoot(request) when building statusFilePath (keeping STATUS_FILE_NAME as before); ensure NextRequest is imported or available in the module if not already and adjust any callers/tests accordingly.src/app/api/bob/events/route.ts-22-24 (1)
22-24:⚠️ Potential issue | 🟠 MajorPass the incoming request into project resolution.
This endpoint receives
request: NextRequestbut callsresolveProjectRoot()without it. TheresolveProjectRootfunction accepts an optional request parameter to check the?project=query parameter first before falling back to the default project. Without passing the request, SSE subscribers for non-default projects will receive the wrong Bob stream.Fix
export async function GET(request: NextRequest) { - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request); const statusFilePath = path.join(projectRoot, BOB_STATUS_FILE_NAME);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/bob/events/route.ts` around lines 22 - 24, The GET handler calls resolveProjectRoot() without the incoming NextRequest so query-based project selection is ignored; update the GET function to pass the request into resolveProjectRoot(request) (so resolveProjectRoot can read the ?project= query) before computing statusFilePath (statusFilePath remains path.join(projectRoot, BOB_STATUS_FILE_NAME)), ensuring SSE subscribers receive the correct project stream.src/app/api/projects/register/route.ts-9-16 (1)
9-16:⚠️ Potential issue | 🟠 MajorAdd path validation in
registerProjectto prevent arbitrary filesystem access.The route correctly validates
projectPathas a non-empty string, butregisterProjectstores the path directly without validation. This allows registering any arbitrary path—including absolute paths, relative paths with traversal sequences, or symlinks—which are then used unsanitized in filesystem operations across the application. Add checks to ensure the path:
- Is within allowed boundaries (e.g., under a specific parent directory)
- Is not absolute (or if absolute, verified against a whitelist)
- Does not contain traversal sequences or resolve outside intended scope
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/projects/register/route.ts` around lines 9 - 16, registerProject currently stores projectPath without sanitization; update registerProject(projectPath, name?) to validate and canonicalize paths before persisting: reject absolute paths unless they match a configured whitelist, normalize and resolve projectPath via path.resolve + path.normalize (or fs.realpath) and ensure the resolved path startsWith a configured allowedBaseDir (compare resolved strings), detect and reject traversal by comparing resolved vs joined baseDir, and optionally lstat the resolved path to disallow symlinks (fs.lstat(...).isSymbolicLink()). On any validation failure return/throw a 400-like error so callers (route.ts) don’t register arbitrary or escaped filesystem locations. Ensure error messages reference registerProject and projectPath for easy tracing.src/app/api/qa/metrics/route.ts-73-75 (1)
73-75:⚠️ Potential issue | 🟠 MajorThis metrics route still ignores
?project=.
collectQAMetrics()resolves the default root becauseGET()never passes the incoming request through. In a multi-project session,/api/qa/metrics?project=...will still read.aiosfrom the default project.Suggested fix
-import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; ... -async function collectQAMetrics(): Promise<QAMetrics> { - const projectRoot = await resolveProjectRoot(); +async function collectQAMetrics(request: NextRequest): Promise<QAMetrics> { + const projectRoot = await resolveProjectRoot(request); const aiosDir = path.join(projectRoot, '.aios'); ... } ... -export async function GET() { +export async function GET(request: NextRequest) { try { - const metrics = await collectQAMetrics(); + const metrics = await collectQAMetrics(request); return NextResponse.json(metrics);Also applies to: 219-221
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/qa/metrics/route.ts` around lines 73 - 75, collectQAMetrics currently always calls resolveProjectRoot() with no context, so GET requests with ?project= are ignored; update collectQAMetrics to accept an optional project identifier/path (e.g., collectQAMetrics(project?: string)) and use that to call resolveProjectRoot(project) (or a new resolveProjectRootFromParam helper) instead of always resolving the default, then update the GET handler to parse req.nextUrl.searchParams.get('project') (or equivalent) and pass that value into collectQAMetrics; apply the same change for the other call sites referenced around the 219-221 region so all metrics collection honors ?project=.src/lib/squad-api-utils.ts-6-13 (1)
6-13: 🛠️ Refactor suggestion | 🟠 MajorAvoid keeping a second project-root resolver here.
getProjectRoot()only consultsAIOS_PROJECT_ROOTand a hardcoded fallback, so any caller that stays on this helper bypasses both the registry and request-scoped?project=selection. That makes it easy for squad routes to drift from the centralized resolver the rest of the API now uses.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/squad-api-utils.ts` around lines 6 - 13, getProjectRoot() duplicates project root resolution and bypasses the centralized resolver; remove this ad-hoc resolver and update callers to use the centralized async resolveProjectRoot from the project-registry (import and await resolveProjectRoot where getProjectRoot() was used). If a synchronous shim is absolutely required, make getProjectRoot() throw a clear deprecation/error telling callers to switch to await resolveProjectRoot(), rather than returning a hardcoded fallback; reference getProjectRoot() and resolveProjectRoot() to locate and change the call sites.src/app/api/prds/route.ts-54-57 (1)
54-57:⚠️ Potential issue | 🟠 MajorPass the request into
resolveProjectRoot()here too.This endpoint lists project-scoped docs, but
GET()resolves the root without the incoming request. That makes/api/prds?project=...return the default project's PRDs regardless of the current selection.Suggested fix
-import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; ... -export async function GET() { +export async function GET(request: NextRequest) { try { - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request); const docsDir = path.join(projectRoot, 'docs');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/prds/route.ts` around lines 54 - 57, The GET handler is calling resolveProjectRoot() without the incoming Request, so project-scoped queries (e.g., ?project=...) are ignored; update the export async function GET to accept the Request (e.g., GET(request: Request)) and pass that request into resolveProjectRoot(request), keeping the rest of the logic (e.g., docsDir = path.join(projectRoot, 'docs')) the same so the resolved project root reflects the current request selection.src/app/api/prds/[...slug]/route.ts-6-14 (1)
6-14:⚠️ Potential issue | 🟠 MajorThis PRD route still ignores the selected project.
The handler receives the request but throws it away before resolving the root, so
?project=always falls back to the default repo.PRDPanelwill show the wrong document set when users switch projects.Suggested fix
-import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; ... export async function GET( - _request: Request, + request: NextRequest, { params }: { params: Promise<{ slug: string[] }> } ) { try { const { slug } = await params; const slugPath = slug.join('/'); - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/prds/`[...slug]/route.ts around lines 6 - 14, The GET handler is ignoring the request's ?project= query so it always uses the default repo; extract the project param from the incoming request (e.g., const project = new URL(_request.url).searchParams.get('project')), await params to get slug as before, and pass the project into resolveProjectRoot (call resolveProjectRoot(project) or resolveProjectRoot(undefined) only when project is missing) so filePath is resolved for the selected project rather than always the default.src/components/prd/PRDPanel.tsx-52-59 (1)
52-59:⚠️ Potential issue | 🟠 MajorClear and gate PRD content fetches by the current selection.
handleSelect()keeps the previousprdContent, and the detail view only switches onceprdContent !== null. Clicking document B right after document A can therefore show A's body under B's header until the second fetch finishes, and an older response can still overwrite the newer selection. Reset the content before navigation and ignore or abort responses that no longer match the latestselectedSlug.♻️ Suggested fix
const handleSelect = (slug: string) => { setSelectedSlug(slug); - fetchPRDContent(slug); + setPrdContent(null); + void fetchPRDContent(slug); }; - if (selectedSlug && prdContent !== null) { + if (selectedSlug) {You'll still want an
AbortControlleror request token insidefetchPRDContent()so a slower older response can't overwrite the latest selection.Also applies to: 71-74, 104-137
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/prd/PRDPanel.tsx` around lines 52 - 59, Reset the displayed PRD body immediately when selection changes and guard fetchPRDContent against stale responses: in handleSelect() set prdContent to null (or empty) and update selectedSlug before calling fetchPRDContent; inside fetchPRDContent() create an AbortController (or attach a request token) and store it per-call so you can abort previous requests, and when a response arrives verify that its slug matches the current selectedSlug (or that the controller has not been aborted) before calling setPrdContent; ensure you cancel the previous controller when starting a new fetch so slower earlier responses cannot overwrite the latest selection.src/components/context/ContextPanel.tsx-134-151 (1)
134-151:⚠️ Potential issue | 🟠 MajorDon't relabel stale mock data as live context.
If
useMockDataflips tofalseand the live request fails,datakeeps the oldMOCK_CONTEXT, the error state stays hidden becausedatais still set, and the footer switches to "Live project context". That makes demo payloads look like real project data. Cleardatabefore a live load, or track the active source separately and render the footer/error state from that source instead.Also applies to: 168-181, 309-311
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/context/ContextPanel.tsx` around lines 134 - 151, The fetchContext function currently leaves MOCK_CONTEXT in state when settings.useMockData flips to false and a live fetch fails, causing mock data to be labeled as live; update fetchContext (and the analogous handlers at lines referenced) to clear prior mock data before attempting a live load (e.g., call setData(null) or setData(undefined)) and/or set an explicit source flag (e.g., setContextSource('live'|'mock'|'error')) so the UI renders footer/error based on that source rather than the presence of data; ensure you still set setLoading(true) and setError(null) before the fetch and only setData(json) on successful fetch, and setContextSource('error') on failure.src/hooks/use-terminal-stream.ts-46-49 (1)
46-49:⚠️ Potential issue | 🟠 MajorUse
apiUrl()for the EventSource endpoint too.This hardcodes
/aiox-dashboard, so log streaming breaks whenever the app runs under a different base path or no base path at all. Build the SSE URL through the shared helper here as well so it follows the same routing config as the rest of the dashboard.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hooks/use-terminal-stream.ts` around lines 46 - 49, The SSE endpoint URL is being built with a hardcoded base (`/aiox-dashboard/api/logs`) which breaks when the app base path changes; update the URL construction in use-terminal-stream (the `url` variable that uses `agentId` and `lastTimestampRef.current`) to call the shared apiUrl() helper instead of hardcoding `/aiox-dashboard/api/...`, ensuring query params (agent and since) are encoded via encodeURIComponent and passed to apiUrl so the EventSource will follow the same routing/base-path configuration as the rest of the dashboard.src/app/api/logs/route.ts-117-175 (1)
117-175:⚠️ Potential issue | 🟠 MajorSerialize
readNewLines()before updatingfileOffset.
fs.watch()can fire again while a previousreadNewLines()call is still awaitingstat/open/read, and both paths mutatefileOffsetandpartialLine. Overlap here can re-read or skip bytes, which will show up as duplicated or missing terminal lines. Guard the reader with an in-flight promise or lock.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/logs/route.ts` around lines 117 - 175, readNewLines is not serialized and concurrent invocations can race on shared state (fileOffset, partialLine), causing duplicated or missing bytes; protect the reader by introducing an in-flight lock/promise (e.g., a boolean like isReading or a Promise currentRead) inside the same scope as readNewLines and early-return if a read is already running, then perform stat/open/read/parse/update while holding the lock and release it when done (also ensure errors release the lock); update references to fileOffset and partialLine only while the lock is held and keep watcher/pollInterval behavior unchanged so multiple watch events simply return when a read is in-flight.src/app/api/logs/route.ts-84-95 (1)
84-95:⚠️ Potential issue | 🟠 MajorUse one resume cursor end-to-end.
sinceis filtered against timestamps parsed from the raw log text, but every streamedTerminalLine.timestampis generated withnew Date().toISOString(). On reconnect the client sends back server receipt time, not the line's log time, so resume can skip or duplicate output depending on the log format. Reuse the same cursor source for both filtering and emitted events, or switch the resume token to a byte offset.Also applies to: 100-105, 147-154
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/logs/route.ts` around lines 84 - 95, The code filters allLines using parsed timestamps from the raw text (allLines and linesToSend) while emitted TerminalLine.timestamp values are generated at receipt, causing resume inconsistencies; change the resume logic to use a single cursor source end-to-end: either (A) switch filtering to use the same timestamp/token carried on emitted TerminalLine objects (use the line.timestamp/TerminalLine.timestamp field instead of parsing the raw string) so reconnect compares the same server-side receipt timestamp, or (B) change the resume token to a stable byte-offset/stream-offset and filter by that instead; update the filtering code around since/linesToSend and any emit logic that sets TerminalLine.timestamp to use the chosen single cursor source consistently (also apply the same change where similar timestamp parsing occurs at the other affected blocks).src/app/api/insights/route.ts-46-52 (1)
46-52:⚠️ Potential issue | 🟠 MajorPopulate
weeklyActivity.storiesbefore returning it.
getWeeklyActivity()initializes every day withstories: 0, and nothing ever increments that field. The API will therefore report zero story activity even when story files exist, which makes the chart misleading. Derive the daily counts from the story timestamps before serializingweeklyActivity.Also applies to: 76-82, 91-93, 171-175
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/insights/route.ts` around lines 46 - 52, getWeeklyActivity currently initializes each day's object with stories: 0 but never increments it; update the function to collect story timestamps, map each story date to the corresponding day in the last 7 days, and increment the stories counter for that day before returning result. Specifically, inside getWeeklyActivity (using the days, activity, result and today variables) parse your story metadata/file timestamps, convert them to Date objects, determine which of the last 7 day buckets each timestamp falls into, and increment either activity[dayKey] or result[i].stories accordingly so weeklyActivity.stories reflects actual story counts.src/app/api/insights/route.ts-61-65 (1)
61-65:⚠️ Potential issue | 🟠 MajorKeep
git logoff the synchronous request path.
execSync()blocks the handler thread for up to five seconds on every/api/insightsrequest. Under concurrent dashboard traffic this can stall the whole Node worker. Use an async subprocess plus caching, or precompute this metric outside the request.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/insights/route.ts` around lines 61 - 65, The code is blocking request handling by calling execSync in the insights route handler; replace the synchronous call with an async subprocess and add short-lived caching: use util.promisify(require('child_process').exec) (or child_process.spawn wrapped in a Promise) to run the git log command asynchronously (refer to execSync -> exec, and the variable output/projectRoot) and implement an in-memory cache (e.g., gitDatesCache + gitDatesCacheTs) to return cached results for a configurable TTL (e.g., 30–60s) to avoid running the command on every request; ensure you preserve cwd and timeout options and handle errors by returning an empty result or previous cached value.src/hooks/use-terminal-stream.ts-145-158 (1)
145-158:⚠️ Potential issue | 🟠 MajorMark the terminal disconnected when
enabledgoes false.The effect cleanup closes the socket, but it never clears the store state or
status, so a disabled stream can still render as connected. Reusedisconnect()for the disable/cleanup path instead of duplicating a partial teardown.♻️ Suggested fix
useEffect(() => { - if (enabled) { - connect(); - } - - return () => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - if (eventSourceRef.current) { - eventSourceRef.current.close(); - } - }; -}, [connect, enabled]); + if (!enabled) { + disconnect(); + return; + } + + connect(); + return disconnect; +}, [connect, disconnect, enabled]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hooks/use-terminal-stream.ts` around lines 145 - 158, The cleanup currently closes sockets but doesn't update the terminal store/status when enabled flips false; replace the manual teardown in the useEffect cleanup with a call to the existing disconnect() function so the store and status are reset too. Specifically, in the effect that depends on connect and enabled (in use-terminal-stream.ts), call disconnect() in the return cleanup path (and when disabling the stream) instead of only clearing reconnectTimeoutRef.current and closing eventSourceRef.current so the terminal state is properly marked disconnected.src/app/api/plans/route.ts-14-17 (1)
14-17:⚠️ Potential issue | 🟠 MajorPass the
requestobject toresolveProjectRoot()to enable project switching.The route's
GET()handler doesn't receive the request parameter needed for multi-project support.resolveProjectRoot()accepts an optionalNextRequestto check?project=<name>query parameters, but without passing it, the function falls back to the default project and ignores the query parameter.Update
src/app/api/plans/route.tsline 16 to pass the request:-export async function GET() { +export async function GET(request: NextRequest) { try { - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request);(Note:
src/app/api/github/route.tsline 31 correctly demonstrates this pattern.)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/plans/route.ts` around lines 14 - 17, The GET() route handler is not passing the incoming request to resolveProjectRoot(), so project switching via ?project= is ignored; update the GET() signature to accept the request parameter and call resolveProjectRoot(request) (matching the pattern used in github route), ensuring resolveProjectRoot receives the NextRequest so it can read query params and return the correct projectRoot.src/app/api/context/route.ts-1-5 (1)
1-5:⚠️ Potential issue | 🟠 MajorThis endpoint never honors
?project=.
resolveProjectRoot()only reads the query string when you pass it the current request. Because thisGET()drops that argument,/api/context?project=foostill serves the default project.Suggested fix
-import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; @@ -export async function GET() { +export async function GET(request: NextRequest) { try { - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request);Also applies to: 223-225
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/context/route.ts` around lines 1 - 5, The GET route drops the request when calling resolveProjectRoot so query params like ?project=foo are ignored; update the handler(s) that call resolveProjectRoot (e.g., the exported GET function and the other handler around the same file that also calls resolveProjectRoot) to pass the incoming Request object (the first parameter) into resolveProjectRoot so it can read req.url/searchParams, i.e., change resolveProjectRoot() -> resolveProjectRoot(request) wherever currently invoked without arguments.src/app/api/roadmap/route.ts-1-5 (1)
1-5:⚠️ Potential issue | 🟠 MajorThis route also drops the selected project.
Without forwarding the incoming request into
resolveProjectRoot(),?project=can never select a non-default repository here.Suggested fix
-import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; @@ -export async function GET() { +export async function GET(request: NextRequest) { try { - const projectRoot = await resolveProjectRoot(); + const projectRoot = await resolveProjectRoot(request);Also applies to: 156-158
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/roadmap/route.ts` around lines 1 - 5, The route is currently calling resolveProjectRoot without passing the incoming Request, so the query param ?project cannot be handled; update calls to resolveProjectRoot(...) to pass the Request object (the handler's req) so it can read the project query (e.g., change resolveProjectRoot() to resolveProjectRoot(req)); make this change for both the initial import usage and the other occurrence around the block referenced (the calls that currently omit arguments), ensuring any variable names (req/request) match the route handler signature.src/app/api/roadmap/route.ts-27-43 (1)
27-43:⚠️ Potential issue | 🟠 MajorRoadmap item IDs collide across files.
parseRoadmapFile()resets IDs for every markdown file, butGET()concatenates all items fromdocs/roadmap. Any client keyed byitem.idwill collide as soon as there is more than one file.Suggested fix
async function parseRoadmapFile(filePath: string): Promise<RoadmapItem[]> { const content = await fs.readFile(filePath, 'utf-8'); const items: RoadmapItem[] = []; + const fileId = path.basename(filePath, path.extname(filePath)); @@ - id: `roadmap-${i + 1}`, + id: `${fileId}-${i + 1}`, @@ - id: `roadmap-${itemIndex}`, + id: `${fileId}-${itemIndex}`, @@ - id: `roadmap-${itemIndex}`, + id: `${fileId}-${itemIndex}`, @@ - id: `roadmap-${itemIndex}`, + id: `${fileId}-${itemIndex}`, @@ - id: `roadmap-${itemIndex}`, + id: `${fileId}-${itemIndex}`,Also applies to: 67-76, 90-99, 117-126, 138-147, 165-169
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/roadmap/route.ts` around lines 27 - 43, parseRoadmapFile currently generates ids starting at "roadmap-1" per file causing collisions when GET() aggregates multiple files; change id generation in parseRoadmapFile to include a file-unique prefix (e.g., derive slug from the filePath or filename using path.basename or a provided fileSlug) and then append the per-file index (e.g., `${slug}-${i+1}`) so every RoadmapItem id is globally unique; update all similar id-generating blocks (the other ranges noted) to use the same filename-based prefix convention so aggregated lists from GET() contain non-colliding ids.src/app/api/context/route.ts-31-35 (1)
31-35:⚠️ Potential issue | 🟠 MajorRelative file paths are based on the wrong root.
scanFiles()currently relativizes againstAIOS_PROJECT_ROOT/process.cwd()instead of the resolvedprojectRoot. For any registered project outside that fallback path, this API will return misleadingpathvalues.Suggested fix
async function scanFiles( + projectRoot: string, dir: string, type: ContextFile['type'], descFn: (name: string) => string, ): Promise<ContextFile[]> { @@ - path: path.relative(process.env.AIOS_PROJECT_ROOT || path.resolve(process.cwd(), '..', '..'), fullPath), + path: path.relative(projectRoot, fullPath), @@ - const ruleFiles = await scanFiles(rulesDir, 'rules', (name) => { + const ruleFiles = await scanFiles(projectRoot, rulesDir, 'rules', (name) => { @@ - const agentFiles = await scanFiles(agentsDir, 'agent', (name) => { + const agentFiles = await scanFiles(projectRoot, agentsDir, 'agent', (name) => { @@ - const squadAgentFiles = await scanFiles(squadAgentsDir, 'agent', (name) => { + const squadAgentFiles = await scanFiles(projectRoot, squadAgentsDir, 'agent', (name) => {Also applies to: 47-48, 80-83, 94-97, 107-110
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/context/route.ts` around lines 31 - 35, scanFiles() is computing relative paths against AIOS_PROJECT_ROOT/process.cwd() instead of the actual resolved projectRoot, producing incorrect ContextFile.path values for projects outside that fallback; update scanFiles (and the other occurrences noted) to use the resolved projectRoot variable when relativizing file paths (e.g. use path.relative(projectRoot, absoluteFilePath) and normalize/ensure no leading slashes if your code expects that) so ContextFile.path and any description logic from descFn operate from the real projectRoot rather than the fallback; apply the same replacement for all other places that call path.relative or compute a relative path (lines referenced) so the projectRoot variable is the source of truth.src/lib/project-registry.ts-49-60 (1)
49-60:⚠️ Potential issue | 🟠 MajorAn explicit but unknown project should fail, not fall through.
If the caller asks for
?project=fooandfoois not registered, this silently serves the default project instead. That makes the UI look like it switched repos while actually showing another one.Suggested fix
if (request) { const projectName = request.nextUrl.searchParams.get('project'); if (projectName) { const registry = await loadRegistry(); const entry = registry.projects[projectName]; - if (entry?.path) { - return entry.path; + if (!entry?.path) { + throw new Error(`Unknown project: ${projectName}`); } + return entry.path; } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/project-registry.ts` around lines 49 - 60, The resolveProjectRoot function currently falls back to the default when a ?project=<name> is provided but not found; change the logic so that if request.nextUrl.searchParams.get('project') yields a projectName but loadRegistry() returns no entry or entry.path, the function does not fall through but instead fails explicitly (e.g., throw an Error or reject) with a clear message including the requested projectName; update resolveProjectRoot to check registry.projects[projectName] and entry?.path and raise an error when missing so callers can handle an unknown project rather than silently returning the default.src/lib/project-registry.ts-84-90 (1)
84-90:⚠️ Potential issue | 🟠 MajorValidate and normalize project paths before saving them.
registerProject()persists any string as-is. A typo or relative path poisons the registry, and an arbitrary absolute path becomes trusted input for every filesystem-backed route later.Suggested fix
export async function registerProject(name: string, projectPath: string): Promise<ProjectRegistry> { + const normalizedPath = await fs.realpath(path.resolve(projectPath)); + const stat = await fs.stat(normalizedPath); + if (!stat.isDirectory()) { + throw new Error(`Project path is not a directory: ${normalizedPath}`); + } + const registry = await loadRegistry(); - registry.projects[name] = { path: projectPath }; + registry.projects[name] = { path: normalizedPath }; if (!registry.defaultProject) { registry.defaultProject = name; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/project-registry.ts` around lines 84 - 90, registerProject currently saves projectPath verbatim; validate and normalize it before persisting by resolving it to an absolute, normalized path and verifying it points to an existing directory and is inside the allowed workspace root. In the registerProject function, call path.resolve(projectPath) (or equivalent) to normalize, check fs.stat/exists to ensure it's a directory, and ensure the resolved path is a child of the workspace root (e.g., compare prefixes) to avoid accepting arbitrary absolute paths; only after these checks update registry.projects[name] and call saveRegistry. Keep references to registerProject, loadRegistry, saveRegistry and ProjectRegistry when making changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0dd0f494-bba2-4c1c-a4d3-d9ef1eead669
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (65)
.coderabbit.yaml.github/CODEOWNERS.gitignoreREADME.mdnext.config.tspackage.jsonsrc/app/(dashboard)/context/page.tsxsrc/app/(dashboard)/insights/page.tsxsrc/app/(dashboard)/plans/page.tsxsrc/app/(dashboard)/prds/page.tsxsrc/app/(dashboard)/qa/page.tsxsrc/app/(dashboard)/roadmap/page.tsxsrc/app/api/bob/events/route.tssrc/app/api/bob/status/route.tssrc/app/api/context/route.tssrc/app/api/events/route.tssrc/app/api/github/route.tssrc/app/api/insights/route.tssrc/app/api/logs/route.tssrc/app/api/plans/route.tssrc/app/api/prds/[...slug]/route.tssrc/app/api/prds/route.tssrc/app/api/projects/register/route.tssrc/app/api/projects/route.tssrc/app/api/qa/metrics/route.tssrc/app/api/roadmap/route.tssrc/app/api/status/route.tssrc/app/api/stories/[id]/route.tssrc/app/api/stories/route.tssrc/app/layout.tsxsrc/app/page.tsxsrc/components/backlog/BacklogPanel.tsxsrc/components/backlog/index.tssrc/components/bob/BobOrchestrationView.tsxsrc/components/context/ContextPanel.tsxsrc/components/github/GitHubPanel.tsxsrc/components/insights/InsightsPanel.tsxsrc/components/kanban/KanbanBoard.tsxsrc/components/layout/StatusBar.tsxsrc/components/monitor/MonitorPanel.tsxsrc/components/plans/PlansPanel.tsxsrc/components/plans/index.tssrc/components/prd/PRDPanel.tsxsrc/components/prd/index.tssrc/components/qa/QAMetricsPanel.tsxsrc/components/qa/index.tssrc/components/roadmap/RoadmapView.tsxsrc/components/stories/StoryCard.tsxsrc/components/stories/StoryCreateModal.tsxsrc/components/stories/StoryEditModal.tsxsrc/components/terminals/TerminalStream.tsxsrc/components/ui/markdown-renderer.tsxsrc/hooks/use-agents.tssrc/hooks/use-aios-status.tssrc/hooks/use-realtime-status.tssrc/hooks/use-squads.tssrc/hooks/use-stories.tssrc/hooks/use-terminal-stream.tssrc/lib/api.tssrc/lib/project-registry.tssrc/lib/squad-api-utils.tssrc/middleware.tssrc/stores/settings-store.tssrc/types/index.tstsconfig.json
💤 Files with no reviewable changes (1)
- .github/CODEOWNERS
| // Check if gh CLI is authenticated | ||
| try { | ||
| await execFileAsync('gh', ['auth', 'status']); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
head -100 src/app/api/github/route.tsRepository: SynkraAI/aiox-dashboard
Length of output: 2622
Add timeouts to prevent hung gh subprocess calls from blocking the request handler.
The gh auth status, gh issue list, gh pr list, and gh repo view calls all run on the request path without timeout protection. If any gh or git process hangs, it will pin the route worker indefinitely until the platform terminates the request. Move execOpts before the auth check and add a timeout value to protect all invocations consistently.
Suggested fix
- // Check if gh CLI is authenticated
- try {
- await execFileAsync('gh', ['auth', 'status']);
+ const execOpts = { cwd: projectRoot, timeout: 10_000 };
+ // Check if gh CLI is authenticated
+ try {
+ await execFileAsync('gh', ['auth', 'status'], execOpts);
} catch {
return NextResponse.json(
{
error: 'GitHub CLI not authenticated',
message: 'Run "gh auth login" to authenticate',
},
{ status: 401 }
);
}
// Fetch issues and PRs in parallel, scoped to the project directory
- const execOpts = { cwd: projectRoot };
const [issuesResult, prsResult] = await Promise.allSettled([Also applies to: lines 46–78
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/api/github/route.ts` around lines 33 - 35, Define shared exec options
with a timeout (e.g., create or move the const execOpts before the auth check)
and pass that execOpts to every execFileAsync call (the 'gh auth status', 'gh
issue list', 'gh pr list', and 'gh repo view' invocations) so all subprocess
calls use the same timeout protection; update calls that currently use
execFileAsync(cmd, args) to execFileAsync(cmd, args, execOpts) and choose a
reasonable timeout value to avoid hanging the request handler.
| // Prevent path traversal | ||
| const resolved = path.resolve(filePath); | ||
| const docsRoot = path.resolve(path.join(projectRoot, 'docs')); | ||
| if (!resolved.startsWith(docsRoot)) { | ||
| return NextResponse.json({ error: 'Invalid path' }, { status: 400 }); | ||
| } | ||
|
|
||
| const content = await fs.readFile(filePath, 'utf-8'); |
There was a problem hiding this comment.
Tighten the docs-root boundary check.
resolved.startsWith(docsRoot) also accepts sibling paths like .../docs-backup/..., so a slug such as ../docs-backup/secrets can escape the docs directory. Use a separator-safe containment check and read from the canonical resolved path.
Suggested fix
- const resolved = path.resolve(filePath);
- const docsRoot = path.resolve(path.join(projectRoot, 'docs'));
- if (!resolved.startsWith(docsRoot)) {
+ const resolved = path.resolve(filePath);
+ const docsRoot = path.resolve(projectRoot, 'docs');
+ const relative = path.relative(docsRoot, resolved);
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
}
- const content = await fs.readFile(filePath, 'utf-8');
+ const content = await fs.readFile(resolved, 'utf-8');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/api/prds/`[...slug]/route.ts around lines 16 - 23, The current
startsWith check allows sibling directories like "docs-backup" to pass; instead
compute canonical resolved (path.resolve(filePath)) and canonical docsRoot
(path.resolve(path.join(projectRoot, 'docs'))), then use a separator-safe
containment test such as checking path.relative(docsRoot, resolved) — reject if
it begins with '..' or isAbsolute — and finally use the canonical resolved path
when calling fs.readFile; update the validation around resolved, docsRoot,
filePath, path.resolve, path.relative, and fs.readFile accordingly and return
the same NextResponse.json error on failure.
| // SWR for API fetch (disabled when using mock data) | ||
| const { data, error, isLoading, mutate } = useSWR<StoriesResponse>( | ||
| useMockData ? null : '/api/stories', // null key disables fetching | ||
| useMockData ? null : apiUrl('/api/stories'), // null key disables fetching |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== src/lib/api.ts =="
sed -n '1,220p' src/lib/api.ts
echo
echo "== project-selection sources in client code =="
rg -n "selectedProject|currentProject|project\\b|searchParams|URLSearchParams|window\\.location" src/stores src/hooks src/components src/lib -g '!**/node_modules/**'
echo
echo "== sample apiUrl call sites touched by this PR =="
rg -n "apiUrl\\('/api/(stories|github|squads|events|status)'" srcRepository: SynkraAI/aiox-dashboard
Length of output: 4778
🏁 Script executed:
cat -n src/hooks/use-stories.tsRepository: SynkraAI/aiox-dashboard
Length of output: 3802
🏁 Script executed:
rg -A 8 "const { data, error" src/hooks | head -60Repository: SynkraAI/aiox-dashboard
Length of output: 176
🏁 Script executed:
rg -A 8 "useSWR" src/hooks | head -100Repository: SynkraAI/aiox-dashboard
Length of output: 5160
🏁 Script executed:
rg "useProjectsStore|activeProjectId|projects-store" src/hooksRepository: SynkraAI/aiox-dashboard
Length of output: 49
🏁 Script executed:
rg "useProjectsStore|activeProjectId" src/Repository: SynkraAI/aiox-dashboard
Length of output: 1288
🏁 Script executed:
rg -B 3 -A 10 "export const useStories" src/hooks/use-stories.tsRepository: SynkraAI/aiox-dashboard
Length of output: 49
🏁 Script executed:
grep -r "apiUrl.*?project" src/Repository: SynkraAI/aiox-dashboard
Length of output: 49
🏁 Script executed:
find src -name "*.ts" -path "*/api/*" -o -path "*/app/*" | head -20Repository: SynkraAI/aiox-dashboard
Length of output: 678
🏁 Script executed:
find src -path "*api/stories*" -type fRepository: SynkraAI/aiox-dashboard
Length of output: 129
🏁 Script executed:
cat -n src/app/api/stories/route.tsRepository: SynkraAI/aiox-dashboard
Length of output: 25938
🏁 Script executed:
cat -n src/lib/project-registry.ts | head -100Repository: SynkraAI/aiox-dashboard
Length of output: 3214
Pass the active project to the API endpoint; otherwise switching projects will always fetch from the default project.
The apiUrl() helper only prepends the base path and does not include project selection. The useStories hook does not import or reference useProjectsStore, so the SWR key never includes the activeProjectId. Meanwhile, the server-side handler in /api/stories calls resolveProjectRoot() without passing the NextRequest object, which means it cannot extract the ?project= query param and always defaults to the default project.
To fix multi-project support: either add the project as a query parameter in the SWR key (e.g., apiUrl('/api/stories?project=${activeProjectId}')), import activeProjectId from useProjectsStore, or pass request to resolveProjectRoot() in the API handler so the server can resolve the correct project root based on the query parameter.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/hooks/use-stories.ts` at line 53, The SWR fetch key in useStories
currently uses apiUrl('/api/stories') and never includes the active project, so
switching projects still fetches the default; update the key to include the
activeProjectId (e.g., apiUrl(`/api/stories?project=${activeProjectId}`) or
similar) by importing activeProjectId from useProjectsStore inside useStories,
and/or update the server handler for /api/stories to call
resolveProjectRoot(request) (pass the NextRequest) so resolveProjectRoot can
read the ?project= query param; ensure the unique symbols involved are
useStories, apiUrl('/api/stories'), useProjectsStore / activeProjectId, the
/api/stories handler, and resolveProjectRoot.
LGTM but I‘ll defer to @pedroavalerio |
Auto-Experiment #3: Bundle size optimization via code splitting. Lazy loading (React.lazy + Suspense): - page.tsx: 11 view panels lazy-loaded (only active view loads) - AppLayout: AgentExplorer, GlobalSearch, KeyboardShortcuts lazy-loaded (modal/overlay components only load when opened) Barrel export cleanup (export * → named exports): - stories, kanban, terminals, terminal, roadmap, qa, github, context, insights, settings, agents, workflow index.ts files - Enables better tree-shaking by making imports explicit Build passes, 65/65 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
This PR brings the dashboard from static mock-data to a fully functional, real-data-driven platform:
docs/directory) with mock-data fallbackuse-terminal-streamhook for real-time outputChanges: 66 files (+3,294 / -1,155)
Security
.env*properly gitignoredTest plan
pnpm devdocs/content)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Chores