Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/cd-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Deploy Kaapi Dev to EC2

on:
push:
branches:
- dev

jobs:
deploy:
runs-on: ubuntu-latest
environment: AWS_ENV

permissions:
packages: write
contents: read
attestations: write
id-token: write

steps:
- name: Checkout Repository
uses: actions/checkout@v6

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}

- name: Deploy via SSM
id: ssm
env:
BUILD_DIRECTORY: ${{ secrets.DEV_BUILD_DIRECTORY }}
APP_NAME: ${{ secrets.DEV_PM2_APP_NAME }}
AWS_REGION: ${{ secrets.AWS_REGION }}
INSTANCE_ID: ${{ secrets.EC2_STAGING_INSTANCE_ID }}
ROOT_USER: ${{ secrets.USER }}
run: |
REMOTE_CMD="export HOME=/home/$ROOT_USER && export NVM_DIR="/home/$ROOT_USER/.nvm" && [ -s "\$NVM_DIR/nvm.sh" ] && \. "\$NVM_DIR/nvm.sh" && git config --global --add safe.directory ${BUILD_DIRECTORY} && set -e && cd ${BUILD_DIRECTORY} && git pull origin dev && npm ci && npm run build && sudo -iu ${ROOT_USER} pm2 restart ${APP_NAME}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix malformed shell quoting in REMOTE_CMD.

Line 38 has broken nested quoting around NVM_DIR; this can fail the deploy step before send-command succeeds.

Suggested patch
-          REMOTE_CMD="export HOME=/home/$ROOT_USER && export NVM_DIR="/home/$ROOT_USER/.nvm" && [ -s "\$NVM_DIR/nvm.sh" ] && \. "\$NVM_DIR/nvm.sh" && git config --global --add safe.directory ${BUILD_DIRECTORY} && set -e && cd ${BUILD_DIRECTORY} && git pull origin dev && npm ci && npm run build && sudo -iu ${ROOT_USER} pm2 restart ${APP_NAME}"
+          REMOTE_CMD=$(cat <<EOF
+export HOME=/home/${ROOT_USER} && export NVM_DIR="/home/${ROOT_USER}/.nvm" && [ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh" && git config --global --add safe.directory "${BUILD_DIRECTORY}" && set -e && cd "${BUILD_DIRECTORY}" && git pull origin dev && npm ci && npm run build && sudo -iu "${ROOT_USER}" pm2 restart "${APP_NAME}"
+EOF
+)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
REMOTE_CMD="export HOME=/home/$ROOT_USER && export NVM_DIR="/home/$ROOT_USER/.nvm" && [ -s "\$NVM_DIR/nvm.sh" ] && \. "\$NVM_DIR/nvm.sh" && git config --global --add safe.directory ${BUILD_DIRECTORY} && set -e && cd ${BUILD_DIRECTORY} && git pull origin dev && npm ci && npm run build && sudo -iu ${ROOT_USER} pm2 restart ${APP_NAME}"
REMOTE_CMD=$(cat <<EOF
export HOME=/home/${ROOT_USER} && export NVM_DIR="/home/${ROOT_USER}/.nvm" && [ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh" && git config --global --add safe.directory "${BUILD_DIRECTORY}" && set -e && cd "${BUILD_DIRECTORY}" && git pull origin dev && npm ci && npm run build && sudo -iu "${ROOT_USER}" pm2 restart "${APP_NAME}"
EOF
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cd-dev.yml at line 38, The REMOTE_CMD assignment has
nested double quotes around NVM_DIR which breaks the shell string; update the
REMOTE_CMD value so the outer assignment uses single quotes (or escape the inner
quotes) and keep inner double quotes for paths that need them; locate the
REMOTE_CMD variable in the workflow and change its assignment to a single-quoted
string (or properly escape the inner quotes) so commands like export
NVM_DIR="/home/$ROOT_USER/.nvm" and the subsequent && chained commands (git
config, cd ${BUILD_DIRECTORY}, npm ci, npm run build, pm2 restart ${APP_NAME})
are preserved and evaluated correctly.

CMD_ID=$(aws ssm send-command \
--instance-ids "$INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters commands="[\"$REMOTE_CMD\"]" \
--region "$AWS_REGION" \
--query 'Command.CommandId' \
--output text)
echo "cmd_id=$CMD_ID" >> "$GITHUB_OUTPUT"

- name: Wait for SSM command to finish
env:
INSTANCE_ID: ${{ secrets.EC2_STAGING_INSTANCE_ID }}
CMD_ID: ${{ steps.ssm.outputs.cmd_id }}
run: |
WAIT_EXIT=0
aws ssm wait command-executed \
--command-id "$CMD_ID" \
--instance-id "$INSTANCE_ID" || WAIT_EXIT=$?

aws ssm get-command-invocation \
--command-id "$CMD_ID" \
--instance-id "$INSTANCE_ID" \
--query '{Status:Status,Stdout:StandardOutputContent,Stderr:StandardErrorContent}' \
--output json

exit $WAIT_EXIT
19 changes: 19 additions & 0 deletions app/(main)/assessment/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import { Suspense } from "react";
import { Loader } from "@/app/components/ui";
import PageLayout from "@/app/components/assessment/PageLayout";
import { useAssessmentWorkflow } from "@/app/hooks/useAssessmentWorkflow";

function PageContent() {
const layoutProps = useAssessmentWorkflow();
return <PageLayout {...layoutProps} />;
}

export default function Page() {
return (
<Suspense fallback={<Loader size="lg" message="Loading..." fullScreen />}>
<PageContent />
</Suspense>
);
}
109 changes: 109 additions & 0 deletions app/(main)/assessment/results/[runId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import dynamic from "next/dynamic";
import { Loader } from "@/app/components/ui";
import { useToast } from "@/app/hooks/useToast";
import { useAuth } from "@/app/lib/context/AuthContext";
import { apiFetch } from "@/app/lib/apiClient";
import { jsonResultsToTableData } from "@/app/lib/assessment/results";
import { SPREADSHEET_PREVIEW_ROW_LIMIT } from "@/app/lib/assessment/constants";

const SpreadsheetView = dynamic(
() => import("@/app/components/assessment/SpreadsheetView"),
{
ssr: false,
loading: () => (
<div className="w-full h-screen flex items-center justify-center bg-bg-primary">
<Loader size="lg" message="Loading spreadsheet..." />
</div>
),
},
);

export default function AssessmentResultsPage() {
const params = useParams<{ runId: string }>();
const searchParams = useSearchParams();
const toast = useToast();
const { apiKeys, isAuthenticated, isHydrated } = useAuth();
const apiKey = apiKeys[0]?.key ?? "";

const [headers, setHeaders] = useState<string[] | null>(null);
const [rows, setRows] = useState<string[][] | null>(null);
const [error, setError] = useState<string | null>(null);

const runId = Number(params?.runId);
const title = searchParams.get("title") ?? `Run ${runId}`;

useEffect(() => {
if (!isHydrated) return;
if (!isAuthenticated) {
setError("You must be signed in to view this run.");
return;
}
if (!Number.isFinite(runId) || runId <= 0) {
setError("Invalid run id.");
return;
}

let cancelled = false;
(async () => {
try {
const json = await apiFetch<
{ data?: Record<string, unknown>[] } | Record<string, unknown>[]
>(`/api/assessment/runs/${runId}/results?export_format=json`, apiKey);
const results: Record<string, unknown>[] = Array.isArray(json)
? json
: json.data || [];
const table = jsonResultsToTableData(results, {
rowLimit: SPREADSHEET_PREVIEW_ROW_LIMIT,
});
if (cancelled) return;
if (results.length > SPREADSHEET_PREVIEW_ROW_LIMIT) {
toast.warning(
`Preview capped at ${SPREADSHEET_PREVIEW_ROW_LIMIT} rows. Download CSV for full data.`,
);
}
setHeaders(table.headers);
setRows(table.rows);
} catch (err) {
if (cancelled) return;
const msg =
err instanceof Error ? err.message : "Failed to load results";
setError(msg);
toast.error(msg);
}
})();

return () => {
cancelled = true;
};
}, [apiKey, isAuthenticated, isHydrated, runId, toast]);

if (error) {
return (
<div className="w-full h-screen flex items-center justify-center bg-bg-primary">
<p className="text-sm text-text-secondary">{error}</p>
</div>
);
}

if (!headers || !rows) {
return (
<div className="w-full h-screen flex items-center justify-center bg-bg-primary">
<Loader size="lg" message="Loading results..." />
</div>
);
}

return (
<SpreadsheetView
runId={runId}
title={title}
subtitle={`${rows.length} rows · ${headers.length} columns`}
headers={headers}
rows={rows}
/>
);
}
2 changes: 1 addition & 1 deletion app/(main)/configurations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
configState,
pendingVersionLoads,
pendingSingleVersionLoads,
} from "@/app/lib/store/configStore";
} from "@/app/lib/store/config";
import { flattenConfigVersion } from "@/app/lib/utils";
import {
SearchIcon,
Expand Down
2 changes: 1 addition & 1 deletion app/(main)/configurations/prompt-editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
import { useConfigs } from "@/app/hooks";
import { useConfigPersistence } from "@/app/hooks/useConfigPersistence";
import { SavedConfig, ConfigVersionItems } from "@/app/lib/types/configs";
import { configState } from "@/app/lib/store/configStore";
import { configState } from "@/app/lib/store/config";
import { DEFAULT_CONFIG } from "@/app/lib/constants";

function PromptEditorContent() {

Check warning on line 27 in app/(main)/configurations/prompt-editor/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Function 'PromptEditorContent' has a complexity of 12. Maximum allowed is 10

Check warning on line 27 in app/(main)/configurations/prompt-editor/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Function 'PromptEditorContent' has too many statements (41). Maximum allowed is 20
const searchParams = useSearchParams();
const { sidebarCollapsed } = useApp();
const { activeKey } = useAuth();
Expand Down
2 changes: 1 addition & 1 deletion app/(main)/datasets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@
import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/app/lib/context/AuthContext";
import { useApp } from "@/app/lib/context/AppContext";
import { Dataset } from "@/app/lib/types/dataset";
import { apiFetch } from "@/app/lib/apiClient";
import Sidebar from "@/app/components/Sidebar";
import { PageHeader } from "@/app/components";
import { useToast } from "@/app/hooks/useToast";
import DatasetListing from "@/app/components/datasets/DatasetListing";
import UploadDatasetModal from "@/app/components/datasets/UploadDatasetModal";
import DeleteDatasetModal from "@/app/components/datasets/DeleteDatasetModal";
import { Dataset } from "@/app/lib/types/dataset";

export const DATASETS_STORAGE_KEY = "kaapi_datasets";

const ITEMS_PER_PAGE = 10;

export default function Datasets() {

Check warning on line 24 in app/(main)/datasets/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-build

Function 'Datasets' has too many statements (26). Maximum allowed is 20
const toast = useToast();
const { sidebarCollapsed } = useApp();
const { activeKey: apiKey, isAuthenticated } = useAuth();
Expand Down
2 changes: 0 additions & 2 deletions app/(main)/keystore/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import { useToast } from "@/app/hooks/useToast";
import { apiFetch } from "@/app/lib/apiClient";
import { APIKey } from "@/app/lib/types/credentials";

export const STORAGE_KEY = "kaapi_api_keys";

export default function KaapiKeystore() {
const { sidebarCollapsed } = useApp();
const { apiKeys, addKey, removeKey: removeApiKey } = useAuth();
Expand Down
93 changes: 93 additions & 0 deletions app/api/_routeProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import "server-only";

import { NextResponse } from "next/server";
import { apiClient } from "@/app/lib/apiClient";

const DOWNLOAD_CONTENT_TYPE_HINTS = [
"text/csv",
"spreadsheetml",
"octet-stream",
"application/zip",
];

function isDownloadContentType(contentType: string): boolean {
return DOWNLOAD_CONTENT_TYPE_HINTS.some((hint) => contentType.includes(hint));
}

async function safeParseJson(
response: Response,
): Promise<Record<string, unknown> | unknown[] | null> {
const text = response.status === 204 ? "" : await response.text();
if (!text) return null;

try {
return JSON.parse(text) as Record<string, unknown> | unknown[];
} catch {
return null;
}
}

async function toDownloadResponse(
response: Response,
): Promise<NextResponse | null> {
const contentType = response.headers.get("content-type") || "";
if (!isDownloadContentType(contentType)) {
return null;
}

const blob = await response.blob();
const headers = new Headers();
headers.set("Content-Type", contentType);

const disposition = response.headers.get("content-disposition");
if (disposition) {
headers.set("Content-Disposition", disposition);
}

return new NextResponse(blob, { status: response.status, headers });
}

export function withQueryParams(
endpoint: string,
queryParams: URLSearchParams,
): string {
const query = queryParams.toString();
return query ? `${endpoint}?${query}` : endpoint;
}

export async function proxyJsonResponse(
request: Request,
endpoint: string,
init: RequestInit = {},
): Promise<NextResponse> {
const { status, data } = await apiClient(request, endpoint, init);
return NextResponse.json(data, { status });
}

export async function proxyDownloadOrJsonResponse(
request: Request,
endpoint: string,
init: RequestInit = {},
): Promise<NextResponse> {
const response = await apiClient(request, endpoint, {
...init,
responseType: "raw",
});

const downloadResponse = await toDownloadResponse(response);
if (downloadResponse) {
return downloadResponse;
}

const data = await safeParseJson(response);
return NextResponse.json(data, { status: response.status });
}

export function proxyErrorResponse(
logLabel: string,
error: unknown,
message = "Failed to forward request to backend",
): NextResponse {
console.error(logLabel, error);
return NextResponse.json({ error: message }, { status: 500 });
}
32 changes: 32 additions & 0 deletions app/api/assessment/assessments/[assessment_id]/results/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// BFF proxy — GET /api/v1/assessment/assessments/:id/results (JSON or file download)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// BFF proxy — GET /api/v1/assessment/assessments/:id/results (JSON or file download)

import { NextRequest } from "next/server";
import {
proxyDownloadOrJsonResponse,
proxyErrorResponse,
withQueryParams,
} from "@/app/api/_routeProxy";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ assessment_id: string }> },
) {
try {
const { assessment_id } = await params;
const queryParams = new URLSearchParams(request.nextUrl.searchParams);
queryParams.set("get_trace_info", "true");
return await proxyDownloadOrJsonResponse(
request,
withQueryParams(
`/api/v1/assessment/assessments/${assessment_id}/results`,
queryParams,
),
{ method: "GET" },
);
} catch (error: unknown) {
return proxyErrorResponse(
"Assessment results proxy error:",
error,
"Failed to forward request",
);
}
}
28 changes: 28 additions & 0 deletions app/api/assessment/assessments/[assessment_id]/retry/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// BFF proxy — POST /api/v1/assessment/assessments/:id/retry

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// BFF proxy — POST /api/v1/assessment/assessments/:id/retry

import { NextRequest, NextResponse } from "next/server";
import { apiClient } from "@/app/lib/apiClient";
import type { RouteContext } from "@/app/lib/types/assessment";

export async function POST(
request: NextRequest,
context: RouteContext<"assessment_id">,
) {
try {
const { assessment_id } = await context.params;
const { status, data } = await apiClient(
request,
`/api/v1/assessment/assessments/${assessment_id}/retry`,
{ method: "POST" },
);

return NextResponse.json(data, { status });
} catch (error: unknown) {
console.error("Assessment retry proxy error:", error);
return NextResponse.json(
{
error: "Failed to forward assessment retry request",
},
{ status: 500 },
);
}
}
Loading
Loading