diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4c314c8d..d596ad1a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,6 +21,7 @@ - **Temp files**: Use `tmp/` folder for experiments (gitignored) - **Build packages**: Use `pnpm build` (builds all packages) - **Containers**: `docker compose -f docker-compose.dev.yml up web-dev -d --wait` +- **Validating**: Use `pnpm validate` - **Testing**: Use `pnpm test` ### Task Tracking @@ -28,6 +29,7 @@ - **Must create devlogs**: For features, refactoring, or multistep work (>30min) - **Required progress updates**: Add notes after successful builds, major changes, or blockers - **Always complete**: Document learnings and close devlogs when work is finished +- **Required details**: Include necessary information in devlogs for comprehensive context ## ๐ŸŽฏ Essential Patterns diff --git a/.github/instructions/all.instructions.md b/.github/instructions/all.instructions.md index 203ecf78..1f603ae1 100644 --- a/.github/instructions/all.instructions.md +++ b/.github/instructions/all.instructions.md @@ -355,9 +355,6 @@ export class ProjectService { if (!this.database.isInitialized) { await this.database.initialize(); } - - // Create default project if it doesn't exist - await this.createDefaultProject(); } async list(): Promise { diff --git a/.github/scripts/publish-dev-packages.sh b/.github/scripts/publish-dev-packages.sh index c2f48d67..f5e53365 100755 --- a/.github/scripts/publish-dev-packages.sh +++ b/.github/scripts/publish-dev-packages.sh @@ -6,8 +6,8 @@ echo "๐Ÿ“ฆ Publishing packages to NPM with dev tag..." # Define all publishable packages (as regular arrays for better compatibility) PACKAGES=( + "core:packages/core" "mcp:packages/mcp" - "core:packages/core" "ai:packages/ai" "cli:packages/cli" ) diff --git a/.github/scripts/setup-node.sh b/.github/scripts/setup-node.sh index 8d629399..59f4172a 100755 --- a/.github/scripts/setup-node.sh +++ b/.github/scripts/setup-node.sh @@ -2,11 +2,6 @@ # Setup Node.js, pnpm, and dependencies with caching set -euo pipefail -NODE_VERSION=${1:-"20"} -PNPM_VERSION=${2:-"10.13.1"} - -echo "๐Ÿ”ง Setting up Node.js $NODE_VERSION and pnpm $PNPM_VERSION..." - # pnpm store path is already set by pnpm/action-setup echo "๐Ÿ“ฆ Installing dependencies..." pnpm install --frozen-lockfile diff --git a/.github/scripts/verify-build.sh b/.github/scripts/verify-build.sh index 9a738be7..4af57a9b 100755 --- a/.github/scripts/verify-build.sh +++ b/.github/scripts/verify-build.sh @@ -38,8 +38,8 @@ else FAILED=1 fi -# Check web package (uses .next-build for standalone builds) -if [ -d "packages/web/.next-build" ]; then +# Check web package +if [ -d "packages/web/.next" ]; then echo "โœ… Web package build artifacts verified" else echo "โŒ Web package build artifacts missing" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b19e80f6..cb0ebfec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,6 +21,8 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + NODE_VERSION: 22 + PNPM_VERSION: 10.13.1 jobs: # Phase 1: Build and Test @@ -29,26 +31,22 @@ jobs: runs-on: ubuntu-latest outputs: cache-key: ${{ steps.cache-key.outputs.key }} - - strategy: - matrix: - node-version: [ 20, 22 ] - + steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Setup Node.js ${{ matrix.node-version }} + - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: 10.13.1 + version: ${{ env.PNPM_VERSION }} run_install: false - name: Generate cache key @@ -68,7 +66,7 @@ jobs: - name: Install dependencies run: ./.github/scripts/setup-node.sh - - name: Build packages (dependency order) + - name: Build packages run: ./.github/scripts/build-packages.sh - name: Run tests @@ -145,34 +143,6 @@ jobs: IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -n1) ./.github/scripts/test-docker.sh "$IMAGE_TAG" - # Phase 3: Security Scan (depends on docker-build) - security-scan: - name: Security Scan - runs-on: ubuntu-latest - needs: docker-build - if: github.event_name != 'pull_request' - permissions: - contents: read - packages: read - security-events: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - format: 'sarif' - output: 'trivy-results.sarif' - - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: 'trivy-results.sarif' - # Phase 4a: NPM Publish Stable (main branch) npm-publish-stable: name: Publish to NPM (Stable) @@ -196,13 +166,13 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: ${{ env.NODE_VERSION }} registry-url: 'https://registry.npmjs.org' - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: 10.13.1 + version: ${{ env.PNPM_VERSION }} run_install: false - name: Restore pnpm cache @@ -280,13 +250,13 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: ${{ env.NODE_VERSION }} registry-url: 'https://registry.npmjs.org' - name: Setup pnpm uses: pnpm/action-setup@v4 with: - version: 10.13.1 + version: ${{ env.PNPM_VERSION }} run_install: false - name: Restore pnpm cache diff --git a/.github/workflows/vscode-automation.yml b/.github/workflows/vscode-automation.yml index fc08a4fe..1b561b92 100644 --- a/.github/workflows/vscode-automation.yml +++ b/.github/workflows/vscode-automation.yml @@ -156,39 +156,4 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} - VCS_REF=${{ github.sha }} - - # Phase 3: Security Scan - security-scan: - name: Security Scan VSCode Automation - runs-on: ubuntu-latest - needs: build-vscode-automation - if: github.event_name != 'pull_request' - permissions: - contents: read - packages: read - security-events: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} - format: 'sarif' - output: 'trivy-results-vscode-automation.sarif' - - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: 'trivy-results-vscode-automation.sarif' + VCS_REF=${{ github.sha }} \ No newline at end of file diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 101fc632..a736a56f 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -13,7 +13,10 @@ "command": "npx", "args": [ "@codervisor/devlog-mcp@dev" - ] + ], + "env": { + // "DEVLOG_BASE_URL": "http://localhost:3200" + } }, "sequential-thinking": { "command": "npx", diff --git a/.vscode/settings.json b/.vscode/settings.json index dbc58ed3..6aa7134e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,12 @@ "chat.agent.maxRequests": 100, "github.copilot.chat.languageContext.typescript.enabled": true, "github.copilot.chat.codesearch.enabled": true, - "github.copilot.chat.scopeSelection": true + "github.copilot.chat.scopeSelection": true, + "chat.mcp.serverSampling": { + "devlog/.vscode/mcp.json: devlog": { + "allowedModels": [ + "copilot/claude-sonnet-4" + ] + } + } } \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev index a636964f..1ca67103 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -7,6 +7,7 @@ RUN apk add --no-cache libc6-compat python3 make g++ curl # Enable pnpm ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" +RUN npm install -g pnpm # Set working directory WORKDIR /app diff --git a/GEMINI.md b/GEMINI.md index 9e82e197..618ef186 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -385,9 +385,6 @@ export class ProjectService { if (!this.database.isInitialized) { await this.database.initialize(); } - - // Create default project if it doesn't exist - await this.createDefaultProject(); } async list(): Promise { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 09ca953d..81b3788f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,7 +15,6 @@ services: - .:/app - /app/node_modules - /app/packages/web/.next - - /app/packages/web/.next-build - './scripts:/app/scripts' env_file: - .env diff --git a/packages/ai/README.md b/packages/ai/README.md index 286dc0ac..fe872740 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -107,9 +107,6 @@ import { const parser = new CopilotParser(); const data = await parser.discoverChatData(); -// Get statistics -const stats = parser.getChatStatistics(data); - // Search content const results = parser.searchChatContent(data, 'async function'); diff --git a/packages/ai/scripts/test-docker-setup.sh b/packages/ai/scripts/test-docker-setup.sh deleted file mode 100755 index 8c376419..00000000 --- a/packages/ai/scripts/test-docker-setup.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash - -# Docker Automation Test Script -# Tests the Docker-based Copilot automation setup - -set -e - -echo "๐Ÿš€ Testing Docker-based Copilot Automation Setup" -echo "================================================" - -# Check if GITHUB_TOKEN is set -if [ -z "$GITHUB_TOKEN" ]; then - echo "โŒ GITHUB_TOKEN environment variable not set" - echo " Set your GitHub token: export GITHUB_TOKEN=your_token_here" - exit 1 -fi - -echo "โœ… GitHub token found" - -# Check Docker installation -echo -n "๐Ÿณ Checking Docker installation... " -if command -v docker >/dev/null 2>&1; then - echo "โœ… Docker found" -else - echo "โŒ Docker not found" - echo " Install Docker: https://docs.docker.com/get-docker/" - exit 1 -fi - -# Check if Docker daemon is running -echo -n "๐Ÿ”„ Checking Docker daemon... " -if docker info >/dev/null 2>&1; then - echo "โœ… Docker daemon running" -else - echo "โŒ Docker daemon not running" - echo " Start Docker Desktop or dockerd service" - exit 1 -fi - -# Test Docker functionality -echo -n "๐Ÿงช Testing Docker functionality... " -if docker run --rm hello-world >/dev/null 2>&1; then - echo "โœ… Docker working" -else - echo "โŒ Docker test failed" - exit 1 -fi - -# Check available resources -echo -n "๐Ÿ’พ Checking system resources... " -AVAILABLE_RAM=$(free -m | awk 'NR==2{printf "%.0f", $7/1024}') -if [ "$AVAILABLE_RAM" -gt 2 ]; then - echo "โœ… ${AVAILABLE_RAM}GB RAM available" -else - echo "โš ๏ธ Low RAM: ${AVAILABLE_RAM}GB (recommend 2GB+)" -fi - -# Test AI automation package -echo -n "๐Ÿ“ฆ Testing @codervisor/devlog-ai package... " -if npx @codervisor/devlog-ai automation test-setup >/dev/null 2>&1; then - echo "โœ… Package test passed" -else - echo "โŒ Package test failed" - echo " Run: pnpm --filter @codervisor/devlog-ai build" - exit 1 -fi - -# Pull base Docker image -echo -n "๐Ÿ“ฅ Pulling Ubuntu base image... " -if docker pull ubuntu:22.04 >/dev/null 2>&1; then - echo "โœ… Base image ready" -else - echo "โŒ Failed to pull base image" - echo " Check internet connection" - exit 1 -fi - -echo "" -echo "๐ŸŽ‰ Docker automation environment ready!" -echo "" -echo "Next steps:" -echo " 1. List available scenarios:" -echo " npx @codervisor/devlog-ai automation scenarios" -echo "" -echo " 2. Run a quick test:" -echo " npx @codervisor/devlog-ai automation run --scenarios algorithms --count 2" -echo "" -echo " 3. Run comprehensive testing:" -echo " npx @codervisor/devlog-ai automation run --scenarios algorithms,api,testing --language javascript" -echo "" -echo " 4. Custom automation (programmatic):" -echo " node examples/automation-examples.js" -echo "" diff --git a/packages/ai/src/__tests__/exporters.test.ts b/packages/ai/src/__tests__/exporters.test.ts deleted file mode 100644 index add5becd..00000000 --- a/packages/ai/src/__tests__/exporters.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Tests for Exporters - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { readFile, rm, mkdir } from 'fs/promises'; -import { resolve } from 'path'; -import { JSONExporter } from '../exporters/json.js'; -import { MarkdownExporter } from '../exporters/markdown.js'; -import type { ChatStatistics } from '../parsers/index.js'; - -const TEST_OUTPUT_DIR = resolve(process.cwd(), 'test-output'); - -describe('JSONExporter', () => { - let exporter: JSONExporter; - - beforeEach(async () => { - exporter = new JSONExporter(); - await mkdir(TEST_OUTPUT_DIR, { recursive: true }); - }); - - afterEach(async () => { - try { - await rm(TEST_OUTPUT_DIR, { recursive: true }); - } catch { - // Ignore cleanup errors - } - }); - - it('should export data to JSON file', async () => { - const testData = { - test: 'data', - number: 42, - array: [1, 2, 3], - }; - - const outputPath = resolve(TEST_OUTPUT_DIR, 'test.json'); - await exporter.exportData(testData, outputPath); - - const fileContent = await readFile(outputPath, 'utf-8'); - const parsedData = JSON.parse(fileContent); - - expect(parsedData).toEqual(testData); - }); - - it('should handle Date objects in JSON export', async () => { - const testDate = new Date('2023-01-01T00:00:00.000Z'); - const testData = { - timestamp: testDate, - }; - - const outputPath = resolve(TEST_OUTPUT_DIR, 'test-date.json'); - await exporter.exportData(testData, outputPath); - - const fileContent = await readFile(outputPath, 'utf-8'); - const parsedData = JSON.parse(fileContent); - - expect(parsedData.timestamp).toBe('2023-01-01T00:00:00.000Z'); - }); -}); - -describe('MarkdownExporter', () => { - let exporter: MarkdownExporter; - - beforeEach(async () => { - exporter = new MarkdownExporter(); - await mkdir(TEST_OUTPUT_DIR, { recursive: true }); - }); - - afterEach(async () => { - try { - await rm(TEST_OUTPUT_DIR, { recursive: true }); - } catch { - // Ignore cleanup errors - } - }); - - it('should export statistics to Markdown', async () => { - const stats: ChatStatistics = { - total_sessions: 2, - total_messages: 5, - message_types: { user: 2, assistant: 3 }, - session_types: { chat_session: 2 }, - workspace_activity: {}, - date_range: { - earliest: '2023-01-01T00:00:00.000Z', - latest: '2023-01-02T00:00:00.000Z', - }, - agent_activity: { 'GitHub Copilot': 2 }, - }; - - const exportData = { statistics: stats }; - const outputPath = resolve(TEST_OUTPUT_DIR, 'stats.md'); - - await exporter.exportChatData(exportData, outputPath); - - const fileContent = await readFile(outputPath, 'utf-8'); - - expect(fileContent).toContain('# GitHub Copilot Chat History'); - expect(fileContent).toContain('**Total Sessions:** 2'); - expect(fileContent).toContain('**Total Messages:** 5'); - expect(fileContent).toContain('user: 2'); - expect(fileContent).toContain('assistant: 3'); - }); -}); diff --git a/packages/ai/src/__tests__/fixtures/vscode-test-data/workspaceStorage/test-workspace-1/workspace.json b/packages/ai/src/__tests__/fixtures/vscode-test-data/workspaceStorage/test-workspace-1/workspace.json new file mode 100644 index 00000000..2b42fd2f --- /dev/null +++ b/packages/ai/src/__tests__/fixtures/vscode-test-data/workspaceStorage/test-workspace-1/workspace.json @@ -0,0 +1,4 @@ +{ + "folder": "file:///Users/testuser/projects/test-project", + "configuration": {} +} diff --git a/packages/ai/src/__tests__/models.test.ts b/packages/ai/src/__tests__/models.test.ts deleted file mode 100644 index 97a9e79e..00000000 --- a/packages/ai/src/__tests__/models.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Tests for AI Models - */ - -import { describe, it, expect } from 'vitest'; -import { MessageData, ChatSessionData, WorkspaceDataContainer } from '../models/index.js'; - -describe('MessageData', () => { - it('should create a message with required fields', () => { - const message = new MessageData({ - role: 'user', - content: 'Hello, world!', - }); - - expect(message.role).toBe('user'); - expect(message.content).toBe('Hello, world!'); - expect(message.timestamp).toBeInstanceOf(Date); - expect(message.metadata).toEqual({}); - }); - - it('should serialize to dict correctly', () => { - const message = new MessageData({ - id: 'msg-1', - role: 'assistant', - content: 'Hello back!', - timestamp: new Date('2023-01-01T00:00:00.000Z'), - metadata: { type: 'assistant_response' }, - }); - - const dict = message.toDict(); - - expect(dict).toEqual({ - id: 'msg-1', - role: 'assistant', - content: 'Hello back!', - timestamp: '2023-01-01T00:00:00.000Z', - metadata: { type: 'assistant_response' }, - }); - }); - - it('should deserialize from dict correctly', () => { - const dict = { - id: 'msg-1', - role: 'user', - content: 'Test message', - timestamp: '2023-01-01T00:00:00.000Z', - metadata: { type: 'user_request' }, - }; - - const message = MessageData.fromDict(dict); - - expect(message.id).toBe('msg-1'); - expect(message.role).toBe('user'); - expect(message.content).toBe('Test message'); - expect(message.timestamp).toEqual(new Date('2023-01-01T00:00:00.000Z')); - expect(message.metadata).toEqual({ type: 'user_request' }); - }); -}); - -describe('ChatSessionData', () => { - it('should create a session with required fields', () => { - const session = new ChatSessionData({ - agent: 'GitHub Copilot', - }); - - expect(session.agent).toBe('GitHub Copilot'); - expect(session.timestamp).toBeInstanceOf(Date); - expect(session.messages).toEqual([]); - expect(session.metadata).toEqual({}); - }); - - it('should handle messages correctly', () => { - const messages = [ - new MessageData({ role: 'user', content: 'Hello' }), - new MessageData({ role: 'assistant', content: 'Hi there!' }), - ]; - - const session = new ChatSessionData({ - agent: 'GitHub Copilot', - messages, - session_id: 'session-1', - }); - - expect(session.messages).toHaveLength(2); - expect(session.session_id).toBe('session-1'); - }); -}); - -describe('WorkspaceDataContainer', () => { - it('should create workspace data with required fields', () => { - const workspace = new WorkspaceDataContainer({ - agent: 'GitHub Copilot', - }); - - expect(workspace.agent).toBe('GitHub Copilot'); - expect(workspace.chat_sessions).toEqual([]); - expect(workspace.metadata).toEqual({}); - }); - - it('should handle chat sessions correctly', () => { - const sessions = [ - new ChatSessionData({ agent: 'GitHub Copilot', session_id: 'session-1' }), - new ChatSessionData({ agent: 'GitHub Copilot', session_id: 'session-2' }), - ]; - - const workspace = new WorkspaceDataContainer({ - agent: 'GitHub Copilot', - chat_sessions: sessions, - workspace_path: '/test/workspace', - }); - - expect(workspace.chat_sessions).toHaveLength(2); - expect(workspace.workspace_path).toBe('/test/workspace'); - }); -}); diff --git a/packages/ai/src/__tests__/parsers/copilot.test.ts b/packages/ai/src/__tests__/parsers/copilot.test.ts new file mode 100644 index 00000000..60ffac53 --- /dev/null +++ b/packages/ai/src/__tests__/parsers/copilot.test.ts @@ -0,0 +1,458 @@ +/** + * Integration tests for CopilotParser using real VS Code data + * + * These tests are designed to work with actual VS Code installations + * and real Copilot chat history data on the local machine. + * + * NOTE: These tests are NOT meant to run in CI - they're integration + * tests that require actual VS Code data to be present. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { stat } from 'fs/promises'; +import { platform } from 'os'; +import { CopilotParser } from '../../parsers/copilot.js'; + +describe('CopilotParser Integration Tests', () => { + let parser: CopilotParser; + let hasVSCodeData: boolean = false; + + beforeAll(async () => { + parser = new CopilotParser(); + + // Check if we have VS Code data available + try { + const applications = await parser.getApplications(); + hasVSCodeData = applications.length > 0; + + if (!hasVSCodeData) { + console.warn('No VS Code installations with Copilot data found. Some tests will be skipped.'); + } + } catch (error) { + console.warn('Error checking for VS Code data:', error); + hasVSCodeData = false; + } + }); + + describe('getParserType', () => { + it('should return copilot as parser type', () => { + expect(parser.getParserType()).toBe('copilot'); + }); + }); + + describe('getApplications', () => { + it('should return array of applications', async () => { + const applications = await parser.getApplications(); + + expect(Array.isArray(applications)).toBe(true); + + // If we have VS Code data, validate the structure + if (hasVSCodeData) { + expect(applications.length).toBeGreaterThan(0); + + for (const app of applications) { + expect(app).toHaveProperty('id'); + expect(app).toHaveProperty('name'); + expect(app).toHaveProperty('path'); + expect(app).toHaveProperty('parser', 'copilot'); + expect(app).toHaveProperty('platform', platform()); + expect(typeof app.workspaceCount).toBe('number'); + expect(app.workspaceCount).toBeGreaterThanOrEqual(0); + + // Validate application IDs + expect(['vscode', 'vscode-insiders']).toContain(app.id); + + // Validate that the path exists + await expect(stat(app.path)).resolves.toBeDefined(); + } + } + }); + + it('should handle missing VS Code installations gracefully', async () => { + // This test should always pass, even without VS Code + const applications = await parser.getApplications(); + expect(Array.isArray(applications)).toBe(true); + }); + }); + + describe('getWorkspaces', () => { + it.runIf(() => hasVSCodeData)('should return workspaces for valid application', async () => { + const applications = await parser.getApplications(); + + if (applications.length === 0) { + return; // Skip if no applications + } + + const firstApp = applications[0]; + const workspaces = await parser.getWorkspaces(firstApp.id); + + expect(Array.isArray(workspaces)).toBe(true); + + for (const workspace of workspaces) { + expect(workspace).toHaveProperty('id'); + expect(workspace).toHaveProperty('name'); + expect(workspace).toHaveProperty('applicationId', firstApp.id); + expect(typeof workspace.sessionCount).toBe('number'); + expect(workspace.sessionCount).toBeGreaterThan(0); // Only workspaces with sessions are returned + + // Workspace should have a path + if (workspace.path) { + expect(typeof workspace.path).toBe('string'); + } + } + }); + + it('should return empty array for invalid application ID', async () => { + const workspaces = await parser.getWorkspaces('invalid-app-id'); + expect(workspaces).toEqual([]); + }); + + it('should handle non-existent application gracefully', async () => { + const workspaces = await parser.getWorkspaces('non-existent-app'); + + expect(workspaces).toEqual([]); + }); + }); + + describe('getChatSessions', () => { + it.runIf(() => hasVSCodeData)('should return chat sessions for valid workspace', async () => { + const applications = await parser.getApplications(); + + if (applications.length === 0) { + return; // Skip if no applications + } + + const firstApp = applications[0]; + const workspaces = await parser.getWorkspaces(firstApp.id); + + if (workspaces.length === 0) { + return; // Skip if no workspaces + } + + const firstWorkspace = workspaces[0]; + const sessions = await parser.getChatSessions(firstApp.id, firstWorkspace.id); + + expect(Array.isArray(sessions)).toBe(true); + expect(sessions.length).toBeGreaterThan(0); // We know this workspace has sessions + + for (const session of sessions) { + expect(session).toHaveProperty('id'); + expect(session).toHaveProperty('workspaceId', firstWorkspace.id); + expect(session).toHaveProperty('metadata'); + expect(session).toHaveProperty('createdAt'); + expect(session).toHaveProperty('updatedAt'); + expect(session).toHaveProperty('turns'); + + expect(session.createdAt).toBeInstanceOf(Date); + expect(session.updatedAt).toBeInstanceOf(Date); + expect(Array.isArray(session.turns)).toBe(true); + + // Validate metadata structure + expect(session.metadata).toHaveProperty('version'); + expect(session.metadata).toHaveProperty('requesterUsername'); + expect(session.metadata).toHaveProperty('responderUsername'); + expect(typeof session.metadata.total_requests).toBe('number'); + } + }); + + it('should return empty array for invalid application/workspace', async () => { + const sessions = await parser.getChatSessions('invalid-app', 'invalid-workspace'); + expect(sessions).toEqual([]); + }); + + it('should handle parsing errors gracefully', async () => { + const sessions = await parser.getChatSessions('invalid-app', 'invalid-workspace'); + + expect(sessions).toEqual([]); + }); + }); + + describe('parseChatSession', () => { + it.runIf(() => hasVSCodeData)('should parse individual chat session correctly', async () => { + const applications = await parser.getApplications(); + + if (applications.length === 0) { + return; // Skip if no applications + } + + const firstApp = applications[0]; + const workspaces = await parser.getWorkspaces(firstApp.id); + + if (workspaces.length === 0) { + return; // Skip if no workspaces + } + + const firstWorkspace = workspaces[0]; + const sessions = await parser.getChatSessions(firstApp.id, firstWorkspace.id); + + if (sessions.length === 0) { + return; // Skip if no sessions + } + + const firstSession = sessions[0]; + + // Test direct parsing + const reparsedSession = await parser.parseChatSession( + firstApp.id, + firstWorkspace.id, + firstSession.id + ); + + expect(reparsedSession).not.toBeNull(); + + if (reparsedSession) { + expect(reparsedSession.id).toBe(firstSession.id); + expect(reparsedSession.workspaceId).toBe(firstWorkspace.id); + expect(reparsedSession.turns.length).toBe(firstSession.turns.length); + + // Validate turn structure + for (const turn of reparsedSession.turns) { + expect(turn).toHaveProperty('id'); + expect(turn).toHaveProperty('sessionId', firstSession.id); + expect(turn).toHaveProperty('metadata'); + expect(turn).toHaveProperty('createdAt'); + expect(turn).toHaveProperty('updatedAt'); + expect(turn).toHaveProperty('messages'); + + expect(turn.createdAt).toBeInstanceOf(Date); + expect(turn.updatedAt).toBeInstanceOf(Date); + expect(Array.isArray(turn.messages)).toBe(true); + + // Validate message structure + for (const message of turn.messages) { + expect(message).toHaveProperty('id'); + expect(message).toHaveProperty('turnId', turn.id); + expect(message).toHaveProperty('role'); + expect(message).toHaveProperty('content'); + expect(message).toHaveProperty('timestamp'); + expect(message).toHaveProperty('metadata'); + + expect(['user', 'assistant']).toContain(message.role); + expect(typeof message.content).toBe('string'); + expect(message.timestamp).toBeInstanceOf(Date); + } + } + } + }); + + it('should return null for non-existent session', async () => { + const session = await parser.parseChatSession('invalid-app', 'invalid-workspace', 'invalid-session'); + expect(session).toBeNull(); + }); + + it('should handle malformed JSON gracefully', async () => { + const session = await parser.parseChatSession('invalid-app', 'invalid-workspace', 'invalid-session'); + + expect(session).toBeNull(); + }); + }); + + describe('Chat message extraction', () => { + it.runIf(() => hasVSCodeData)('should extract messages with correct structure', async () => { + const applications = await parser.getApplications(); + + if (applications.length === 0) { + return; // Skip if no applications + } + + const firstApp = applications[0]; + const workspaces = await parser.getWorkspaces(firstApp.id); + + if (workspaces.length === 0) { + return; // Skip if no workspaces + } + + const firstWorkspace = workspaces[0]; + const sessions = await parser.getChatSessions(firstApp.id, firstWorkspace.id); + + if (sessions.length === 0) { + return; // Skip if no sessions + } + + const sessionWithMessages = sessions.find(s => + s.turns.some(t => t.messages.length > 0) + ); + + if (!sessionWithMessages) { + return; // Skip if no sessions with messages + } + + const turnWithMessages = sessionWithMessages.turns.find(t => t.messages.length > 0); + + if (!turnWithMessages) { + return; // Skip if no turns with messages + } + + // Test message types + const userMessages = turnWithMessages.messages.filter(m => m.role === 'user'); + const assistantMessages = turnWithMessages.messages.filter(m => m.role === 'assistant'); + + expect(userMessages.length).toBeGreaterThanOrEqual(0); + expect(assistantMessages.length).toBeGreaterThanOrEqual(0); + + // User messages should have user_message type + for (const userMsg of userMessages) { + expect(userMsg.metadata.type).toBe('user_message'); + expect(typeof userMsg.content).toBe('string'); + expect((userMsg.content as string).length).toBeGreaterThan(0); + } + + // Assistant messages should have various types + for (const assistantMsg of assistantMessages) { + expect(assistantMsg.metadata).toHaveProperty('type'); + expect(typeof assistantMsg.metadata.type).toBe('string'); + + // Common assistant message types + const validTypes = [ + 'text_response', + 'tool_preparation', + 'tool_invocation', + 'text_edit', + 'code_edit', + 'undo_stop', + 'inline_reference', + 'unknown_response' + ]; + + expect(validTypes).toContain(assistantMsg.metadata.type); + } + }); + }); + + describe('Error handling and edge cases', () => { + it('should handle empty applications gracefully', async () => { + // Create parser that will find no applications + const testParser = new CopilotParser(); + + // Mock getStoragePaths to return non-existent paths + const originalGetStoragePaths = (testParser as any).getStoragePaths; + (testParser as any).getStoragePaths = () => ['/non/existent/path']; + + const applications = await testParser.getApplications(); + expect(applications).toEqual([]); + + // Restore original method + (testParser as any).getStoragePaths = originalGetStoragePaths; + }); + + it('should handle filesystem errors gracefully', async () => { + // Try to get workspaces for a non-existent application + const workspaces = await parser.getWorkspaces('definitely-does-not-exist'); + expect(workspaces).toEqual([]); + }); + + it('should validate platform detection', () => { + const currentPlatform = platform(); + expect(['win32', 'darwin', 'linux']).toContain(currentPlatform); + + // The parser should handle the current platform + expect(async () => { + await parser.getApplications(); + }).not.toThrow(); + }); + }); + + describe('Performance and scalability', () => { + it.runIf(() => hasVSCodeData)('should handle large numbers of sessions efficiently', async () => { + const startTime = Date.now(); + + const applications = await parser.getApplications(); + + if (applications.length === 0) { + return; // Skip if no applications + } + + const firstApp = applications[0]; + const workspaces = await parser.getWorkspaces(firstApp.id); + + if (workspaces.length === 0) { + return; // Skip if no workspaces + } + + // Get all sessions from all workspaces + let totalSessions = 0; + for (const workspace of workspaces) { + const sessions = await parser.getChatSessions(firstApp.id, workspace.id); + totalSessions += sessions.length; + } + + const endTime = Date.now(); + const duration = endTime - startTime; + + console.log(`Processed ${totalSessions} sessions from ${workspaces.length} workspaces in ${duration}ms`); + + // Performance expectation: should process sessions reasonably quickly + // This is a soft assertion - adjust based on typical data sizes + if (totalSessions > 0) { + const avgTimePerSession = duration / totalSessions; + expect(avgTimePerSession).toBeLessThan(1000); // Less than 1 second per session + } + }); + }); + + describe('Data integrity and consistency', () => { + it.runIf(() => hasVSCodeData)('should maintain consistent data relationships', async () => { + const applications = await parser.getApplications(); + + if (applications.length === 0) { + return; // Skip if no applications + } + + for (const application of applications) { + const workspaces = await parser.getWorkspaces(application.id); + + for (const workspace of workspaces) { + expect(workspace.applicationId).toBe(application.id); + + const sessions = await parser.getChatSessions(application.id, workspace.id); + + for (const session of sessions) { + expect(session.workspaceId).toBe(workspace.id); + + for (const turn of session.turns) { + expect(turn.sessionId).toBe(session.id); + + for (const message of turn.messages) { + expect(message.turnId).toBe(turn.id); + } + } + } + } + } + }); + + it.runIf(() => hasVSCodeData)('should have consistent timestamps', async () => { + const applications = await parser.getApplications(); + + if (applications.length === 0) { + return; // Skip if no applications + } + + const firstApp = applications[0]; + const workspaces = await parser.getWorkspaces(firstApp.id); + + if (workspaces.length === 0) { + return; // Skip if no workspaces + } + + const firstWorkspace = workspaces[0]; + const sessions = await parser.getChatSessions(firstApp.id, firstWorkspace.id); + + for (const session of sessions) { + // Session timestamps should be valid dates + expect(session.createdAt).toBeInstanceOf(Date); + expect(session.updatedAt).toBeInstanceOf(Date); + expect(session.updatedAt.getTime()).toBeGreaterThanOrEqual(session.createdAt.getTime()); + + for (const turn of session.turns) { + expect(turn.createdAt).toBeInstanceOf(Date); + expect(turn.updatedAt).toBeInstanceOf(Date); + + for (const message of turn.messages) { + expect(message.timestamp).toBeInstanceOf(Date); + } + } + } + }); + }); +}); \ No newline at end of file diff --git a/packages/ai/src/__tests__/services.test.ts b/packages/ai/src/__tests__/services.test.ts deleted file mode 100644 index 191bc93f..00000000 --- a/packages/ai/src/__tests__/services.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Tests for Services - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { DefaultChatImportService } from '../services/chat-import-service.js'; -import { ChatHubService } from '../services/chat-hub-service.js'; -import type { StorageProvider } from '@codervisor/devlog-core'; - -// Mock storage provider -const mockStorageProvider: StorageProvider = { - saveChatSession: vi.fn(), - saveChatMessages: vi.fn(), - saveChatWorkspace: vi.fn(), -} as any; - -describe('DefaultChatImportService', () => { - let service: DefaultChatImportService; - - beforeEach(() => { - service = new DefaultChatImportService(mockStorageProvider); - vi.clearAllMocks(); - }); - - it('should create service with storage provider', () => { - expect(service).toBeInstanceOf(DefaultChatImportService); - }); - - it('should throw error for unsupported source', async () => { - await expect(service.importFromSource('manual' as any)).rejects.toThrow( - 'Unsupported chat source: manual', - ); - }); -}); - -describe('ChatHubService', () => { - let service: ChatHubService; - - beforeEach(() => { - service = new ChatHubService(mockStorageProvider); - vi.clearAllMocks(); - }); - - it('should create service with storage provider', () => { - expect(service).toBeInstanceOf(ChatHubService); - }); - - it('should ingest empty chat sessions', async () => { - const progress = await service.ingestChatSessions([]); - - expect(progress.status).toBe('completed'); - expect(progress.progress.totalSessions).toBe(0); - expect(progress.progress.processedSessions).toBe(0); - expect(progress.progress.percentage).toBe(0); - }); - - it('should return null for non-existent import progress', async () => { - const result = await service.getImportProgress('non-existent'); - expect(result).toBeNull(); - }); -}); diff --git a/packages/ai/src/exporters/index.ts b/packages/ai/src/exporters/index.ts deleted file mode 100644 index 231647bc..00000000 --- a/packages/ai/src/exporters/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Exporters module - */ - -export { JSONExporter } from './json.js'; -export { MarkdownExporter } from './markdown.js'; -export type { JSONExportOptions } from './json.js'; -export type { MarkdownExportData } from './markdown.js'; diff --git a/packages/ai/src/exporters/json.ts b/packages/ai/src/exporters/json.ts deleted file mode 100644 index 1f895ea7..00000000 --- a/packages/ai/src/exporters/json.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Simple JSON exporter for AI chat data - * - * TypeScript implementation without complex configuration. - */ - -import { writeFile, mkdir } from 'fs/promises'; -import { dirname } from 'path'; - -export interface JSONExportOptions { - indent?: number; - ensureAscii?: boolean; -} - -export class JSONExporter { - private defaultOptions: JSONExportOptions = { - indent: 2, - ensureAscii: false, - }; - - /** - * Export arbitrary data to JSON file - */ - async exportData( - data: Record, - outputPath: string, - options?: JSONExportOptions, - ): Promise { - const exportOptions = { ...this.defaultOptions, ...options }; - - // Ensure output directory exists - await mkdir(dirname(outputPath), { recursive: true }); - - // Convert data to JSON string - const jsonString = JSON.stringify(data, this.jsonReplacer, exportOptions.indent); - - // Write JSON file - await writeFile(outputPath, jsonString, 'utf-8'); - } - - /** - * Export chat data specifically - */ - async exportChatData( - data: Record, - outputPath: string, - options?: JSONExportOptions, - ): Promise { - return this.exportData(data, outputPath, options); - } - - /** - * Custom JSON replacer function for objects that aren't JSON serializable by default - */ - private jsonReplacer(key: string, value: unknown): unknown { - // Handle Date objects - if (value instanceof Date) { - return value.toISOString(); - } - - // Handle objects with toDict method - if ( - value && - typeof value === 'object' && - 'toDict' in value && - typeof (value as any).toDict === 'function' - ) { - return (value as any).toDict(); - } - - // Handle objects with toJSON method - if ( - value && - typeof value === 'object' && - 'toJSON' in value && - typeof (value as any).toJSON === 'function' - ) { - return (value as any).toJSON(); - } - - return value; - } -} diff --git a/packages/ai/src/exporters/markdown.ts b/packages/ai/src/exporters/markdown.ts deleted file mode 100644 index 3c8d2eae..00000000 --- a/packages/ai/src/exporters/markdown.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Simple Markdown exporter for AI chat data - * - * TypeScript implementation without complex configuration. - */ - -import { mkdir, writeFile } from 'fs/promises'; -import { dirname } from 'path'; -import type { ChatStatistics, SearchResult } from '../parsers/index.js'; -import type { ChatSession } from '../models/index.js'; - -export interface MarkdownExportData { - statistics?: ChatStatistics; - chat_data?: { - chat_sessions: ChatSession[]; - }; - search_results?: SearchResult[]; -} - -export class MarkdownExporter { - /** - * Export chat data to Markdown file - */ - async exportChatData(data: MarkdownExportData, outputPath: string): Promise { - const sections: string[] = []; - - // Header - sections.push('# GitHub Copilot Chat History'); - sections.push(''); - sections.push(`**Export Date:** ${new Date().toLocaleString()}`); - sections.push(''); - - // Statistics - const stats = data.statistics; - if (stats) { - sections.push('## Summary'); - sections.push(''); - sections.push(`- **Total Sessions:** ${stats.total_sessions}`); - sections.push(`- **Total Messages:** ${stats.total_messages}`); - - if (stats.date_range?.earliest) { - sections.push( - `- **Date Range:** ${stats.date_range.earliest} to ${stats.date_range.latest}`, - ); - } - - // Session types - if (Object.keys(stats.session_types).length > 0) { - sections.push(''); - sections.push('**Session Types:**'); - for (const [type, count] of Object.entries(stats.session_types)) { - sections.push(`- ${type}: ${count}`); - } - } - - // Message types - if (Object.keys(stats.message_types).length > 0) { - sections.push(''); - sections.push('**Message Types:**'); - for (const [type, count] of Object.entries(stats.message_types)) { - sections.push(`- ${type}: ${count}`); - } - } - - sections.push(''); - } - - // Chat data - const chatData = data.chat_data; - if (chatData?.chat_sessions) { - sections.push('## Chat Sessions'); - sections.push(''); - - const sessionsToShow = Math.min(10, chatData.chat_sessions.length); - for (let i = 0; i < sessionsToShow; i++) { - const session = chatData.chat_sessions[i]; - - const sessionId = (session.session_id || 'Unknown').slice(0, 8); // Truncate for readability - sections.push(`### Session ${i + 1}: ${sessionId}`); - sections.push(''); - sections.push(`- **Agent:** ${session.agent || 'Unknown'}`); - sections.push(`- **Timestamp:** ${session.timestamp || 'Unknown'}`); - - if (session.workspace) { - sections.push(`- **Workspace:** ${session.workspace}`); - } - - sections.push(`- **Messages:** ${(session.messages || []).length}`); - sections.push(''); - - // Messages (limit to first few) - const messages = session.messages || []; - const messagesToShow = Math.min(3, messages.length); - - for (let j = 0; j < messagesToShow; j++) { - const msg = messages[j]; - const role = - (msg.role || 'Unknown').charAt(0).toUpperCase() + (msg.role || 'Unknown').slice(1); - sections.push(`#### Message ${j + 1} (${role})`); - sections.push(''); - - let content = msg.content || ''; - if (content.length > 500) { - content = content.slice(0, 500) + '... [TRUNCATED]'; - } - - sections.push('```'); - sections.push(content); - sections.push('```'); - sections.push(''); - } - - if (messages.length > 3) { - sections.push(`... and ${messages.length - 3} more messages`); - sections.push(''); - } - } - - if (chatData.chat_sessions.length > 10) { - sections.push(`... and ${chatData.chat_sessions.length - 10} more sessions`); - sections.push(''); - } - } - - // Search results - const searchResults = data.search_results; - if (searchResults && searchResults.length > 0) { - sections.push('## Search Results'); - sections.push(''); - - const resultsToShow = Math.min(20, searchResults.length); - for (let i = 0; i < resultsToShow; i++) { - const result = searchResults[i]; - - sections.push(`### Match ${i + 1}`); - sections.push(''); - sections.push(`- **Session:** ${(result.session_id || 'Unknown').slice(0, 8)}`); - sections.push(`- **Role:** ${result.role || 'Unknown'}`); - sections.push(`- **Timestamp:** ${result.timestamp || 'Unknown'}`); - sections.push(''); - sections.push('**Context:**'); - sections.push(''); - sections.push('```'); - sections.push(result.context || ''); - sections.push('```'); - sections.push(''); - } - - if (searchResults.length > 20) { - sections.push(`... and ${searchResults.length - 20} more matches`); - } - } - - const markdownContent = sections.join('\n'); - - // Ensure output directory exists - await mkdir(dirname(outputPath), { recursive: true }); - - // Write to file - await writeFile(outputPath, markdownContent, 'utf-8'); - } -} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 76114967..385745f9 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -4,24 +4,11 @@ * Main entry point for the TypeScript implementation */ -// Export all models -export * from './models/index.js'; +// Export all types +export * from './types/index.js'; // Export all parsers export * from './parsers/index.js'; -// Export all exporters -export * from './exporters/index.js'; - -// Export all services -export * from './services/index.js'; - // Export automation layer export * from './automation/index.js'; - -// Re-export main classes for convenience -export { - MessageData as Message, - ChatSessionData as ChatSession, - WorkspaceDataContainer as ProjectData, -} from './models/index.js'; diff --git a/packages/ai/src/models/index.ts b/packages/ai/src/models/index.ts deleted file mode 100644 index d131611d..00000000 --- a/packages/ai/src/models/index.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Data models for AI Chat processing - * - * TypeScript interfaces and classes for representing chat histories - * focused on core chat functionality. - * - * Note: These models are for internal AI package use. For devlog integration, - * use the types from @codervisor/devlog-core/types/chat. - */ - -import { z } from 'zod'; -import type { - ChatSession as CoreChatSession, - ChatMessage as CoreChatMessage, - AgentType, - ChatRole, - ChatStatus, -} from '@codervisor/devlog-core'; - -// Specific metadata type definitions -export interface MessageMetadata { - type?: 'user_request' | 'assistant_response' | 'editing_session' | 'snapshot'; - agent?: Record; - variableData?: Record; - modelId?: string; - result?: Record; - followups?: unknown[]; - isCanceled?: boolean; - contentReferences?: unknown[]; - codeCitations?: unknown[]; - requestTimestamp?: string; - [key: string]: unknown; // Allow additional properties -} - -export interface ChatSessionMetadata { - version?: string; - requesterUsername?: string; - responderUsername?: string; - initialLocation?: Record; - creationDate?: string; - lastMessageDate?: string; - isImported?: boolean; - customTitle?: string; - type?: 'chat_session' | 'chat_editing_session' | string; // Allow any string for flexibility - source_file?: string; - linearHistoryIndex?: number; - initialFileContents?: unknown[]; - recentSnapshot?: unknown; - total_requests?: number; - [key: string]: unknown; // Allow additional properties -} - -export interface WorkspaceMetadata { - discovered_files_count?: number; - parsing_errors?: string[]; - total_sessions_discovered?: number; - discovery_timestamp?: string; - vscode_installations?: string[]; - [key: string]: unknown; // Allow additional properties -} - -// Zod schemas for runtime validation with more specific metadata -export const MessageSchema = z.object({ - id: z.string().optional(), - role: z.enum(['user', 'assistant']), - content: z.string(), - timestamp: z.string().datetime(), - metadata: z.record(z.unknown()).default({}), -}); - -export const ChatSessionSchema = z.object({ - agent: z.string(), - timestamp: z.string().datetime(), - messages: z.array(MessageSchema).default([]), - workspace: z.string().optional(), - session_id: z.string().optional(), - metadata: z.record(z.unknown()).default({}), -}); - -export const WorkspaceDataSchema = z.object({ - agent: z.string(), - version: z.string().optional(), - workspace_path: z.string().optional(), - chat_sessions: z.array(ChatSessionSchema).default([]), - metadata: z.record(z.unknown()).default({}), -}); - -// TypeScript interfaces -export interface Message { - /** Unique identifier for the message */ - id?: string; - /** Role of the message sender */ - role: 'user' | 'assistant'; - /** Content of the message */ - content: string; - /** Timestamp when the message was created */ - timestamp: Date; - /** Additional metadata */ - metadata: MessageMetadata; -} - -export interface ChatSession { - /** Name of the AI agent (e.g., "copilot", "cursor", "windsurf") */ - agent: string; - /** Timestamp when the session was created */ - timestamp: Date; - /** List of messages in the session */ - messages: Message[]; - /** Workspace identifier or path */ - workspace?: string; - /** Unique session identifier */ - session_id?: string; - /** Additional metadata */ - metadata: ChatSessionMetadata; -} - -export interface WorkspaceData { - /** Name of the AI agent */ - agent: string; - /** Version of the agent or data format */ - version?: string; - /** Path to the workspace */ - workspace_path?: string; - /** List of chat sessions */ - chat_sessions: ChatSession[]; - /** Additional metadata */ - metadata: WorkspaceMetadata; -} - -export interface SearchResult { - session_id?: string; - message_id?: string; - role: string; - timestamp: string; - match_position: number; - context: string; - full_content: string; - metadata: Record; -} - -export interface ChatStatistics { - total_sessions: number; - total_messages: number; - message_types: Record; - session_types: Record; - workspace_activity: Record< - string, - { - sessions: number; - messages: number; - first_seen: string; - last_seen: string; - } - >; - date_range: { - earliest: string | null; - latest: string | null; - }; - agent_activity: Record; -} - -// Utility classes for data manipulation -export class MessageData implements Message { - id?: string; - role: 'user' | 'assistant'; - content: string; - timestamp: Date; - metadata: MessageMetadata; - - constructor(data: Partial & Pick) { - this.id = data.id; - this.role = data.role; - this.content = data.content; - this.timestamp = data.timestamp || new Date(); - this.metadata = data.metadata || {}; - } - - toDict(): Record { - return { - id: this.id, - role: this.role, - content: this.content, - timestamp: this.timestamp.toISOString(), - metadata: this.metadata, - }; - } - - static fromDict(data: Record): MessageData { - const validated = MessageSchema.parse(data); - return new MessageData({ - id: validated.id, - role: validated.role, - content: validated.content, - timestamp: new Date(validated.timestamp), - metadata: validated.metadata as MessageMetadata, - }); - } -} - -export class ChatSessionData implements ChatSession { - agent: string; - timestamp: Date; - messages: Message[]; - workspace?: string; - session_id?: string; - metadata: ChatSessionMetadata; - - constructor(data: Partial & Pick) { - this.agent = data.agent; - this.timestamp = data.timestamp || new Date(); - this.messages = data.messages || []; - this.workspace = data.workspace; - this.session_id = data.session_id; - this.metadata = data.metadata || {}; - } - - toDict(): Record { - return { - agent: this.agent, - timestamp: this.timestamp.toISOString(), - messages: this.messages.map((msg) => - msg instanceof MessageData ? msg.toDict() : new MessageData(msg).toDict(), - ), - workspace: this.workspace, - session_id: this.session_id, - metadata: this.metadata, - }; - } - - static fromDict(data: Record): ChatSessionData { - const validated = ChatSessionSchema.parse(data); - return new ChatSessionData({ - agent: validated.agent, - timestamp: new Date(validated.timestamp), - messages: validated.messages.map((msgData: unknown) => - MessageData.fromDict(msgData as Record), - ), - workspace: validated.workspace, - session_id: validated.session_id, - metadata: validated.metadata as ChatSessionMetadata, - }); - } -} - -export class WorkspaceDataContainer implements WorkspaceData { - agent: string; - version?: string; - workspace_path?: string; - chat_sessions: ChatSession[]; - metadata: WorkspaceMetadata; - - constructor(data: Partial & Pick) { - this.agent = data.agent; - this.version = data.version; - this.workspace_path = data.workspace_path; - this.chat_sessions = data.chat_sessions || []; - this.metadata = data.metadata || {}; - } - - toDict(): Record { - return { - agent: this.agent, - version: this.version, - workspace_path: this.workspace_path, - chat_sessions: this.chat_sessions.map((session) => - session instanceof ChatSessionData - ? session.toDict() - : new ChatSessionData(session).toDict(), - ), - metadata: this.metadata, - }; - } - - static fromDict(data: Record): WorkspaceDataContainer { - const validated = WorkspaceDataSchema.parse(data); - return new WorkspaceDataContainer({ - agent: validated.agent, - version: validated.version, - workspace_path: validated.workspace_path, - chat_sessions: validated.chat_sessions.map((sessionData: unknown) => - ChatSessionData.fromDict(sessionData as Record), - ), - metadata: validated.metadata as WorkspaceMetadata, - }); - } -} diff --git a/packages/ai/src/parsers/base.ts b/packages/ai/src/parsers/base.ts new file mode 100644 index 00000000..da8fea36 --- /dev/null +++ b/packages/ai/src/parsers/base.ts @@ -0,0 +1,51 @@ +/** + * Abstract base class for AI assistant chat history parsers + * + * Provides a common interface for parsing chat history from various AI coding assistants + * like GitHub Copilot, Cursor, Claude Code, etc. + * + * Supports four-level hierarchy: Application โ†’ Workspace โ†’ Session โ†’ Turn โ†’ Message + */ + +import type { ChatSession, ChatTurn, ChatMessage, Application, Workspace, ParserType } from '../types/index.js'; + +/** + * Abstract base class for AI assistant parsers + */ +export abstract class BaseParser { + protected constructor() { + // Base parser constructor + } + + /** + * Get the parser type (e.g. 'github-copilot', 'cursor', etc.) + * Used for metadata and identification + */ + abstract getParserType(): ParserType; + + /** + * Get all available applications (VS Code installations) for this AI assistant + * Returns lightweight application info without workspaces or sessions + */ + abstract getApplications(): Promise; + + /** + * Get all workspaces from a specific application + * Returns lightweight workspace info without sessions + */ + abstract getWorkspaces(applicationId: string): Promise; + + /** + * Get all chat sessions from a specific workspace within an application + */ + abstract getChatSessions(applicationId: string, workspaceId: string): Promise; + + /** + * Parse a single chat session by session ID + */ + protected abstract parseChatSession( + applicationId: string, + workspaceId: string, + sessionId: string, + ): Promise; +} diff --git a/packages/ai/src/parsers/base/ai-assistant-parser.ts b/packages/ai/src/parsers/base/ai-assistant-parser.ts deleted file mode 100644 index cf97370f..00000000 --- a/packages/ai/src/parsers/base/ai-assistant-parser.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Abstract base class for AI assistant chat history parsers - * - * Provides a common interface for parsing chat history from various AI coding assistants - * like GitHub Copilot, Cursor, Claude Code, etc. - */ - -import type { - ChatSession, - ChatStatistics, - SearchResult, - WorkspaceData, -} from '../../models/index.js'; - -// Logger interface for parsers -export interface Logger { - error?(message: string, ...args: unknown[]): void; - - warn?(message: string, ...args: unknown[]): void; - - info?(message: string, ...args: unknown[]): void; - - debug?(message: string, ...args: unknown[]): void; -} - -// Simple console logger implementation -export class SimpleConsoleLogger implements Logger { - error(message: string, ...args: unknown[]): void { - console.error(`[ERROR] ${message}`, ...args); - } - - warn(message: string, ...args: unknown[]): void { - console.warn(`[WARN] ${message}`, ...args); - } - - info(message: string, ...args: unknown[]): void { - console.log(`[INFO] ${message}`, ...args); - } - - debug(message: string, ...args: unknown[]): void { - // Only log debug in development or when DEBUG env var is set - if (process.env.NODE_ENV === 'development' || process.env.DEBUG) { - console.debug(`[DEBUG] ${message}`, ...args); - } - } -} - -/** - * Abstract base class for AI assistant parsers - */ -export abstract class AIAssistantParser { - protected logger: Logger; - - constructor(logger?: Logger) { - this.logger = logger || new SimpleConsoleLogger(); - } - - /** - * Get the name of the AI assistant this parser handles - */ - abstract getAssistantName(): string; - - /** - * Get platform-specific data storage paths for this AI assistant - */ - protected abstract getDataPaths(): string[]; - - /** - * Parse a single chat session file - */ - protected abstract parseChatSession(filePath: string): Promise; - - /** - * Discover and parse all chat data from the assistant's storage - */ - abstract discoverChatData(): Promise; - - /** - * Search for content in chat sessions - */ - searchChatContent( - workspaceData: WorkspaceData, - query: string, - caseSensitive: boolean = false, - ): SearchResult[] { - const results: SearchResult[] = []; - const searchQuery = caseSensitive ? query : query.toLowerCase(); - - for (const session of workspaceData.chat_sessions) { - for (const message of session.messages) { - const content = caseSensitive ? message.content : message.content.toLowerCase(); - - if (content.includes(searchQuery)) { - // Find context around the match - const matchPos = content.indexOf(searchQuery); - const contextStart = Math.max(0, matchPos - 100); - const contextEnd = Math.min(content.length, matchPos + searchQuery.length + 100); - const context = content.slice(contextStart, contextEnd); - - results.push({ - session_id: session.session_id, - message_id: message.id, - role: message.role, - timestamp: message.timestamp.toISOString(), - match_position: matchPos, - context, - full_content: message.content, - metadata: message.metadata, - }); - } - } - } - - return results; - } - - /** - * Get statistics about chat sessions - */ - getChatStatistics(workspaceData: WorkspaceData): ChatStatistics { - const stats: ChatStatistics = { - total_sessions: workspaceData.chat_sessions.length, - total_messages: 0, - message_types: {}, - session_types: {}, - workspace_activity: {}, - date_range: { - earliest: null, - latest: null, - }, - agent_activity: {}, - }; - - const allTimestamps: Date[] = []; - - for (const session of workspaceData.chat_sessions) { - const sessionType = session.metadata.type || 'unknown'; - stats.session_types[sessionType] = (stats.session_types[sessionType] || 0) + 1; - - allTimestamps.push(session.timestamp); - - const agent = session.agent; - stats.agent_activity[agent] = (stats.agent_activity[agent] || 0) + 1; - - // Track workspace activity - const workspace = session.workspace || 'unknown_workspace'; - if (!stats.workspace_activity[workspace]) { - stats.workspace_activity[workspace] = { - sessions: 0, - messages: 0, - first_seen: session.timestamp.toISOString(), - last_seen: session.timestamp.toISOString(), - }; - } - - const workspaceStats = stats.workspace_activity[workspace]; - workspaceStats.sessions += 1; - - // Update workspace date range - if (session.timestamp.toISOString() < workspaceStats.first_seen) { - workspaceStats.first_seen = session.timestamp.toISOString(); - } - if (session.timestamp.toISOString() > workspaceStats.last_seen) { - workspaceStats.last_seen = session.timestamp.toISOString(); - } - - for (const message of session.messages) { - stats.total_messages += 1; - workspaceStats.messages += 1; - - const messageType = message.metadata.type || message.role; - stats.message_types[messageType] = (stats.message_types[messageType] || 0) + 1; - - allTimestamps.push(message.timestamp); - } - } - - if (allTimestamps.length > 0) { - const earliest = new Date(Math.min(...allTimestamps.map((d) => d.getTime()))); - const latest = new Date(Math.max(...allTimestamps.map((d) => d.getTime()))); - stats.date_range.earliest = earliest.toISOString(); - stats.date_range.latest = latest.toISOString(); - } - - return stats; - } -} diff --git a/packages/ai/src/parsers/copilot.ts b/packages/ai/src/parsers/copilot.ts new file mode 100644 index 00000000..c5ed63ca --- /dev/null +++ b/packages/ai/src/parsers/copilot.ts @@ -0,0 +1,584 @@ +/** + * GitHub Copilot chat history parser for VS Code + * + * This module handles parsing GitHub Copilot chat sessions from VS Code's + * JSON storage files to extract actual conversation history. + */ + +import { readFile, stat } from 'fs/promises'; +import { resolve } from 'path'; +import { homedir, platform } from 'os'; +import fg from 'fast-glob'; +import chalk from 'chalk'; +import { + Application, + ChatMessage, + ChatSession, + type ChatSessionMetadata, + ChatTurn, + ParserType, + Workspace, +} from '../types/index.js'; +import { BaseParser } from './base.js'; + +export class CopilotParser extends BaseParser { + constructor() { + super(); + } + + getParserType(): ParserType { + return 'copilot'; + } + + /** + * Get all available applications (VS Code installations) for GitHub Copilot + */ + async getApplications(): Promise { + const vscodePaths = this.getStoragePaths(); + const applications: Application[] = []; + + for (const basePath of vscodePaths) { + try { + await stat(basePath); + + // Determine application type from path + const isInsiders = basePath.includes('Insiders'); + const appId = isInsiders ? 'vscode-insiders' : 'vscode'; + const appName = isInsiders ? 'VS Code Insiders' : 'VS Code'; + + // Count workspaces in this application + const workspaceMapping = await this.getWorkspaceMapping(basePath); + const workspaceCount = Object.keys(workspaceMapping).length; + + if (workspaceCount > 0) { + applications.push({ + id: appId, + name: appName, + path: basePath, + parser: this.getParserType(), + platform: platform(), + workspaceCount, + }); + } + } catch (error) { + // Skip inaccessible paths silently + } + } + + if (applications.length > 0) { + const totalWorkspaces = applications.reduce((sum, app) => sum + (app.workspaceCount || 0), 0); + console.log(chalk.green(`โœ“ Found ${applications.length} VS Code installation(s) with ${totalWorkspaces} total workspaces`)); + } + + return applications; + } + + /** + * Get all workspaces from a specific application + */ + async getWorkspaces(applicationId: string): Promise { + // Find the application path based on applicationId + const targetBasePath = this.getTargetBasePath(applicationId); + if (!targetBasePath) { + return []; + } + + const workspaces: Workspace[] = []; + + try { + // Build workspace mapping to discover workspaces + const workspaceMapping = await this.getWorkspaceMapping(targetBasePath); + + // Count sessions for each workspace + const chatSessionPattern = 'workspaceStorage/*/chatSessions/*.json'; + const sessionFiles = await fg(chatSessionPattern, { cwd: targetBasePath, absolute: true }); + + // Group sessions by workspace ID + const sessionCountByWorkspace: Record = {}; + for (const sessionFile of sessionFiles) { + const pathParts = sessionFile.split('/'); + const workspaceId = pathParts[pathParts.indexOf('workspaceStorage') + 1]; + if (workspaceId) { + sessionCountByWorkspace[workspaceId] = (sessionCountByWorkspace[workspaceId] || 0) + 1; + } + } + + // Create WorkspaceInfo for each discovered workspace + for (const [workspaceId, workspacePath] of Object.entries(workspaceMapping)) { + const sessionCount = sessionCountByWorkspace[workspaceId] || 0; + + // Only include workspaces that have sessions + if (sessionCount > 0) { + workspaces.push({ + id: workspaceId, // Use just the workspace storage ID + name: workspacePath.split('/').pop() || workspacePath, // Use folder name as display name + path: workspacePath, + applicationId, + sessionCount, + }); + } + } + } catch (error) { + // Return empty array on error + } + + return workspaces; + } + + /** + * Get all chat sessions from a specific workspace within an application + */ + async getChatSessions(applicationId: string, workspaceId: string): Promise { + // Find the application path based on applicationId + const targetBasePath = this.getTargetBasePath(applicationId); + if (!targetBasePath) { + return []; + } + + try { + // Look for chat session files in the specific workspace + const chatSessionPattern = `workspaceStorage/${workspaceId}/chatSessions/*.json`; + const sessionFiles = await fg(chatSessionPattern, { cwd: targetBasePath, absolute: true }); + + const sessions: ChatSession[] = []; + for (const sessionFile of sessionFiles) { + const sessionId = sessionFile.split('/').pop()?.replace('.json', ''); + if (!sessionId) { + continue; + } + const session = await this.parseChatSession(applicationId, workspaceId, sessionId); + if (session) { + sessions.push(session); + } + } + + return sessions; + } catch (error) { + return []; + } + } + + /** + * Parse actual chat session from JSON file by session ID + */ + async parseChatSession( + applicationId: string, + workspaceId: string, + sessionId: string, + ): Promise { + const targetBasePath = this.getTargetBasePath(applicationId); + if (!targetBasePath) { + return null; + } + const filePath = resolve( + targetBasePath, + 'workspaceStorage', + workspaceId, + 'chatSessions', + `${sessionId}.json`, + ); + + try { + const fileContent = await readFile(filePath, 'utf-8'); + const sessionData = JSON.parse(fileContent); + + const actualSessionId = sessionData.sessionId || sessionId; + + // Parse timestamps + const creationDate = sessionData.creationDate; + const lastMessageDate = sessionData.lastMessageDate; + + let timestamp: Date; + if (creationDate) { + try { + timestamp = new Date(creationDate.replace('Z', '+00:00')); + } catch { + const fileStats = await stat(filePath); + timestamp = new Date(fileStats.mtime); + } + } else { + const fileStats = await stat(filePath); + timestamp = new Date(fileStats.mtime); + } + + const sessionMetadata: ChatSessionMetadata = { + version: sessionData.version, + requesterUsername: sessionData.requesterUsername, + responderUsername: sessionData.responderUsername, + initialLocation: sessionData.initialLocation, + creationDate, + lastMessageDate, + isImported: sessionData.isImported, + customTitle: sessionData.customTitle, + type: 'chat_session', + source_file: filePath, + total_requests: (sessionData.requests || []).length, + }; + + const session: ChatSession = { + id: actualSessionId, + workspaceId, + title: sessionData.customTitle, // Extract title from customTitle field + metadata: sessionMetadata, + createdAt: timestamp, + updatedAt: timestamp, + turns: this.extractChatTurns(sessionId, sessionData), + }; + + return session; + } catch (error) { + console.error(chalk.red(`Error parsing chat session ${sessionId}:`), error instanceof Error ? error.message : String(error)); + return null; + } + } + + /** + * Get VS Code storage paths based on platform + */ + private getStoragePaths(): string[] { + const home = homedir(); + const paths: string[] = []; + + switch (platform()) { + case 'win32': // Windows + const appDataRoaming = resolve(home, 'AppData', 'Roaming'); + paths.push( + resolve(appDataRoaming, 'Code', 'User'), + resolve(appDataRoaming, 'Code - Insiders', 'User'), + ); + break; + + case 'darwin': // macOS + const applicationSupport = resolve(home, 'Library', 'Application Support'); + paths.push( + resolve(applicationSupport, 'Code', 'User'), + resolve(applicationSupport, 'Code - Insiders', 'User'), + ); + break; + + default: // Linux and others + const config = resolve(home, '.config'); + paths.push(resolve(config, 'Code', 'User'), resolve(config, 'Code - Insiders', 'User')); + break; + } + + return paths; + } + + /** + * Get the base path for the target application based on applicationId + */ + private getTargetBasePath(applicationId: string): string | undefined { + const vscodePaths = this.getStoragePaths(); + for (const basePath of vscodePaths) { + const isInsiders = basePath.includes('Insiders'); + const appId = isInsiders ? 'vscode-insiders' : 'vscode'; + + if (appId === applicationId) { + try { + stat(basePath); + return basePath; + } catch (error) { + // Path not accessible, continue + } + } + } + return undefined; + } + + /** + * Get mapping from workspace storage directory to actual workspace path + */ + private async getWorkspaceMapping(basePath: string): Promise> { + const workspaceMapping: Record = {}; + + try { + const workspaceStoragePath = resolve(basePath, 'workspaceStorage'); + + // Get all workspace directories + const workspaceDirs = await fg('*/', { + cwd: workspaceStoragePath, + onlyDirectories: true, + }); + + for (const workspaceDir of workspaceDirs) { + const workspaceDirPath = resolve(workspaceStoragePath, workspaceDir); + const workspaceJsonPath = resolve(workspaceDirPath, 'workspace.json'); + + try { + const workspaceJsonContent = await readFile(workspaceJsonPath, 'utf-8'); + const workspaceData = JSON.parse(workspaceJsonContent); + + const folderUri = workspaceData.folder || ''; + if (folderUri.startsWith('file://')) { + const folderPath = folderUri.slice(7); // Remove file:// prefix + + // Use the full path as the workspace identifier + workspaceMapping[workspaceDir.replace('/', '')] = folderPath; + } else if (workspaceData.workspace) { + // Multi-root workspace + const workspaceRef = workspaceData.workspace || ''; + workspaceMapping[workspaceDir.replace('/', '')] = `multi-root: ${workspaceRef}`; + } + } catch (error) { + // Failed to read workspace.json, skip this workspace + } + } + } catch (error) { + // Error building workspace mapping + } + + return workspaceMapping; + } + + /** + * Extract all chat turns from a specific session + */ + private extractChatTurns(sessionId: string, sessionData: any): ChatTurn[] { + try { + const turns: ChatTurn[] = []; + + for (const request of sessionData.requests || []) { + const requestTimestamp = new Date( + request.timestamp || sessionData.creationDate || Date.now(), + ); + + const turnId = request.requestId; + + const turn: ChatTurn = { + id: turnId, + sessionId, + metadata: { + turnType: 'request_response_cycle', + status: request.isCanceled ? 'cancelled' : 'completed', + startedAt: requestTimestamp, + completedAt: requestTimestamp, + requestId: request.requestId, + responseId: request.responseId, + modelId: sessionData.modelId || request.modelId, + isCanceled: request.isCanceled, + userRequest: request.message, + variableData: request.variableData, + followups: request.followups, + contentReferences: request.contentReferences, + codeCitations: request.codeCitations, + timings: request.result?.timings, + agentInfo: request.agent, + messageCount: (request.response || []).length + 1, // +1 for user message + }, + createdAt: requestTimestamp, + updatedAt: requestTimestamp, + messages: this.extractChatMessages(turnId, request), + }; + + turns.push(turn); + } + + return turns; + } catch (error) { + return []; + } + } + + /** + * Extract all chat messages from a specific turn + */ + private extractChatMessages(turnId: string, requestData: any): ChatMessage[] { + try { + const requestTimestamp = new Date(requestData.timestamp || Date.now()); + const messages: ChatMessage[] = []; + + // User message + const userMessageText = requestData.message?.text || ''; + if (userMessageText) { + const userMessage: ChatMessage = { + id: `${requestData.requestId}_user`, + turnId, + role: 'user', + content: userMessageText, + timestamp: requestTimestamp, + metadata: { + type: 'user_message', + variableData: requestData.variableData || {}, + }, + }; + messages.push(userMessage); + } + + // Parse assistant responses (response is an array) + if (requestData.response && Array.isArray(requestData.response)) { + for (let i = 0; i < requestData.response.length; i++) { + const responseItem = requestData.response[i]; + const responseMessage = this.parseResponseItem( + responseItem, + requestData.requestId, + i, + requestTimestamp, + turnId, + ); + if (responseMessage) { + messages.push(responseMessage); + } + } + } + + return messages; + } catch (error) { + return []; + } + } + + /** + * Parse a single response item from the response array + */ + private parseResponseItem( + responseItem: any, + requestId: string, + index: number, + timestamp: Date, + turnId: string, + ): ChatMessage | null { + if (!responseItem) return null; + + const messageId = `${requestId}_response_${index}`; + + // Handle different response kinds + switch (responseItem.kind) { + case 'prepareToolInvocation': + return { + id: messageId, + turnId, + role: 'assistant', + content: `Preparing to use tool: ${responseItem.toolName || 'unknown'}`, + timestamp, + metadata: { + type: 'tool_preparation', + toolName: responseItem.toolName, + kind: responseItem.kind, + }, + }; + + case 'toolInvocationSerialized': + const toolMessage = + responseItem.invocationMessage?.value || + responseItem.pastTenseMessage?.value || + 'Tool invocation'; + return { + id: messageId, + turnId, + role: 'assistant', + content: toolMessage, + timestamp, + metadata: { + type: 'tool_invocation', + toolId: responseItem.toolId, + toolCallId: responseItem.toolCallId, + isConfirmed: responseItem.isConfirmed, + isComplete: responseItem.isComplete, + resultDetails: responseItem.resultDetails, + toolSpecificData: responseItem.toolSpecificData, + kind: responseItem.kind, + invocationMessage: responseItem.invocationMessage, + pastTenseMessage: responseItem.pastTenseMessage, + originMessage: responseItem.originMessage, + }, + }; + + case 'textEditGroup': + return { + id: messageId, + turnId, + role: 'assistant', + content: `Made text edits to ${responseItem.uri?.path || 'file'}`, + timestamp, + metadata: { + type: 'text_edit', + uri: responseItem.uri, + edits: responseItem.edits, + done: responseItem.done, + kind: responseItem.kind, + }, + }; + + case 'codeblockUri': + return { + id: messageId, + turnId, + role: 'assistant', + content: `Modified code in ${responseItem.uri?.path || 'file'}`, + timestamp, + metadata: { + type: 'code_edit', + uri: responseItem.uri, + kind: responseItem.kind, + }, + }; + + case 'undoStop': + return { + id: messageId, + turnId, + role: 'assistant', + content: 'Undo stop marker', + timestamp, + metadata: { + type: 'undo_stop', + kind: responseItem.kind, + }, + }; + + case 'inlineReference': + const refPath = responseItem.inlineReference?.path || 'unknown file'; + return { + id: messageId, + turnId, + role: 'assistant', + content: `Referenced: ${refPath}`, + timestamp, + metadata: { + type: 'inline_reference', + inlineReference: responseItem.inlineReference, + kind: responseItem.kind, + }, + }; + + default: + // Handle plain text responses (no kind property) + if (responseItem.value) { + return { + id: messageId, + turnId, + role: 'assistant', + content: responseItem.value, + timestamp, + metadata: { + type: 'text_response', + value: responseItem.value, + supportThemeIcons: responseItem.supportThemeIcons, + supportHtml: responseItem.supportHtml, + baseUri: responseItem.baseUri, + uris: responseItem.uris, + kind: responseItem.kind || 'text', + }, + }; + } + + // Handle other unknown response types + if (responseItem.kind) { + return { + id: messageId, + turnId, + role: 'assistant', + content: `Unknown response type: ${responseItem.kind}`, + timestamp, + metadata: { + type: 'unknown_response', + kind: responseItem.kind, + }, + }; + } + + return null; + } + } +} diff --git a/packages/ai/src/parsers/copilot/copilot-parser.ts b/packages/ai/src/parsers/copilot/copilot-parser.ts deleted file mode 100644 index 6e615a49..00000000 --- a/packages/ai/src/parsers/copilot/copilot-parser.ts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * GitHub Copilot chat history parser for VS Code - * - * This module handles parsing GitHub Copilot chat sessions from VS Code's - * JSON storage files to extract actual conversation history. - */ - -import { readFile, stat } from 'fs/promises'; -import { resolve } from 'path'; -import { homedir, platform } from 'os'; -import fg from 'fast-glob'; -import { - ChatSession, - ChatSessionData, - Message, - MessageData, - WorkspaceData, - WorkspaceDataContainer, -} from '../../models/index.js'; -import { AIAssistantParser, Logger } from '../base/ai-assistant-parser.js'; - -export class CopilotParser extends AIAssistantParser { - constructor(logger?: Logger) { - super(logger); - } - - getAssistantName(): string { - return 'GitHub Copilot'; - } - - /** - * Get VS Code storage paths based on platform - */ - protected getDataPaths(): string[] { - const home = homedir(); - const paths: string[] = []; - - switch (platform()) { - case 'win32': // Windows - const appDataRoaming = resolve(home, 'AppData', 'Roaming'); - paths.push( - resolve(appDataRoaming, 'Code', 'User'), - resolve(appDataRoaming, 'Code - Insiders', 'User'), - ); - break; - - case 'darwin': // macOS - const applicationSupport = resolve(home, 'Library', 'Application Support'); - paths.push( - resolve(applicationSupport, 'Code', 'User'), - resolve(applicationSupport, 'Code - Insiders', 'User'), - ); - break; - - default: // Linux and others - const config = resolve(home, '.config'); - paths.push(resolve(config, 'Code', 'User'), resolve(config, 'Code - Insiders', 'User')); - break; - } - - return paths; - } - - /** - * Build mapping from workspace storage directory to actual workspace path - */ - private async buildWorkspaceMapping(basePath: string): Promise> { - const workspaceMapping: Record = {}; - - try { - const workspaceStoragePath = resolve(basePath, 'workspaceStorage'); - - // Get all workspace directories - const workspaceDirs = await fg('*/', { - cwd: workspaceStoragePath, - onlyDirectories: true, - }); - - for (const workspaceDir of workspaceDirs) { - const workspaceDirPath = resolve(workspaceStoragePath, workspaceDir); - const workspaceJsonPath = resolve(workspaceDirPath, 'workspace.json'); - - try { - const workspaceJsonContent = await readFile(workspaceJsonPath, 'utf-8'); - const workspaceData = JSON.parse(workspaceJsonContent); - - const folderUri = workspaceData.folder || ''; - if (folderUri.startsWith('file://')) { - const folderPath = folderUri.slice(7); // Remove file:// prefix - - // Use the full path as the workspace identifier - workspaceMapping[workspaceDir.replace('/', '')] = folderPath; - this.logger.debug?.(`Mapped workspace ${workspaceDir} -> ${folderPath}`); - } else if (workspaceData.workspace) { - // Multi-root workspace - const workspaceRef = workspaceData.workspace || ''; - workspaceMapping[workspaceDir.replace('/', '')] = `multi-root: ${workspaceRef}`; - this.logger.debug?.(`Mapped workspace ${workspaceDir} -> multi-root: ${workspaceRef}`); - } - } catch (error) { - this.logger.debug?.( - `Failed to read workspace.json from ${workspaceJsonPath}:`, - error instanceof Error ? error.message : String(error), - ); - } - } - } catch (error) { - this.logger.error?.( - 'Error building workspace mapping:', - error instanceof Error ? error.message : String(error), - ); - } - - return workspaceMapping; - } - - /** - * Parse actual chat session from JSON file - */ - async parseChatSession(filePath: string): Promise { - try { - const fileContent = await readFile(filePath, 'utf-8'); - const data = JSON.parse(fileContent); - - const sessionId = - data.sessionId || resolve(filePath).split('/').pop()?.replace('.json', '') || ''; - - // Parse timestamps - const creationDate = data.creationDate; - const lastMessageDate = data.lastMessageDate; - - let timestamp: Date; - if (creationDate) { - try { - timestamp = new Date(creationDate.replace('Z', '+00:00')); - } catch { - const fileStats = await stat(filePath); - timestamp = new Date(fileStats.mtime); - } - } else { - const fileStats = await stat(filePath); - timestamp = new Date(fileStats.mtime); - } - - // Extract messages from requests - const messages: Message[] = []; - for (const request of data.requests || []) { - // User message - const userMessageText = request.message?.text || ''; - if (userMessageText) { - const userMessage = new MessageData({ - role: 'user', - content: userMessageText, - timestamp, - id: request.requestId, - metadata: { - type: 'user_request', - agent: request.agent || {}, - variableData: request.variableData || {}, - modelId: request.modelId, - }, - }); - messages.push(userMessage); - } - - // Assistant response - const response = request.response; - if (response) { - let responseText = ''; - if (typeof response === 'object') { - if ('value' in response) { - responseText = response.value; - } else if ('text' in response) { - responseText = response.text; - } else if ('content' in response) { - responseText = response.content; - } - } else if (typeof response === 'string') { - responseText = response; - } - - if (responseText) { - const assistantMessage = new MessageData({ - role: 'assistant', - content: responseText, - timestamp, - id: request.responseId, - metadata: { - type: 'assistant_response', - result: request.result || {}, - followups: request.followups || [], - isCanceled: request.isCanceled || false, - contentReferences: request.contentReferences || [], - codeCitations: request.codeCitations || [], - requestTimestamp: request.timestamp, - }, - }); - messages.push(assistantMessage); - } - } - } - - const sessionMetadata = { - version: data.version, - requesterUsername: data.requesterUsername, - responderUsername: data.responderUsername, - initialLocation: data.initialLocation, - creationDate, - lastMessageDate, - isImported: data.isImported, - customTitle: data.customTitle, - type: 'chat_session', - source_file: filePath, - total_requests: (data.requests || []).length, - }; - - const session = new ChatSessionData({ - agent: 'GitHub Copilot', - timestamp, - messages, - workspace: undefined, // Will be set by caller - session_id: sessionId, - metadata: sessionMetadata, - }); - - this.logger.info?.(`Parsed chat session ${sessionId} with ${messages.length} messages`); - return session; - } catch (error) { - this.logger.error?.( - `Error parsing chat session ${filePath}:`, - error instanceof Error ? error.message : String(error), - ); - return null; - } - } - - /** - * Parse chat editing session from state.json file (legacy format) - */ - async parseChatEditingSession(filePath: string): Promise { - try { - const fileContent = await readFile(filePath, 'utf-8'); - const data = JSON.parse(fileContent); - - const sessionId = data.sessionId || ''; - const fileStats = await stat(filePath); - const timestamp = new Date(fileStats.mtime); - - // Extract messages from linear history - const messages: Message[] = []; - for (let i = 0; i < (data.linearHistory || []).length; i++) { - const historyEntry = data.linearHistory[i]; - const requestId = historyEntry.requestId || `request_${i}`; - const workingSet = historyEntry.workingSet || []; - const entries = historyEntry.entries || []; - - // Create a descriptive message for the editing session - let content = `Chat editing session with ${workingSet.length} files in working set`; - if (entries.length > 0) { - content += ` and ${entries.length} entries`; - } - - const message = new MessageData({ - role: 'user', // Changed from 'system' to match interface - content, - timestamp, - id: requestId, - metadata: { - workingSet, - entries, - type: 'editing_session', - }, - }); - messages.push(message); - } - - // Add information about recent snapshot - const recentSnapshot = data.recentSnapshot || {}; - if (Object.keys(recentSnapshot).length > 0) { - const snapshotMessage = new MessageData({ - role: 'assistant', - content: `Recent snapshot with ${(recentSnapshot.workingSet || []).length} files`, - timestamp, - id: `snapshot_${sessionId}`, - metadata: { - recentSnapshot, - type: 'snapshot', - }, - }); - messages.push(snapshotMessage); - } - - const sessionMetadata = { - version: data.version, - linearHistoryIndex: data.linearHistoryIndex, - initialFileContents: data.initialFileContents || [], - recentSnapshot, - type: 'chat_editing_session', - source_file: filePath, - }; - - const session = new ChatSessionData({ - agent: 'GitHub Copilot', - timestamp, - messages, - workspace: undefined, // Will be set by caller - session_id: sessionId, - metadata: sessionMetadata, - }); - - this.logger.info?.( - `Parsed chat editing session ${sessionId} with ${messages.length} entries`, - ); - return session; - } catch (error) { - this.logger.error?.( - `Error parsing chat editing session ${filePath}:`, - error instanceof Error ? error.message : String(error), - ); - return null; - } - } - - /** - * Discover Copilot data from VS Code's application support directory - */ - async discoverChatData(): Promise { - const vscodePaths = this.getDataPaths(); - - // Collect all data from all VS Code installations - const allData = new WorkspaceDataContainer({ agent: 'GitHub Copilot' }); - - for (const basePath of vscodePaths) { - try { - // Check if path exists - await stat(basePath); - - this.logger.info?.(`Discovering Copilot data from: ${basePath}`); - const data = await this.discoverCopilotData(basePath); - - if (data.chat_sessions.length > 0) { - // Merge the data - allData.chat_sessions.push(...data.chat_sessions); - allData.workspace_path = basePath; // Use the last successful path - - // Merge metadata - for (const [key, value] of Object.entries(data.metadata)) { - if (key in allData.metadata) { - if (Array.isArray(allData.metadata[key]) && Array.isArray(value)) { - (allData.metadata[key] as unknown[]).push(...value); - } else { - allData.metadata[`${key}_${basePath.split('/').pop()}`] = value; - } - } else { - allData.metadata[key] = value; - } - } - } - } catch (error) { - // Path doesn't exist, continue to next - this.logger.debug?.(`VS Code path not found: ${basePath}`); - } - } - - if (allData.chat_sessions.length === 0) { - this.logger.warn?.('No chat sessions found in any VS Code installation'); - } else { - this.logger.info?.(`Total discovered: ${allData.chat_sessions.length} chat sessions`); - } - - return allData; - } - - /** - * Discover and parse all Copilot data in a directory - */ - async discoverCopilotData(basePath: string): Promise { - const workspaceData = new WorkspaceDataContainer({ - agent: 'GitHub Copilot', - workspace_path: basePath, - metadata: { discovery_source: basePath }, - }); - - // Build workspace mapping from workspace.json files - const workspaceMapping = await this.buildWorkspaceMapping(basePath); - this.logger.info?.( - `Built workspace mapping with ${Object.keys(workspaceMapping).length} workspaces`, - ); - - // Look for actual chat session JSON files (new format) - const chatSessionPattern = 'workspaceStorage/*/chatSessions/*.json'; - const sessionFiles = await fg(chatSessionPattern, { cwd: basePath, absolute: true }); - - for (const sessionFile of sessionFiles) { - const session = await this.parseChatSession(sessionFile); - if (session) { - // Extract workspace from file path using mapping - const pathParts = sessionFile.split('/'); - const workspaceId = pathParts[pathParts.indexOf('workspaceStorage') + 1]; - - if (workspaceId && workspaceMapping[workspaceId]) { - session.workspace = workspaceMapping[workspaceId]; - } - workspaceData.chat_sessions.push(session); - } - } - - // Look for chat editing session files (legacy format) - const editingSessionPattern = 'workspaceStorage/*/chatEditingSessions/*/state.json'; - const editingSessionFiles = await fg(editingSessionPattern, { cwd: basePath, absolute: true }); - - for (const sessionFile of editingSessionFiles) { - const session = await this.parseChatEditingSession(sessionFile); - if (session) { - // Extract workspace from file path using mapping - const pathParts = sessionFile.split('/'); - const workspaceStorageIndex = pathParts.indexOf('workspaceStorage'); - const workspaceId = pathParts[workspaceStorageIndex + 1]; - - if (workspaceId && workspaceMapping[workspaceId]) { - session.workspace = workspaceMapping[workspaceId]; - } - workspaceData.chat_sessions.push(session); - } - } - - this.logger.info?.( - `Discovered ${workspaceData.chat_sessions.length} chat sessions from ${basePath}`, - ); - return workspaceData; - } - - // Legacy method name for backwards compatibility - async discoverVSCodeCopilotData(): Promise { - return this.discoverChatData(); - } -} diff --git a/packages/ai/src/parsers/index.ts b/packages/ai/src/parsers/index.ts index 1f3987c9..7f027dc5 100644 --- a/packages/ai/src/parsers/index.ts +++ b/packages/ai/src/parsers/index.ts @@ -2,14 +2,14 @@ * AI Chat History Parsers * * This module provides parsers for various AI coding assistants and their chat histories. - * Currently supports GitHub Copilot, with planned support for Cursor, Claude Code, and others. + * Currently, supports GitHub Copilot, with planned support for Cursor, Claude Code, and others. */ // Export base classes -export { AIAssistantParser, Logger, SimpleConsoleLogger } from './base/ai-assistant-parser.js'; +export * from './base.js'; -// Export provider-specific parsers -export { CopilotParser } from './copilot/copilot-parser.js'; +// Export copilot parser +export * from './copilot.js'; -// Re-export types from models for convenience -export type { SearchResult, ChatStatistics } from '../models/index.js'; +// Export utility functions and types +export * from './utils.js'; diff --git a/packages/ai/src/parsers/utils.ts b/packages/ai/src/parsers/utils.ts new file mode 100644 index 00000000..7d830ab2 --- /dev/null +++ b/packages/ai/src/parsers/utils.ts @@ -0,0 +1,6 @@ +/** + * Utility functions and types for AI parsers + */ + +// This file is reserved for parser utility functions +// Logger functionality has been removed in favor of direct chalk usage diff --git a/packages/ai/src/services/chat-hub-service.ts b/packages/ai/src/services/chat-hub-service.ts deleted file mode 100644 index 570896b6..00000000 --- a/packages/ai/src/services/chat-hub-service.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Chat import service for importing chat history from various sources - * - * This service handles importing chat data through ChatHub (GitHub Copilot, etc.) - * into the devlog storage system with proper workspace mapping and linking. - */ - -import type { - ChatDevlogLink, - ChatImportProgress, - ChatMessage, - ChatSession, - ChatSessionId, - ChatSource, -} from '@codervisor/devlog-core'; - -// Define workspace info type instead of using any -interface WorkspaceInfo { - id: string; - name: string; - path?: string; - source: string; - firstSeen: string; - lastSeen: string; - sessionCount: number; - metadata: Record; -} - -export interface IChatHubService { - /** - * Ingest chat sessions from external clients - */ - ingestChatSessions(sessions: ChatSession[]): Promise; - - /** - * Ingest chat messages from external clients - */ - ingestChatMessages(messages: ChatMessage[]): Promise; - - /** - * Process bulk chat data from external clients - */ - processBulkChatData(data: { - sessions: ChatSession[]; - messages: ChatMessage[]; - source: ChatSource; - workspaceInfo?: WorkspaceInfo; - }): Promise; - - /** - * Get import progress by ID - */ - getImportProgress(importId: string): Promise; - - /** - * Suggest links between chat sessions and devlog entries - */ - suggestChatDevlogLinks( - sessionId?: ChatSessionId, - minConfidence?: number, - ): Promise; - - /** - * Auto-link chat sessions to devlog entries based on various heuristics - */ - autoLinkSessions(sessionIds: ChatSessionId[], threshold?: number): Promise; -} - -export class ChatHubService implements IChatHubService { - private activeImports = new Map(); - - async ingestChatSessions(sessions: ChatSession[]): Promise { - const importId = this.generateImportId(); - const progress: ChatImportProgress = { - importId, - status: 'running', - source: sessions[0]?.agent === 'GitHub Copilot' ? 'github-copilot' : 'manual', - progress: { - totalSessions: sessions.length, - processedSessions: 0, - totalMessages: 0, - processedMessages: 0, - percentage: 0, - }, - startedAt: new Date().toISOString(), - }; - - this.activeImports.set(importId, progress); - - try { - console.log(`[ChatHub] Ingesting ${sessions.length} chat sessions`); - - for (const session of sessions) { - // await this.storageProvider.saveChatSession(session); - progress.progress.processedSessions++; - progress.progress.percentage = Math.round( - (progress.progress.processedSessions / progress.progress.totalSessions) * 100, - ); - } - - progress.status = 'completed'; - progress.completedAt = new Date().toISOString(); - progress.results = { - importedSessions: sessions.length, - importedMessages: 0, - linkedSessions: 0, - errors: 0, - warnings: [], - }; - - console.log(`[ChatHub] Successfully ingested ${sessions.length} sessions`); - return progress; - } catch (error: unknown) { - console.error('[ChatHub] Error ingesting sessions:', error); - progress.status = 'failed'; - progress.completedAt = new Date().toISOString(); - progress.error = { - message: error instanceof Error ? error.message : 'Unknown error', - details: { stack: error instanceof Error ? error.stack : undefined }, - }; - throw error; - } - } - - async ingestChatMessages(messages: ChatMessage[]): Promise { - try { - console.log(`[ChatHub] Ingesting ${messages.length} chat messages`); - // await this.storageProvider.saveChatMessages(messages); - console.log(`[ChatHub] Successfully ingested ${messages.length} messages`); - } catch (error: unknown) { - console.error('[ChatHub] Error ingesting messages:', error); - throw error; - } - } - - async processBulkChatData(data: { - sessions: ChatSession[]; - messages: ChatMessage[]; - source: ChatSource; - workspaceInfo?: WorkspaceInfo; - }): Promise { - const importId = this.generateImportId(); - const progress: ChatImportProgress = { - importId, - status: 'running', - source: data.source, - progress: { - totalSessions: data.sessions.length, - processedSessions: 0, - totalMessages: data.messages.length, - processedMessages: 0, - percentage: 0, - }, - startedAt: new Date().toISOString(), - }; - - this.activeImports.set(importId, progress); - - try { - console.log( - `[ChatHub] Processing bulk data: ${data.sessions.length} sessions, ${data.messages.length} messages from ${data.source}`, - ); - - // Process workspace info if provided - if (data.workspaceInfo) { - // await this.storageProvider.saveChatWorkspace(data.workspaceInfo); - } - - // Ingest sessions - for (const session of data.sessions) { - // await this.storageProvider.saveChatSession(session); - progress.progress.processedSessions++; - } - - // Ingest messages - if (data.messages.length > 0) { - // await this.storageProvider.saveChatMessages(data.messages); - progress.progress.processedMessages = data.messages.length; - } - - // Update final progress - progress.progress.percentage = 100; - progress.status = 'completed'; - progress.completedAt = new Date().toISOString(); - progress.results = { - importedSessions: data.sessions.length, - importedMessages: data.messages.length, - linkedSessions: 0, // TODO: Implement auto-linking - errors: 0, - warnings: [], - }; - - console.log(`[ChatHub] Successfully processed bulk data from ${data.source}`); - return progress; - } catch (error: unknown) { - console.error('[ChatHub] Error processing bulk data:', error); - progress.status = 'failed'; - progress.completedAt = new Date().toISOString(); - progress.error = { - message: error instanceof Error ? error.message : 'Unknown error', - details: { stack: error instanceof Error ? error.stack : undefined }, - }; - throw error; - } - } - - async getImportProgress(importId: string): Promise { - return this.activeImports.get(importId) || null; - } - - async suggestChatDevlogLinks( - sessionId?: ChatSessionId, - minConfidence = 0.5, - ): Promise { - // Simplified implementation - can be enhanced later - console.log( - `[ChatHub] Suggesting links for session ${sessionId || 'all'} with min confidence ${minConfidence}`, - ); - - // TODO: Implement sophisticated chat-devlog linking logic - // For now, return empty array - this will be enhanced with proper analysis - return []; - } - - async autoLinkSessions(sessionIds: ChatSessionId[], threshold = 0.8): Promise { - // Simplified implementation - can be enhanced later - console.log(`[ChatHub] Auto-linking ${sessionIds.length} sessions with threshold ${threshold}`); - - // TODO: Implement sophisticated auto-linking logic - // For now, return empty array - this will be enhanced with proper analysis - return []; - } - - // Helper methods - - private generateImportId(): string { - return `chathub_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - } -} diff --git a/packages/ai/src/services/chat-import-service.ts b/packages/ai/src/services/chat-import-service.ts deleted file mode 100644 index 69903a43..00000000 --- a/packages/ai/src/services/chat-import-service.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Chat Import Service - * - * Service for importing chat history from AI assistants and converting - * them to the devlog system format. - */ - -import type { - ChatSession as CoreChatSession, - ChatMessage as CoreChatMessage, - ChatImportProgress, - ChatSource, - AgentType, -} from '@codervisor/devlog-core'; - -import { CopilotParser } from '../parsers/copilot/copilot-parser.js'; -import type { WorkspaceData, ChatSession, Message } from '../models/index.js'; - -export interface ChatImportService { - /** - * Import chat history from GitHub Copilot - */ - importFromCopilot(): Promise; - - /** - * Import chat history from a specific source - */ - importFromSource( - source: ChatSource, - config?: Record, - ): Promise; - - /** - * Convert AI package chat data to core package format - */ - convertToCoreChatSessions(workspaceData: WorkspaceData): CoreChatSession[]; - - /** - * Convert AI package messages to core package format - */ - convertToCoreMessages(sessions: ChatSession[]): CoreChatMessage[]; -} - -export class DefaultChatImportService implements ChatImportService { - async importFromCopilot(): Promise { - const importId = this.generateImportId(); - const progress: ChatImportProgress = { - importId, - status: 'running', - source: 'github-copilot', - progress: { - totalSessions: 0, - processedSessions: 0, - totalMessages: 0, - processedMessages: 0, - percentage: 0, - }, - startedAt: new Date().toISOString(), - }; - - try { - // Use CopilotParser to discover chat data - const parser = new CopilotParser(); - const workspaceData = await parser.discoverChatData(); - - progress.progress.totalSessions = workspaceData.chat_sessions.length; - - // Convert to core format - const coreSessions = this.convertToCoreChatSessions(workspaceData); - const coreMessages = this.convertToCoreMessages(workspaceData.chat_sessions); - - progress.progress.totalMessages = coreMessages.length; - - // Save to storage - for (const session of coreSessions) { - // await this.storageProvider.saveChatSession(session); - progress.progress.processedSessions++; - } - - if (coreMessages.length > 0) { - // await this.storageProvider.saveChatMessages(coreMessages); - progress.progress.processedMessages = coreMessages.length; - } - - progress.progress.percentage = 100; - progress.status = 'completed'; - progress.completedAt = new Date().toISOString(); - progress.results = { - importedSessions: coreSessions.length, - importedMessages: coreMessages.length, - linkedSessions: 0, - errors: 0, - warnings: [], - }; - - return progress; - } catch (error: unknown) { - progress.status = 'failed'; - progress.completedAt = new Date().toISOString(); - progress.error = { - message: error instanceof Error ? error.message : 'Unknown error', - details: { stack: error instanceof Error ? error.stack : undefined }, - }; - throw error; - } - } - - async importFromSource( - source: ChatSource, - config?: Record, - ): Promise { - switch (source) { - case 'github-copilot': - return this.importFromCopilot(); - default: - throw new Error(`Unsupported chat source: ${source}`); - } - } - - convertToCoreChatSessions(workspaceData: WorkspaceData): CoreChatSession[] { - return workspaceData.chat_sessions.map((session, index) => ({ - id: session.session_id || `imported_${Date.now()}_${index}`, - agent: session.agent as AgentType, - timestamp: session.timestamp.toISOString(), - workspace: session.workspace, - workspacePath: session.workspace, - title: `Chat Session ${session.session_id?.slice(0, 8) || index}`, - status: 'imported' as const, - messageCount: session.messages.length, - duration: undefined, - metadata: session.metadata, - tags: [], - importedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - linkedDevlogs: [], - archived: false, - })); - } - - convertToCoreMessages(sessions: ChatSession[]): CoreChatMessage[] { - const messages: CoreChatMessage[] = []; - - for (const session of sessions) { - session.messages.forEach((message, index) => { - messages.push({ - id: message.id || `msg_${Date.now()}_${index}`, - sessionId: session.session_id || `session_${Date.now()}`, - role: message.role, - content: message.content, - timestamp: message.timestamp.toISOString(), - sequence: index, - metadata: message.metadata, - searchContent: message.content.toLowerCase().replace(/[^\w\s]/g, ' '), - }); - }); - } - - return messages; - } - - private generateImportId(): string { - return `import_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - } -} diff --git a/packages/ai/src/services/index.ts b/packages/ai/src/services/index.ts deleted file mode 100644 index 4f5ac821..00000000 --- a/packages/ai/src/services/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * AI Services - ChatHub and other AI-related services - */ - -export { ChatHubService, type IChatHubService } from './chat-hub-service.js'; -export { DefaultChatImportService, type ChatImportService } from './chat-import-service.js'; diff --git a/packages/ai/src/types/application.ts b/packages/ai/src/types/application.ts new file mode 100644 index 00000000..02b54271 --- /dev/null +++ b/packages/ai/src/types/application.ts @@ -0,0 +1,26 @@ +/** + * Application and workspace hierarchy types for AI Chat processing + * + * Defines the three-level hierarchy: + * Application (VS Code app) -> Workspace (project) -> Session (chat) + */ +import { ParserType } from './common.js'; + +/** + * Application information (VS Code app type) + * Top level of the hierarchy: Application -> Workspace -> Session + */ +export interface Application { + /** Unique application identifier */ + id: string; + /** Human-readable application name */ + name: string; + /** Path to the application's data directory */ + path: string; + /** Optional: platform type (e.g., "vscode", "cursor") */ + platform?: string; + /** Optional: parser type used for this application */ + parser?: ParserType; + /** Optional: number of workspaces in this application */ + workspaceCount?: number; +} diff --git a/packages/ai/src/types/chat-message.ts b/packages/ai/src/types/chat-message.ts new file mode 100644 index 00000000..62bfe2ed --- /dev/null +++ b/packages/ai/src/types/chat-message.ts @@ -0,0 +1,87 @@ +/** + * ChatMessage model for AI Chat processing + */ + +// Specific metadata type definitions +export interface ChatMessageMetadata { + /** Type of individual message/action within a turn */ + type?: + | 'user_message' // User input or request + | 'assistant_message' // Assistant text response + | 'tool_preparation' // Preparing to use a tool ("prepareToolInvocation") + | 'tool_invocation' // Actually invoking a tool ("toolInvocationSerialized") + | 'tool_result' // Result from tool execution + | 'text_edit' // Individual text edit action ("textEditGroup") + | 'code_edit' // Individual code edit action + | 'inline_reference' // Reference to inline content ("inlineReference") + | 'undo_stop' // Undo operation + | 'text_response' // Plain text response + | 'system_message' // System-generated message + | 'unknown_response'; // Unknown response type + + // Common message fields from Copilot data + value?: string; // Text content of the message + supportThemeIcons?: boolean; // Whether message supports theme icons + supportHtml?: boolean; // Whether message supports HTML + baseUri?: unknown; // Base URI for resolving relative paths + uris?: unknown; // URIs referenced in the message + kind?: string; // Kind of message (e.g., "prepareToolInvocation", "toolInvocationSerialized", "textEditGroup", "inlineReference") + isTrusted?: boolean; // Whether the message is trusted + + // Tool invocation specific fields + toolName?: string; // Name of the tool being invoked + toolId?: string; // Unique identifier for the tool + toolCallId?: string; // Unique identifier for this specific tool call + isConfirmed?: boolean; // Whether the tool invocation was confirmed + isComplete?: boolean; // Whether the tool invocation completed + resultDetails?: unknown; // Detailed results from tool execution + toolSpecificData?: unknown; // Tool-specific data and parameters + invocationMessage?: { + // Message shown during tool invocation + value: string; + isTrusted?: boolean; + supportThemeIcons?: boolean; + supportHtml?: boolean; + }; + pastTenseMessage?: { + // Message shown after tool completion + value: string; + isTrusted?: boolean; + supportThemeIcons?: boolean; + supportHtml?: boolean; + }; + originMessage?: { + // Origin/source of the tool + value: string; + isTrusted?: boolean; + supportThemeIcons?: boolean; + supportHtml?: boolean; + }; + + // File/text editing specific fields + uri?: unknown; // URI of the file being edited + edits?: unknown[]; // Array of edits being applied + done?: boolean; // Whether the edit is complete + inlineReference?: unknown; // Inline reference data + + // Legacy/deprecated fields (kept for backward compatibility) + variableData?: Record; + + [key: string]: unknown; // Allow additional properties +} + +// TypeScript interface +export interface ChatMessage { + /** Unique identifier for the message */ + id?: string; + /** Optional reference to the parent ChatTurn */ + turnId?: string; + /** Role of the message sender */ + role: 'user' | 'assistant' | 'system'; + /** Content of the message - can be text or structured data */ + content: string | unknown; + /** Timestamp when the message was created */ + timestamp: Date; + /** Additional metadata */ + metadata: ChatMessageMetadata; +} diff --git a/packages/ai/src/types/chat-session.ts b/packages/ai/src/types/chat-session.ts new file mode 100644 index 00000000..c9dc72a5 --- /dev/null +++ b/packages/ai/src/types/chat-session.ts @@ -0,0 +1,62 @@ +/** + * Chat Session model - represents a complete conversation session + * Hierarchy: Application โ†’ Workspace โ†’ Chat Session โ†’ Chat Turn โ†’ Chat Message + */ + +import type { ChatTurn } from "./chat-turn.js"; + +// Session-level metadata based on actual Copilot data structure +export interface ChatSessionMetadata { + /** Version of the chat session format */ + version: number; + /** Username of the person making requests */ + requesterUsername: string; + /** Avatar info for the requester */ + requesterAvatarIconUri?: { + $mid?: number; + path?: string; + scheme?: string; + authority?: string; + query?: string; + }; + /** Username of the AI assistant responding */ + responderUsername: string; + /** Avatar info for the responder */ + responderAvatarIconUri?: { + id?: string; + }; + /** Where the session was initiated (panel, editor, etc.) */ + initialLocation?: string; + /** Session creation and last activity dates */ + creationDate?: string; + lastMessageDate?: string; + /** Whether this session was imported from external source */ + isImported?: boolean; + /** Custom title for the session */ + customTitle?: string; + /** Type of session */ + type?: 'chat_session' | 'chat_editing_session' | string; + /** Source file if imported */ + source_file?: string; + /** Total number of requests/turns in the session */ + total_requests?: number; + [key: string]: unknown; // Allow additional properties +} + +// Chat Session represents a complete conversation +export interface ChatSession { + /** Unique identifier for the session */ + id: string; + /** Workspace identifier this session belongs to */ + workspaceId?: string; + /** Custom title for the session */ + title?: string; + /** Session-level metadata */ + metadata: ChatSessionMetadata; + /** When this session was created */ + createdAt: Date; + /** When this session was last updated */ + updatedAt: Date; + /** Chat turns */ + turns: ChatTurn[]; +} diff --git a/packages/ai/src/types/chat-turn.ts b/packages/ai/src/types/chat-turn.ts new file mode 100644 index 00000000..eab3da7a --- /dev/null +++ b/packages/ai/src/types/chat-turn.ts @@ -0,0 +1,98 @@ +/** + * ChatTurn model for grouping related messages in AI Chat processing + * Hierarchy: Application โ†’ Workspace โ†’ Chat Session โ†’ Chat Turn โ†’ Chat Message + * + * A ChatTurn represents one complete request-response cycle: + * - User makes a request (user message) + * - Assistant responds with multiple actions (assistant messages) + */ + +import type { ChatMessage } from "./chat-message.js"; + +// Turn-level metadata based on actual Copilot data structure +export interface ChatTurnMetadata { + /** Type of turn */ + turnType: 'request_response_cycle'; + /** Current status of the turn */ + status: 'in_progress' | 'completed' | 'failed' | 'cancelled'; + /** When the turn started */ + startedAt: Date; + /** When the turn completed (if applicable) */ + completedAt?: Date; + /** Information about the agent handling this turn */ + agentInfo?: { + name?: string; + id?: string; + extensionId?: string; + description?: string; + fullName?: string; + isDefault?: boolean; + locations?: string[]; + modes?: string[]; + }; + /** Model used for this entire turn */ + modelId?: string; + /** Whether the turn was cancelled */ + isCanceled?: boolean; + /** Request/Response IDs from Copilot */ + requestId?: string; + responseId?: string; + /** User's original request message */ + userRequest?: { + text: string; + parts?: Array<{ + range?: { start: number; endExclusive: number }; + text: string; + kind: string; + [key: string]: unknown; + }>; + }; + /** Variable data attached to the request */ + variableData?: { + variables?: Array<{ + id: string; + name: string; + value: unknown; + kind: string; + [key: string]: unknown; + }>; + }; + /** Follow-up suggestions */ + followups?: unknown[]; + /** Content references used in the turn */ + contentReferences?: unknown[]; + /** Code citations */ + codeCitations?: unknown[]; + /** Timing information */ + timings?: { + firstProgress?: number; + totalElapsed?: number; + }; + /** High-level goal or purpose of this turn */ + goal?: string; + /** Total number of messages in this turn */ + messageCount?: number; + /** Any turn-level errors or issues */ + errors?: Array<{ + code: string; + message: string; + timestamp: Date; + }>; + [key: string]: unknown; // Allow additional properties +} + +// ChatTurn represents a complete request-response cycle +export interface ChatTurn { + /** Unique identifier for the turn */ + id: string; + /** Reference to the parent chat session */ + sessionId: string; + /** Turn-level metadata */ + metadata: ChatTurnMetadata; + /** When this turn was created */ + createdAt: Date; + /** When this turn was last updated */ + updatedAt: Date; + /** Chat messages */ + messages: ChatMessage[]; +} diff --git a/packages/ai/src/types/common.ts b/packages/ai/src/types/common.ts new file mode 100644 index 00000000..ac3711f7 --- /dev/null +++ b/packages/ai/src/types/common.ts @@ -0,0 +1 @@ +export type ParserType = 'copilot' | 'cursor' | 'claude-code' | 'gemini'; diff --git a/packages/ai/src/types/index.ts b/packages/ai/src/types/index.ts new file mode 100644 index 00000000..00536e3a --- /dev/null +++ b/packages/ai/src/types/index.ts @@ -0,0 +1,17 @@ +/** + * Data types for AI Chat processing + * + * TypeScript interfaces and classes for representing chat histories + * focused on core chat functionality. + * + * Note: These types are for internal AI package use. For devlog integration, + * use the types from @codervisor/devlog-core/types/chat. + */ + +// Re-export all types from their respective files +export * from './common.js'; +export * from './application.js'; +export * from './workspace.js'; +export * from './chat-session.js'; +export * from './chat-turn.js'; +export * from './chat-message.js'; diff --git a/packages/ai/src/types/workspace.ts b/packages/ai/src/types/workspace.ts new file mode 100644 index 00000000..ee4274b2 --- /dev/null +++ b/packages/ai/src/types/workspace.ts @@ -0,0 +1,20 @@ +/** + * Workspace model for AI Chat processing + */ + +/** + * Lightweight workspace information (without sessions) + * Used for workspace discovery and listing within an application + */ +export interface Workspace { + /** Unique workspace identifier (within the application) */ + id: string; + /** Human-readable workspace name */ + name: string; + /** Path to the workspace */ + path?: string; + /** Parent application identifier */ + applicationId: string; + /** Optional: number of sessions in this workspace */ + sessionCount?: number; +} diff --git a/packages/cli/src/automation.ts b/packages/cli/src/cli/automation.ts similarity index 100% rename from packages/cli/src/automation.ts rename to packages/cli/src/cli/automation.ts diff --git a/packages/cli/src/dev.ts b/packages/cli/src/cli/dev.ts similarity index 100% rename from packages/cli/src/dev.ts rename to packages/cli/src/cli/dev.ts diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts new file mode 100644 index 00000000..9ffcb361 --- /dev/null +++ b/packages/cli/src/cli/index.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +/** + * DevLog CLI - Main Entry Point + */ + +import { Command } from 'commander'; + +const program = new Command(); + +program + .name('devlog') + .description('DevLog CLI - Stream chat history and manage devlog projects') + .version('0.1.0') + .option('-s, --server ', 'DevLog server URL') + .option('-w, --project ', 'Project ID') + .option('-c, --config ', 'Configuration file path') + .option('-v, --verbose', 'Show detailed progress', false); + +// Parse and execute +program.parse(); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1b989fd9..c1bca644 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,472 +1 @@ -#!/usr/bin/env node - -/** - * DevLog CLI - Main Entry Point - * - * Command-line interface for streaming chat history to devlog server - * and managing devlog projects. - */ - -import { Command } from 'commander'; -import chalk from 'chalk'; -import Table from 'cli-table3'; -import ora from 'ora'; -import ProgressBar from 'progress'; -import { - ChatStatistics, - CopilotParser, - SearchResult, - WorkspaceDataContainer, -} from '@codervisor/devlog-ai'; -import { DevlogApiClient, ChatImportRequest } from './api/devlog-api-client.js'; -import { - validateConvertedData, - convertWorkspaceDataToCoreFormat, - extractWorkspaceInfo, -} from './utils/data-mapper.js'; -import { - displayError, - displayHeader, - displayInfo, - displaySuccess, - displayWarning, - formatCount, -} from './utils/display.js'; -import { loadConfig, ConfigOptions } from './utils/config.js'; - -// CLI option interfaces for better type safety -interface BaseCommandOptions { - server?: string; - project?: string; - verbose: boolean; - config?: string; -} - -interface ChatImportOptions extends BaseCommandOptions { - source: string; - autoLink: boolean; - threshold: string; - dryRun: boolean; -} - -interface SearchCommandOptions extends BaseCommandOptions { - limit: string; - caseSensitive: boolean; - searchType: 'exact' | 'fuzzy' | 'semantic'; -} - -const program = new Command(); - -program - .name('devlog') - .description('DevLog CLI - Stream chat history and manage devlog projects') - .version('0.1.0') - .option('-s, --server ', 'DevLog server URL') - .option('-w, --project ', 'Project ID') - .option('-c, --config ', 'Configuration file path') - .option('-v, --verbose', 'Show detailed progress', false); - -// Configuration setup -async function setupApiClient(options: BaseCommandOptions): Promise { - const config = await loadConfig(options.config); - - const serverUrl = options.server || config.server || process.env.DEVLOG_SERVER; - if (!serverUrl) { - displayError( - 'configuration', - 'Server URL is required. Use --server, DEVLOG_SERVER env var, or config file.', - ); - process.exit(1); - } - - return new DevlogApiClient({ - baseURL: serverUrl, - timeout: config.timeout || 30000, - retries: config.retries || 3, - }); -} - -function getProjectId(options: BaseCommandOptions, config: ConfigOptions): string { - const projectId = options.project || process.env.DEVLOG_PROJECT; - if (!projectId) { - displayError( - 'configuration', - 'Project ID is required. Use --project, DEVLOG_PROJECT env var, or config file.', - ); - process.exit(1); - } - return projectId; -} - -// Chat import command -program - .command('chat') - .description('Chat history management commands') - .addCommand( - new Command('import') - .description('Import chat history from local sources to devlog server') - .option( - '-s, --source ', - 'Chat source (github-copilot, cursor, claude)', - 'github-copilot', - ) - .option('--auto-link', 'Automatically link chat sessions to devlog entries', true) - .option('--threshold ', 'Auto-linking confidence threshold', '0.8') - .option('--dry-run', 'Show what would be imported without actually importing', false) - .action(async (options: ChatImportOptions) => { - const spinner = options.verbose ? ora('Connecting to devlog server...').start() : null; - - try { - const config = await loadConfig(options.config); - const apiClient = await setupApiClient(options); - const projectId = getProjectId(options, config); - - // Test connection first - spinner && (spinner.text = 'Testing server connection...'); - const connected = await apiClient.testConnection(); - if (!connected) { - throw new Error('Could not connect to devlog server. Make sure it is running.'); - } - - spinner && (spinner.text = 'Discovering local chat data...'); - - // For now, only support GitHub Copilot - if (options.source !== 'github-copilot') { - throw new Error( - `Source '${options.source}' not yet supported. Only 'github-copilot' is available.`, - ); - } - - const parser = new CopilotParser(); - const projectData = await parser.discoverVSCodeCopilotData(); - - if (projectData.chat_sessions.length === 0) { - spinner?.stop(); - displayError('discovery', 'No GitHub Copilot chat data found'); - displayWarning('Make sure VS Code is installed and you have used GitHub Copilot chat'); - process.exit(1); - } - - spinner?.stop(); - displaySuccess(`Found ${formatCount(projectData.chat_sessions.length)} chat sessions`); - - // Show dry run information - if (options.dryRun) { - const stats = parser.getChatStatistics(projectData); - displayInfo('DRY RUN - No data will be imported'); - displayChatSummary(stats, [], options.verbose); - return; - } - - // Convert AI package data to Core package format - const convertedData = convertWorkspaceDataToCoreFormat( - projectData as WorkspaceDataContainer, - ); - - // Validate the converted data - if (!validateConvertedData(convertedData)) { - throw new Error( - 'Data conversion failed validation. Please check the chat data format.', - ); - } - - // Prepare data for API - const importData: ChatImportRequest = { - sessions: convertedData.sessions, - messages: convertedData.messages, - source: options.source, - workspaceInfo: extractWorkspaceInfo(projectData as WorkspaceDataContainer), - }; - - // Start import - displayInfo(`Importing to project: ${projectId}`); - const progressSpinner = ora('Starting import...').start(); - - const importResponse = await apiClient.importChatData(projectId, importData); - - progressSpinner.stop(); - displaySuccess(`Import started: ${importResponse.importId}`); - - // Track progress - const progressBar = new ProgressBar('Importing [:bar] :current/:total :percent :etas', { - complete: '=', - incomplete: ' ', - width: 40, - total: - importResponse.progress.progress.totalSessions + - importResponse.progress.progress.totalMessages, - }); - - // Poll for progress - let lastProgress = importResponse.progress; - while (lastProgress.status === 'pending' || lastProgress.status === 'processing') { - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const progressResponse = await apiClient.getImportProgress( - projectId, - importResponse.importId, - ); - lastProgress = progressResponse.progress; - - const current = - lastProgress.progress.sessionsProcessed + lastProgress.progress.messagesProcessed; - progressBar.update(current / progressBar.total); - } - - progressBar.terminate(); - - if (lastProgress.status === 'completed') { - displaySuccess(`Import completed successfully!`); - displayInfo( - `Sessions: ${lastProgress.progress.sessionsProcessed}/${lastProgress.progress.totalSessions}`, - ); - displayInfo( - `Messages: ${lastProgress.progress.messagesProcessed}/${lastProgress.progress.totalMessages}`, - ); - } else { - displayError('import', lastProgress.error || 'Import failed'); - process.exit(1); - } - } catch (error) { - spinner?.stop(); - if (options.verbose) { - console.error(error); - } else { - displayError('importing chat data', error); - } - process.exit(1); - } - }), - ) - .addCommand( - new Command('stats') - .description('Show chat statistics from devlog server') - .action(async (options: BaseCommandOptions) => { - try { - const config = await loadConfig(options.config); - const apiClient = await setupApiClient(options); - const projectId = getProjectId(options, config); - - const stats = await apiClient.getChatStats(projectId); - - displayHeader('DevLog Chat Statistics'); - - const table = new Table({ - head: [chalk.cyan('Metric'), chalk.green('Value')], - colWidths: [25, 30], - }); - - table.push( - ['Total Sessions', stats.totalSessions?.toString() || '0'], - ['Total Messages', stats.totalMessages?.toString() || '0'], - ['Unique Agents', stats.uniqueAgents?.toString() || '0'], - ['Projects', stats.projectCount?.toString() || '0'], - ); - - if (stats.dateRange?.earliest) { - table.push(['Date Range', `${stats.dateRange.earliest} to ${stats.dateRange.latest}`]); - } - - console.log(table.toString()); - - // Show additional details if available - if (stats.agentBreakdown && Object.keys(stats.agentBreakdown).length > 0) { - console.log(chalk.bold.blue('\nBy AI Agent:')); - for (const [agent, count] of Object.entries(stats.agentBreakdown)) { - console.log(` โ€ข ${agent}: ${count}`); - } - } - } catch (error) { - displayError('getting statistics', error); - process.exit(1); - } - }), - ) - .addCommand( - new Command('search') - .argument('', 'Search query') - .description('Search chat content on devlog server') - .option('-l, --limit ', 'Maximum results to show', '10') - .option('-c, --case-sensitive', 'Case sensitive search', false) - .option('-t, --search-type ', 'Search type (exact, fuzzy, semantic)', 'exact') - .action(async (query: string, options: SearchCommandOptions) => { - try { - const config = await loadConfig(options.config); - const apiClient = await setupApiClient(options); - const projectId = getProjectId(options, config); - - const searchResults = await apiClient.searchChatContent(projectId, query, { - limit: parseInt(options.limit, 10), - caseSensitive: options.caseSensitive, - searchType: options.searchType, - }); - - if (!searchResults.results || searchResults.results.length === 0) { - console.log(chalk.yellow(`No matches found for '${query}'`)); - return; - } - - console.log(chalk.green(`Found ${searchResults.results.length} matches for '${query}'`)); - - // Display results - for (let i = 0; i < searchResults.results.length; i++) { - const result = searchResults.results[i]; - console.log(chalk.bold.blue(`\nMatch ${i + 1}:`)); - console.log(` Session: ${result.sessionId || 'Unknown'}`); - console.log(` Agent: ${result.agent || 'Unknown'}`); - console.log(` Role: ${result.role || 'Unknown'}`); - if (result.highlightedContent) { - console.log(` Content: ${result.highlightedContent.slice(0, 200)}...`); - } - } - } catch (error) { - displayError('searching', error); - process.exit(1); - } - }), - ); - -// Project management commands -program - .command('project') - .description('Project management commands') - .addCommand( - new Command('list') - .description('List available projects on server') - .action(async (options: BaseCommandOptions) => { - try { - const apiClient = await setupApiClient(options); - const projects = await apiClient.listProjects(); - - if (projects.length === 0) { - console.log(chalk.yellow('No projects found')); - return; - } - - displayHeader('Available Projects'); - - const table = new Table({ - head: [chalk.cyan('ID'), chalk.cyan('Name'), chalk.cyan('Status')], - colWidths: [20, 30, 15], - }); - - for (const project of projects) { - table.push([ - project.id || 'N/A', - project.name || 'Unnamed', - project.status || 'active', - ]); - } - - console.log(table.toString()); - } catch (error) { - displayError('listing projects', error); - process.exit(1); - } - }), - ) - .addCommand( - new Command('info') - .description('Show project information') - .action(async (options: BaseCommandOptions) => { - try { - const config = await loadConfig(options.config); - const apiClient = await setupApiClient(options); - const projectId = getProjectId(options, config); - - const project = await apiClient.getProject(projectId); - - displayHeader(`Project: ${project.name || projectId}`); - - const table = new Table({ - head: [chalk.cyan('Property'), chalk.green('Value')], - colWidths: [20, 50], - }); - - table.push( - ['ID', project.id || 'N/A'], - ['Name', project.name || 'Unnamed'], - ['Status', project.status || 'active'], - ['Created', project.createdAt ? new Date(project.createdAt).toLocaleString() : 'N/A'], - ['Updated', project.updatedAt ? new Date(project.updatedAt).toLocaleString() : 'N/A'], - ); - - console.log(table.toString()); - } catch (error) { - displayError('getting project info', error); - process.exit(1); - } - }), - ); - -// Automation command - delegate to dedicated automation CLI -program - .command('automation') - .description('AI automation testing (Docker-based)') - .action(async () => { - try { - // Dynamically import and run the automation CLI - const { runAutomationCLI } = await import('./automation.js'); - await runAutomationCLI(); - } catch (error) { - console.error( - chalk.red('Automation feature not available:'), - error instanceof Error ? error.message : String(error), - ); - console.log(chalk.gray('Make sure Docker is installed and running for automation features.')); - process.exit(1); - } - }); - -// Dev environment command -program - .command('dev') - .description('Manage local development environment') - .action(async () => { - try { - const { runDevCLI } = await import('./dev.js'); - await runDevCLI(); - } catch (error) { - console.error( - chalk.red('Dev command failed:'), - error instanceof Error ? error.message : String(error), - ); - process.exit(1); - } - }); - -// Helper function to display chat summary -function displayChatSummary( - stats: ChatStatistics, - searchResults: SearchResult[] = [], - verbose: boolean = false, -): void { - console.log(chalk.bold.blue('\n๐Ÿ“Š Chat History Summary')); - console.log(`Sessions: ${stats.total_sessions}`); - console.log(`Messages: ${stats.total_messages}`); - - if (stats.date_range.earliest) { - console.log(`Date range: ${stats.date_range.earliest} to ${stats.date_range.latest}`); - } - - if (verbose && Object.keys(stats.session_types).length > 0) { - console.log(chalk.bold('\nSession types:')); - for (const [sessionType, count] of Object.entries(stats.session_types)) { - console.log(` ${sessionType}: ${count}`); - } - } - - if (verbose && Object.keys(stats.message_types).length > 0) { - console.log(chalk.bold('\nMessage types:')); - for (const [msgType, count] of Object.entries(stats.message_types)) { - console.log(` ${msgType}: ${count}`); - } - } - - if (searchResults.length > 0) { - console.log(chalk.green(`\nSearch found ${searchResults.length} matches`)); - } -} - -// Parse and execute -program.parse(); +import './cli/index.js'; diff --git a/packages/cli/src/utils/data-mapper.ts b/packages/cli/src/utils/data-mapper.ts deleted file mode 100644 index 0349417d..00000000 --- a/packages/cli/src/utils/data-mapper.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Data mapper for converting between AI package and Core package types - * - * Handles the conversion between different ChatSession and ChatMessage - * structures used by the AI parsing logic and the core storage system. - */ - -import { - ChatSession as CoreChatSession, - ChatMessage as CoreChatMessage, -} from '@codervisor/devlog-core'; -import { - WorkspaceData, - WorkspaceDataContainer, - ChatSession as AiChatSession, - Message as AiMessage, -} from '@codervisor/devlog-ai'; -import { v4 as uuidv4 } from 'uuid'; - -export interface ConvertedChatData { - sessions: CoreChatSession[]; - messages: CoreChatMessage[]; -} - -/** - * Convert AI package WorkspaceData to Core package format - */ -export function convertWorkspaceDataToCoreFormat( - workspaceData: WorkspaceData | WorkspaceDataContainer, -): ConvertedChatData { - const sessions: CoreChatSession[] = []; - const messages: CoreChatMessage[] = []; - - for (const aiSession of workspaceData.chat_sessions) { - // Generate a proper session ID if not present - const sessionId = aiSession.session_id || uuidv4(); - - // Convert AI ChatSession to Core ChatSession - const currentTime = new Date().toISOString(); - const coreSession: CoreChatSession = { - id: sessionId, - agent: (aiSession.agent || workspaceData.agent) as any, // Type assertion for agent compatibility - timestamp: - typeof aiSession.timestamp === 'string' - ? aiSession.timestamp - : aiSession.timestamp?.toISOString() || currentTime, - workspace: aiSession.workspace || 'unknown', - title: aiSession.metadata?.customTitle || `Chat ${sessionId.slice(0, 8)}`, - status: 'imported', - messageCount: aiSession.messages?.length || 0, - tags: [], - importedAt: currentTime, - updatedAt: (() => { - const lastDate = aiSession.metadata?.lastMessageDate || aiSession.timestamp; - if (!lastDate) { - return currentTime; // Fallback to current time if no date available - } - return typeof lastDate === 'string' ? lastDate : lastDate.toISOString(); - })(), - linkedDevlogs: [], - archived: false, - metadata: { - ...aiSession.metadata, - source: 'ai-package-import', - originalSessionId: aiSession.session_id, - type: aiSession.metadata?.type || 'chat_session', - }, - }; - - sessions.push(coreSession); - - // Convert messages - if (aiSession.messages && Array.isArray(aiSession.messages)) { - for (let i = 0; i < aiSession.messages.length; i++) { - const aiMessage = aiSession.messages[i]; - - const coreMessage: CoreChatMessage = { - id: aiMessage.id || uuidv4(), - sessionId: sessionId, - role: aiMessage.role === 'user' ? 'user' : 'assistant', - content: aiMessage.content, - timestamp: - typeof aiMessage.timestamp === 'string' - ? aiMessage.timestamp - : aiMessage.timestamp?.toISOString() || new Date().toISOString(), - sequence: i, - metadata: { - ...aiMessage.metadata, - originalMessageId: aiMessage.id, - }, - }; - - messages.push(coreMessage); - } - } - } - - return { sessions, messages }; -} - -/** - * Extract workspace information from AI WorkspaceData - */ -export function extractWorkspaceInfo(workspaceData: WorkspaceData | WorkspaceDataContainer) { - return { - name: - (workspaceData.metadata as any)?.workspace_name || - workspaceData.workspace_path?.split('/').pop() || - 'Unknown Workspace', - path: workspaceData.workspace_path, - agent: workspaceData.agent, - version: workspaceData.version, - sessionCount: workspaceData.chat_sessions.length, - totalMessages: workspaceData.chat_sessions.reduce( - (total, session) => total + (session.messages?.length || 0), - 0, - ), - }; -} - -/** - * Validate that the converted data is properly structured - */ -export function validateConvertedData(data: ConvertedChatData): boolean { - // Check sessions - for (const session of data.sessions) { - if (!session.id || !session.agent || !session.timestamp) { - console.error('Invalid session data:', session); - return false; - } - } - - // Check messages - for (const message of data.messages) { - if ( - !message.id || - !message.sessionId || - !message.role || - !message.content || - !message.timestamp - ) { - console.error('Invalid message data:', message); - return false; - } - } - - // Check that all messages reference valid sessions - const sessionIds = new Set(data.sessions.map((s) => s.id)); - for (const message of data.messages) { - if (!sessionIds.has(message.sessionId)) { - console.error(`Message ${message.id} references non-existent session ${message.sessionId}`); - return false; - } - } - - return true; -} diff --git a/packages/core/src/__tests__/services/notes-crud.test.ts b/packages/core/src/__tests__/services/notes-crud.test.ts deleted file mode 100644 index ffe1059b..00000000 --- a/packages/core/src/__tests__/services/notes-crud.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, beforeEach, afterEach, it, expect, beforeAll, afterAll } from 'vitest'; -import type { DevlogEntry } from '../../types/index.js'; -import { - createIsolatedTestEnvironment, - type IsolatedTestEnvironment, -} from '../utils/isolated-services.js'; -import { createTestProject, createTestDevlog } from '../utils/test-env.js'; - -// Skipped as SQLite is not fully implemented and tested yet -describe.skip('DevlogService - Note CRUD Operations', () => { - let testEnv: IsolatedTestEnvironment; - let testProject: any; - let testDevlog: DevlogEntry; - - beforeAll(async () => { - // Create isolated test environment - testEnv = await createIsolatedTestEnvironment('notes-crud-test'); - }); - - afterAll(async () => { - // Clean up test environment - await testEnv.cleanup(); - }); - - beforeEach(async () => { - // Create test project using isolated service - const projectEntity = await createTestProject(testEnv.database, { - name: `Test Project - Notes CRUD - ${Date.now()}`, - description: 'Test project for note CRUD operations', - }); - - testProject = { - id: projectEntity.id, - name: projectEntity.name, - description: projectEntity.description, - }; - - // Create test devlog entry using isolated service - const devlogEntity = await createTestDevlog(testEnv.database, testProject.id, { - title: 'Test Devlog for Notes', - description: 'Test devlog entry for testing note CRUD operations', - }); - - testDevlog = { - id: devlogEntity.id, - title: devlogEntity.title, - type: devlogEntity.type, - description: devlogEntity.description, - status: devlogEntity.status, - priority: devlogEntity.priority, - projectId: devlogEntity.projectId, - createdAt: devlogEntity.createdAt.toISOString(), - updatedAt: devlogEntity.updatedAt.toISOString(), - notes: [], - }; - }); - - afterEach(async () => { - // Clear test data between tests (but keep the isolated database) - const { clearTestDatabase } = await import('../utils/test-env.js'); - await clearTestDatabase(testEnv.database); - }); - - describe('addNote', () => { - it('should add a note to a devlog entry', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const noteData = { - content: 'This is a test note', - category: 'progress' as const, - }; - - const note = await devlogService.addNote(testDevlog.id!, noteData); - - expect(note).toBeDefined(); - expect(note.id).toMatch(/^note-\d+-\d+-[a-z0-9]+$/); - expect(note.content).toBe(noteData.content); - expect(note.category).toBe(noteData.category); - expect(note.timestamp).toBeDefined(); - }); - - it('should throw error for non-existent devlog', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const noteData = { - content: 'Test note', - category: 'progress' as const, - }; - - await expect(devlogService.addNote(99999, noteData)).rejects.toThrow( - "Devlog with ID '99999' not found", - ); - }); - - it('should handle minimal note data', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const noteData = { - content: 'Minimal note', - category: 'idea' as const, - }; - - const note = await devlogService.addNote(testDevlog.id!, noteData); - - expect(note.content).toBe(noteData.content); - expect(note.category).toBe(noteData.category); - }); - }); - - describe('getNotes', () => { - it('should return empty array for devlog with no notes', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const notes = await devlogService.getNotes(testDevlog.id!); - expect(notes).toEqual([]); - }); - - it('should return notes in reverse chronological order', async () => { - const devlogService = testEnv.devlogService(testProject.id); - // Add multiple notes with delays to ensure different timestamps - const note1 = await devlogService.addNote(testDevlog.id!, { - content: 'First note', - category: 'progress', - }); - - // Small delay to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 10)); - - const note2 = await devlogService.addNote(testDevlog.id!, { - content: 'Second note', - category: 'issue', - }); - - const notes = await devlogService.getNotes(testDevlog.id!); - - expect(notes).toHaveLength(2); - expect(notes[0].content).toBe('Second note'); // Most recent first - expect(notes[1].content).toBe('First note'); - }); - - it('should respect limit parameter', async () => { - const devlogService = testEnv.devlogService(testProject.id); - // Add 3 notes - await devlogService.addNote(testDevlog.id!, { content: 'Note 1', category: 'progress' }); - await devlogService.addNote(testDevlog.id!, { content: 'Note 2', category: 'progress' }); - await devlogService.addNote(testDevlog.id!, { content: 'Note 3', category: 'progress' }); - - const notes = await devlogService.getNotes(testDevlog.id!, 2); - expect(notes).toHaveLength(2); - }); - }); - - describe('getNote', () => { - it('should return specific note by ID', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const addedNote = await devlogService.addNote(testDevlog.id!, { - content: 'Specific note', - category: 'solution', - }); - - const retrievedNote = await devlogService.getNote(addedNote.id); - - expect(retrievedNote).toBeDefined(); - expect(retrievedNote!.id).toBe(addedNote.id); - expect(retrievedNote!.content).toBe(addedNote.content); - expect(retrievedNote!.category).toBe(addedNote.category); - }); - - it('should return null for non-existent note', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const note = await devlogService.getNote('non-existent-note-id'); - expect(note).toBeNull(); - }); - }); - - describe('updateNote', () => { - it('should update note content', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const originalNote = await devlogService.addNote(testDevlog.id!, { - content: 'Original content', - category: 'progress', - }); - - const updatedNote = await devlogService.updateNote(originalNote.id, { - content: 'Updated content', - }); - - expect(updatedNote.content).toBe('Updated content'); - expect(updatedNote.category).toBe('progress'); // Unchanged - expect(updatedNote.id).toBe(originalNote.id); - expect(updatedNote.timestamp).toBe(originalNote.timestamp); // Should not change - }); - - it('should update multiple fields', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const originalNote = await devlogService.addNote(testDevlog.id!, { - content: 'Original content', - category: 'progress', - }); - - const updatedNote = await devlogService.updateNote(originalNote.id, { - content: 'New content', - category: 'solution', - }); - - expect(updatedNote.content).toBe('New content'); - expect(updatedNote.category).toBe('solution'); - }); - - it('should throw error for non-existent note', async () => { - const devlogService = testEnv.devlogService(testProject.id); - await expect( - devlogService.updateNote('non-existent-note-id', { content: 'New content' }), - ).rejects.toThrow("Note with ID 'non-existent-note-id' not found"); - }); - }); - - describe('deleteNote', () => { - it('should delete a note', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const note = await devlogService.addNote(testDevlog.id!, { - content: 'Note to delete', - category: 'progress', - }); - - await devlogService.deleteNote(note.id); - - const retrievedNote = await devlogService.getNote(note.id); - expect(retrievedNote).toBeNull(); - }); - - it('should throw error for non-existent note', async () => { - const devlogService = testEnv.devlogService(testProject.id); - await expect(devlogService.deleteNote('non-existent-note-id')).rejects.toThrow( - "Note with ID 'non-existent-note-id' not found", - ); - }); - - it('should not affect other notes', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const note1 = await devlogService.addNote(testDevlog.id!, { - content: 'Note 1', - category: 'progress', - }); - const note2 = await devlogService.addNote(testDevlog.id!, { - content: 'Note 2', - category: 'progress', - }); - - await devlogService.deleteNote(note1.id); - - const remainingNotes = await devlogService.getNotes(testDevlog.id!); - expect(remainingNotes).toHaveLength(1); - expect(remainingNotes[0].id).toBe(note2.id); - }); - }); - - describe('integration with devlog operations', () => { - it('should load notes when getting devlog with includeNotes=true', async () => { - const devlogService = testEnv.devlogService(testProject.id); - // Add some notes - await devlogService.addNote(testDevlog.id!, { - content: 'Integration test note 1', - category: 'progress', - }); - await devlogService.addNote(testDevlog.id!, { - content: 'Integration test note 2', - category: 'issue', - }); - - const devlogWithNotes = await devlogService.get(testDevlog.id!, true); - expect(devlogWithNotes!.notes).toHaveLength(2); - expect(devlogWithNotes!.notes![0].content).toBe('Integration test note 2'); // Most recent first - }); - - it('should not load notes when getting devlog with includeNotes=false', async () => { - const devlogService = testEnv.devlogService(testProject.id); - await devlogService.addNote(testDevlog.id!, { - content: 'Should not be loaded', - category: 'progress', - }); - - const devlogWithoutNotes = await devlogService.get(testDevlog.id!, false); - expect(devlogWithoutNotes!.notes).toEqual([]); - }); - - it('should cascade delete notes when devlog is deleted', async () => { - const devlogService = testEnv.devlogService(testProject.id); - const note = await devlogService.addNote(testDevlog.id!, { - content: 'Will be cascade deleted', - category: 'progress', - }); - - await devlogService.delete(testDevlog.id!); - - const retrievedNote = await devlogService.getNote(note.id); - expect(retrievedNote).toBeNull(); - - // Mark testDevlog as deleted so cleanup doesn't try to delete again - testDevlog.id = undefined; - }); - }); -}); diff --git a/packages/core/src/entities/chat-session.entity.ts b/packages/core/src/entities/chat-session.entity.ts index b01d623c..d5df37a8 100644 --- a/packages/core/src/entities/chat-session.entity.ts +++ b/packages/core/src/entities/chat-session.entity.ts @@ -16,7 +16,6 @@ import { JsonColumn, getStorageType } from './decorators.js'; @Index(['timestamp']) @Index(['workspace']) @Index(['status']) -@Index(['importedAt']) @Index(['archived']) export class ChatSessionEntity { @PrimaryColumn({ type: 'varchar', length: 255 }) @@ -49,104 +48,9 @@ export class ChatSessionEntity { @JsonColumn({ default: getStorageType() === 'sqlite' ? '{}' : {} }) metadata!: Record; - @JsonColumn({ default: getStorageType() === 'sqlite' ? '[]' : [] }) - tags!: string[]; - - @Column({ type: 'varchar', length: 255, name: 'imported_at' }) - importedAt!: string; // ISO string - @Column({ type: 'varchar', length: 255, name: 'updated_at' }) updatedAt!: string; // ISO string @Column({ type: 'boolean', default: false }) archived!: boolean; - - @JsonColumn({ default: getStorageType() === 'sqlite' ? '[]' : [], name: 'linked_devlogs' }) - linkedDevlogs!: number[]; - - /** - * Convert entity to ChatSession interface - */ - toChatSession(): import('../types/index.js').ChatSession { - return { - id: this.id, - agent: this.agent, - timestamp: this.timestamp, - workspace: this.workspace, - workspacePath: this.workspacePath, - title: this.title, - status: this.status, - messageCount: this.messageCount, - duration: this.duration, - metadata: this.parseJsonField(this.metadata, {}), - tags: this.parseJsonField(this.tags, []), - importedAt: this.importedAt, - updatedAt: this.updatedAt, - linkedDevlogs: this.parseJsonField(this.linkedDevlogs, []), - archived: this.archived, - }; - } - - /** - * Create entity from ChatSession interface - */ - static fromChatSession(session: import('../types/index.js').ChatSession): ChatSessionEntity { - const entity = new ChatSessionEntity(); - - entity.id = session.id; - entity.agent = session.agent; - entity.timestamp = session.timestamp; - entity.workspace = session.workspace; - entity.workspacePath = session.workspacePath; - entity.title = session.title; - entity.status = session.status || 'imported'; - entity.messageCount = session.messageCount || 0; - entity.duration = session.duration; - entity.metadata = entity.stringifyJsonField(session.metadata || {}); - entity.tags = entity.stringifyJsonField(session.tags || []); - entity.importedAt = session.importedAt; - entity.updatedAt = session.updatedAt; - entity.linkedDevlogs = entity.stringifyJsonField(session.linkedDevlogs || []); - entity.archived = session.archived || false; - - return entity; - } - - /** - * Helper method for JSON field parsing (database-specific) - */ - private parseJsonField(value: any, defaultValue: T): T { - if (value === null || value === undefined) { - return defaultValue; - } - - // For SQLite, values are stored as text and need parsing - if (getStorageType() === 'sqlite' && typeof value === 'string') { - try { - return JSON.parse(value); - } catch { - return defaultValue; - } - } - - // For PostgreSQL and MySQL, JSON fields are handled natively - return value; - } - - /** - * Helper method for JSON field stringification (database-specific) - */ - private stringifyJsonField(value: any): any { - if (value === null || value === undefined) { - return value; - } - - // For SQLite, we need to stringify JSON data - if (getStorageType() === 'sqlite') { - return typeof value === 'string' ? value : JSON.stringify(value); - } - - // For PostgreSQL and MySQL, return the object directly - return value; - } } diff --git a/packages/core/src/entities/devlog-note.entity.ts b/packages/core/src/entities/devlog-note.entity.ts index 6bf98c3a..7067d4ae 100644 --- a/packages/core/src/entities/devlog-note.entity.ts +++ b/packages/core/src/entities/devlog-note.entity.ts @@ -5,7 +5,7 @@ import 'reflect-metadata'; import { Column, Entity, Index, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import type { NoteCategory } from '../types/index.js'; +import type { DevlogNoteCategory } from '../types/index.js'; import { DevlogEntryEntity } from './devlog-entry.entity.js'; import { JsonColumn, TimestampColumn } from './decorators.js'; @@ -28,7 +28,7 @@ export class DevlogNoteEntity { length: 50, enum: ['progress', 'issue', 'solution', 'idea', 'reminder', 'feedback', 'acceptance-criteria'], }) - category!: NoteCategory; + category!: DevlogNoteCategory; @Column({ type: 'text' }) content!: string; diff --git a/packages/core/src/services/project-service.ts b/packages/core/src/services/project-service.ts index 18ba805e..4279203e 100644 --- a/packages/core/src/services/project-service.ts +++ b/packages/core/src/services/project-service.ts @@ -43,9 +43,6 @@ export class ProjectService { this.database.entityMetadatas.length, ); console.log('[ProjectService] Repository initialized:', !!this.repository); - - // Create default project if it doesn't exist - await this.createDefaultProject(); } } catch (error) { console.error('[ProjectService] Failed to initialize:', error); @@ -53,26 +50,6 @@ export class ProjectService { } } - /** - * Create default project - */ - private async createDefaultProject(): Promise { - const defaultProject: Omit = { - name: 'Default Project', - description: 'Default devlog project', - }; - - // Create project directly without initialization check since this is called during initialization - const existing = await this.repository.findOne({ where: { name: defaultProject.name } }); - if (existing) { - return; // Default project already exists - } - - // Create and save new project entity - const entity = ProjectEntity.fromProjectData(defaultProject); - await this.repository.save(entity); - } - async list(): Promise { await this.ensureInitialized(); // Ensure initialization diff --git a/packages/core/src/types/core.ts b/packages/core/src/types/core.ts index 428a4c06..3c5c9270 100644 --- a/packages/core/src/types/core.ts +++ b/packages/core/src/types/core.ts @@ -100,7 +100,7 @@ export type DevlogPriority = 'low' | 'medium' | 'high' | 'critical'; * }; * ``` */ -export type NoteCategory = +export type DevlogNoteCategory = /** * **Progress** - Work progress updates, milestones, and status changes * - General updates on development progress @@ -159,7 +159,7 @@ export type DevlogId = number; export interface DevlogNote { id: string; timestamp: string; - category: NoteCategory; + category: DevlogNoteCategory; content: string; } diff --git a/packages/core/src/utils/field-change-tracking.ts b/packages/core/src/utils/field-change-tracking.ts index ab445961..06323781 100644 --- a/packages/core/src/utils/field-change-tracking.ts +++ b/packages/core/src/utils/field-change-tracking.ts @@ -3,7 +3,7 @@ * Extends the existing acceptance criteria tracking to all devlog fields */ -import type { DevlogEntry, DevlogNote, NoteCategory } from '../types/core.js'; +import type { DevlogEntry, DevlogNote, DevlogNoteCategory } from '../types/core.js'; import type { ChangeRecord, FieldChange, @@ -283,7 +283,7 @@ function generateChangeNoteContent(changes: FieldChange[], changeRecord: ChangeR /** * Determine the appropriate note category for field changes */ -function determineNoteCategory(changes: FieldChange[]): NoteCategory { +function determineNoteCategory(changes: FieldChange[]): DevlogNoteCategory { // Check if any workflow changes (status, archived, etc.) const hasWorkflowChanges = changes.some((c) => c.category === 'workflow'); if (hasWorkflowChanges) { diff --git a/packages/mcp/src/__tests__/tools-definition.test.ts b/packages/mcp/src/__tests__/tools-definition.test.ts deleted file mode 100644 index 3bad612e..00000000 --- a/packages/mcp/src/__tests__/tools-definition.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - allTools, - coreTools, - actionTools, - contextTools, - devlogTools, - projectTools, -} from '../tools/index.js'; -import { Tool } from '@modelcontextprotocol/sdk/types.js'; - -describe('MCP Tools', () => { - describe('tool definitions', () => { - it('should export all tool categories', () => { - expect(coreTools).toBeDefined(); - expect(actionTools).toBeDefined(); - expect(contextTools).toBeDefined(); - expect(devlogTools).toBeDefined(); - expect(projectTools).toBeDefined(); - }); - - it('should have all tools in allTools array', () => { - expect(allTools).toBeDefined(); - expect(Array.isArray(allTools)).toBe(true); - expect(allTools.length).toBe(10); // 7 devlog + 3 project tools - - // Verify it contains tools from all categories - const totalExpectedTools = coreTools.length + actionTools.length + contextTools.length; - - expect(allTools.length).toBe(totalExpectedTools); - }); - - it('should have unique tool names', () => { - const toolNames = allTools.map((tool) => tool.name); - const uniqueNames = new Set(toolNames); - expect(uniqueNames.size).toBe(toolNames.length); - }); - }); - - describe('tool schema validation', () => { - const validateTool = (tool: Tool) => { - expect(tool.name, `Tool name should be defined`).toBeDefined(); - expect(typeof tool.name, `Tool name should be string`).toBe('string'); - expect(tool.name.length, `Tool name should not be empty`).toBeGreaterThan(0); - - expect(tool.description, `Tool ${tool.name} should have description`).toBeDefined(); - expect(typeof tool.description, `Tool ${tool.name} description should be string`).toBe( - 'string', - ); - expect( - tool.description?.length || 0, - `Tool ${tool.name} description should not be empty`, - ).toBeGreaterThan(0); - - expect(tool.inputSchema, `Tool ${tool.name} should have input schema`).toBeDefined(); - expect(tool.inputSchema.type, `Tool ${tool.name} input schema should be object type`).toBe( - 'object', - ); - expect( - tool.inputSchema.properties, - `Tool ${tool.name} should have properties defined`, - ).toBeDefined(); - }; - - it('should validate core tools', () => { - expect(coreTools.length).toBeGreaterThan(0); - coreTools.forEach(validateTool); - }); - - it('should validate action tools', () => { - expect(actionTools.length).toBeGreaterThan(0); - actionTools.forEach(validateTool); - }); - - it('should validate context tools (project tools)', () => { - expect(contextTools.length).toBeGreaterThan(0); - expect(contextTools).toBe(projectTools); // contextTools === projectTools - contextTools.forEach(validateTool); - }); - - it('should validate devlog tools', () => { - expect(devlogTools.length).toBe(7); - devlogTools.forEach(validateTool); - }); - - it('should validate project tools', () => { - expect(projectTools.length).toBe(3); - projectTools.forEach(validateTool); - }); - }); - - describe('tool naming conventions', () => { - it('should follow consistent naming convention', () => { - // Current tool names - updated to match actual tools - const expectedNames = [ - 'create_devlog', - 'get_devlog', - 'update_devlog', - 'list_devlogs', - 'add_devlog_note', - 'complete_devlog', - 'find_related_devlogs', - 'list_projects', - 'get_current_project', - 'switch_project', - ]; - - // Verify all expected tools exist - expectedNames.forEach((expectedName) => { - const tool = allTools.find((tool) => tool.name === expectedName); - expect(tool, `Tool '${expectedName}' should exist`).toBeDefined(); - }); - - // Verify no unexpected tools exist - allTools.forEach((tool) => { - expect(expectedNames, `Tool '${tool.name}' should be in expected list`).toContain( - tool.name, - ); - }); - }); - - it('should have descriptive names', () => { - const reservedWords = ['test', 'temp', 'debug', 'todo']; - - allTools.forEach((tool) => { - reservedWords.forEach((reserved) => { - expect(tool.name.toLowerCase()).not.toContain(reserved); - }); - }); - }); - }); - - describe('tool parameter validation', () => { - it('should have required parameters properly defined', () => { - allTools.forEach((tool) => { - if (tool.inputSchema.required && Array.isArray(tool.inputSchema.required)) { - tool.inputSchema.required.forEach((requiredParam) => { - expect(tool.inputSchema.properties).toHaveProperty(requiredParam); - }); - } - }); - }); - - it('should have proper parameter types', () => { - const validTypes = ['string', 'number', 'boolean', 'array', 'object', 'integer']; - - allTools.forEach((tool) => { - if (tool.inputSchema.properties) { - Object.values(tool.inputSchema.properties).forEach((property: any) => { - if (property.type) { - expect(validTypes).toContain(property.type); - } - }); - } - }); - }); - - // Skip parameter description tests for now as schemas may not have descriptions - it('should have valid input schemas', () => { - allTools.forEach((tool) => { - expect(tool.inputSchema).toBeDefined(); - expect(tool.inputSchema.type).toBe('object'); - }); - }); - }); - - describe('specific tool tests', () => { - it('should have create_devlog tool', () => { - const createTool = allTools.find((tool) => tool.name === 'create_devlog'); - expect(createTool).toBeDefined(); - expect(createTool!.inputSchema.required).toContain('title'); - expect(createTool!.inputSchema.required).toContain('description'); - }); - - it('should have update_devlog tool', () => { - const updateTool = allTools.find((tool) => tool.name === 'update_devlog'); - expect(updateTool).toBeDefined(); - expect(updateTool!.inputSchema.required).toContain('id'); - }); - - it('should have list_devlogs tool', () => { - const listTool = allTools.find((tool) => tool.name === 'list_devlogs'); - expect(listTool).toBeDefined(); - }); - - it('should have get_devlog tool', () => { - const getTool = allTools.find((tool) => tool.name === 'get_devlog'); - expect(getTool).toBeDefined(); - expect(getTool!.inputSchema.required).toContain('id'); - }); - - it('should have add_devlog_note tool', () => { - const addNoteTool = allTools.find((tool) => tool.name === 'add_devlog_note'); - expect(addNoteTool).toBeDefined(); - expect(addNoteTool!.inputSchema.required).toContain('id'); - expect(addNoteTool!.inputSchema.required).toContain('note'); - }); - - it('should have complete_devlog tool', () => { - const completeTool = allTools.find((tool) => tool.name === 'complete_devlog'); - expect(completeTool).toBeDefined(); - expect(completeTool!.inputSchema.required).toContain('id'); - }); - - it('should have find_related_devlogs tool', () => { - const findRelatedTool = allTools.find((tool) => tool.name === 'find_related_devlogs'); - expect(findRelatedTool).toBeDefined(); - expect(findRelatedTool!.inputSchema.required).toContain('description'); - }); - - it('should have list_projects tool', () => { - const listProjectsTool = allTools.find((tool) => tool.name === 'list_projects'); - expect(listProjectsTool).toBeDefined(); - }); - - it('should have get_current_project tool', () => { - const getCurrentProjectTool = allTools.find((tool) => tool.name === 'get_current_project'); - expect(getCurrentProjectTool).toBeDefined(); - }); - - it('should have switch_project tool', () => { - const switchProjectTool = allTools.find((tool) => tool.name === 'switch_project'); - expect(switchProjectTool).toBeDefined(); - expect(switchProjectTool!.inputSchema.required).toContain('projectId'); - }); - }); -}); diff --git a/packages/mcp/src/adapters/mcp-adapter.ts b/packages/mcp/src/adapters/mcp-adapter.ts index 21fe78ef..e4a3098e 100644 --- a/packages/mcp/src/adapters/mcp-adapter.ts +++ b/packages/mcp/src/adapters/mcp-adapter.ts @@ -11,13 +11,13 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { DevlogApiClient, type DevlogApiClientConfig } from '../api/devlog-api-client.js'; import type { - AddNoteArgs, - CompleteDevlogArgs, + AddDevlogNoteArgs, CreateDevlogArgs, - FindRelatedArgs, + FindRelatedDevlogsArgs, GetCurrentProjectArgs, GetDevlogArgs, ListDevlogArgs, + ListDevlogNotesArgs, ListProjectsArgs, SwitchProjectArgs, UpdateDevlogArgs, @@ -134,7 +134,7 @@ export class MCPAdapter { // === DEVLOG OPERATIONS === - async create(args: CreateDevlogArgs): Promise { + async createDevlog(args: CreateDevlogArgs): Promise { await this.ensureInitialized(); try { @@ -164,7 +164,7 @@ export class MCPAdapter { } } - async get(args: GetDevlogArgs): Promise { + async getDevlog(args: GetDevlogArgs): Promise { await this.ensureInitialized(); try { @@ -175,39 +175,39 @@ export class MCPAdapter { } } - async update(args: UpdateDevlogArgs): Promise { + async updateDevlog(args: UpdateDevlogArgs): Promise { await this.ensureInitialized(); try { - // Handle update with optional note - if (args.note) { - // First update the fields if provided - if (args.status || args.priority) { - await this.apiClient.updateDevlog(args.id, { - status: args.status, - priority: args.priority, - }); - } - - // Then add the note - await this.apiClient.addDevlogNote(args.id, args.note, 'progress'); - - return this.toStandardResponse(true, { id: args.id }, `Updated entry ${args.id} with note`); - } else { - // Regular update without note - const entry = await this.apiClient.updateDevlog(args.id, { + // First update the fields if provided + if ( + args.status || + args.priority || + args.businessContext || + args.technicalContext || + args.acceptanceCriteria + ) { + await this.apiClient.updateDevlog(args.id, { status: args.status, priority: args.priority, + businessContext: args.businessContext, + technicalContext: args.technicalContext, + acceptanceCriteria: args.acceptanceCriteria, }); + } - return this.toStandardResponse(true, { id: args.id }, `Updated entry ${args.id}`); + // Handle update with optional note + if (args.note) { + await this.apiClient.addDevlogNote(args.id, args.note.content, args.note.category); } + + return this.toStandardResponse(true, { id: args.id }, `Updated entry ${args.id}`); } catch (error) { return this.handleError('Failed to update entry', error); } } - async list(args: ListDevlogArgs): Promise { + async listDevlogs(args: ListDevlogArgs): Promise { await this.ensureInitialized(); try { @@ -234,43 +234,7 @@ export class MCPAdapter { } } - async addNote(args: AddNoteArgs): Promise { - await this.ensureInitialized(); - - try { - await this.apiClient.addDevlogNote(args.id, args.note, args.category); - - return this.toStandardResponse( - true, - { id: args.id }, - `Added ${args.category} note to entry ${args.id}`, - ); - } catch (error) { - return this.handleError('Failed to add note', error); - } - } - - async complete(args: CompleteDevlogArgs): Promise { - await this.ensureInitialized(); - - try { - await this.apiClient.updateDevlog(args.id, { status: 'done' }); - - if (args.summary) { - await this.apiClient.addDevlogNote(args.id, `Completed: ${args.summary}`, 'progress'); - } - - return this.toStandardResponse( - true, - { id: args.id, status: 'done' }, - `Completed entry ${args.id}${args.summary ? ` - ${args.summary}` : ''}`, - ); - } catch (error) { - return this.handleError('Failed to complete entry', error); - } - } - - async findRelated(args: FindRelatedArgs): Promise { + async findRelatedDevlogs(args: FindRelatedDevlogsArgs): Promise { await this.ensureInitialized(); try { @@ -306,6 +270,42 @@ export class MCPAdapter { } } + async addDevlogNote(args: AddDevlogNoteArgs): Promise { + await this.ensureInitialized(); + + try { + await this.apiClient.addDevlogNote(args.id, args.content, args.category); + + return this.toStandardResponse( + true, + { id: args.id }, + `Added ${args.category} note to entry ${args.id}`, + ); + } catch (error) { + return this.handleError('Failed to add note', error); + } + } + + async listDevlogNotes(args: ListDevlogNotesArgs): Promise { + await this.ensureInitialized(); + + try { + const result = await this.apiClient.listDevlogNotes(args.id, args.category, args.limit); + + return this.toStandardResponse( + true, + { + devlogId: args.id, + total: result.total, + notes: result.notes, + }, + `Found ${result.notes.length} notes for devlog ${args.id}`, + ); + } catch (error) { + return this.handleError('Failed to list notes', error); + } + } + // === PROJECT OPERATIONS === async listProjects(args: ListProjectsArgs): Promise { diff --git a/packages/mcp/src/api/devlog-api-client.ts b/packages/mcp/src/api/devlog-api-client.ts index 804206c3..f0ba5142 100644 --- a/packages/mcp/src/api/devlog-api-client.ts +++ b/packages/mcp/src/api/devlog-api-client.ts @@ -7,6 +7,8 @@ import type { CreateDevlogRequest, DevlogEntry, DevlogFilter, + DevlogNote, + DevlogNoteCategory, PaginatedResult, PaginationMeta, SortOptions, @@ -286,6 +288,23 @@ export class DevlogApiClient { return this.unwrapApiResponse(response); } + async listDevlogNotes( + devlogId: number, + category?: DevlogNoteCategory, + limit?: number, + ): Promise<{ devlogId: number; total: number; notes: DevlogNote[] }> { + const params = new URLSearchParams(); + + if (category) params.append('category', category); + if (limit) params.append('limit', limit.toString()); + + const query = params.toString() ? `?${params.toString()}` : ''; + const response = await this.get( + `${this.getProjectEndpoint()}/devlogs/${devlogId}/notes${query}`, + ); + return this.unwrapApiResponse(response); + } + // Health check async healthCheck(): Promise<{ status: string; timestamp: string }> { try { diff --git a/packages/mcp/src/handlers/tool-handlers.ts b/packages/mcp/src/handlers/tool-handlers.ts index 7ce345f3..424d960f 100644 --- a/packages/mcp/src/handlers/tool-handlers.ts +++ b/packages/mcp/src/handlers/tool-handlers.ts @@ -5,26 +5,26 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { MCPAdapter } from '../adapters/index.js'; import { + type AddDevlogNoteArgs, + AddDevlogNoteSchema, + type CreateDevlogArgs, CreateDevlogSchema, - GetDevlogSchema, - UpdateDevlogSchema, - ListDevlogSchema, - AddNoteSchema, - CompleteDevlogSchema, - FindRelatedSchema, - ListProjectsSchema, + type FindRelatedDevlogsArgs, + FindRelatedDevlogsSchema, + type GetCurrentProjectArgs, GetCurrentProjectSchema, - SwitchProjectSchema, - type CreateDevlogArgs, type GetDevlogArgs, - type UpdateDevlogArgs, + GetDevlogSchema, type ListDevlogArgs, - type AddNoteArgs, - type CompleteDevlogArgs, - type FindRelatedArgs, + ListDevlogNotesArgs, + ListDevlogNotesSchema, + ListDevlogSchema, type ListProjectsArgs, - type GetCurrentProjectArgs, + ListProjectsSchema, type SwitchProjectArgs, + SwitchProjectSchema, + type UpdateDevlogArgs, + UpdateDevlogSchema, } from '../schemas/index.js'; /** @@ -59,43 +59,46 @@ export const toolHandlers = { // Devlog operations create_devlog: (adapter: MCPAdapter, args: unknown) => validateAndHandle(CreateDevlogSchema, args, 'create_devlog', (validArgs) => - adapter.create(validArgs), + adapter.createDevlog(validArgs), ), get_devlog: (adapter: MCPAdapter, args: unknown) => validateAndHandle(GetDevlogSchema, args, 'get_devlog', (validArgs) => - adapter.get(validArgs), + adapter.getDevlog(validArgs), ), update_devlog: (adapter: MCPAdapter, args: unknown) => validateAndHandle(UpdateDevlogSchema, args, 'update_devlog', (validArgs) => - adapter.update(validArgs), + adapter.updateDevlog(validArgs), ), list_devlogs: (adapter: MCPAdapter, args: unknown) => validateAndHandle(ListDevlogSchema, args, 'list_devlogs', (validArgs) => - adapter.list(validArgs), + adapter.listDevlogs(validArgs), ), - add_devlog_note: (adapter: MCPAdapter, args: unknown) => - validateAndHandle(AddNoteSchema, args, 'add_devlog_note', (validArgs) => - adapter.addNote(validArgs), + find_related_devlogs: (adapter: MCPAdapter, args: unknown) => + validateAndHandle( + FindRelatedDevlogsSchema, + args, + 'find_related_devlogs', + (validArgs) => adapter.findRelatedDevlogs(validArgs), ), - complete_devlog: (adapter: MCPAdapter, args: unknown) => - validateAndHandle( - CompleteDevlogSchema, + add_devlog_note: (adapter: MCPAdapter, args: unknown) => + validateAndHandle( + AddDevlogNoteSchema, args, - 'complete_devlog', - (validArgs) => adapter.complete(validArgs), + 'add_devlog_note', + (validArgs) => adapter.addDevlogNote(validArgs), ), - find_related_devlogs: (adapter: MCPAdapter, args: unknown) => - validateAndHandle( - FindRelatedSchema, + list_devlog_notes: (adapter: MCPAdapter, args: unknown) => + validateAndHandle( + ListDevlogNotesSchema, args, - 'find_related_devlogs', - (validArgs) => adapter.findRelated(validArgs), + 'list_devlog_notes', + (validArgs) => adapter.listDevlogNotes(validArgs), ), // Project operations diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index bd873788..8f00619c 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -101,7 +101,7 @@ async function main() { // Create adapter configuration const config: MCPAdapterConfig = { apiClient: { - baseUrl: process.env.DEVLOG_API_URL || 'https://devlog.codervisor.dev/api', + baseUrl: process.env.DEVLOG_BASE_URL || 'https://devlog.codervisor.dev', timeout: 30000, retries: 3, }, diff --git a/packages/mcp/src/schemas/base.ts b/packages/mcp/src/schemas/base.ts index a81b1a59..ba789318 100644 --- a/packages/mcp/src/schemas/base.ts +++ b/packages/mcp/src/schemas/base.ts @@ -25,7 +25,7 @@ export const DevlogStatusSchema = z.enum([ export const DevlogPrioritySchema = z.enum(['low', 'medium', 'high', 'critical']).default('medium'); -export const NoteCategorySchema = z +export const DevlogNoteCategorySchema = z .enum(['progress', 'issue', 'solution', 'idea', 'reminder']) .default('progress'); @@ -34,11 +34,9 @@ export const TitleSchema = z.string().min(1, 'Title is required').max(200, 'Titl export const DescriptionSchema = z.string().min(1, 'Description is required'); -export const NoteContentSchema = z.string().min(1, 'Note content is required'); +export const DevlogNoteContentSchema = z.string().min(1, 'Note content is required'); -export const FilesSchema = z.array(z.string()).optional(); - -export const KeywordsSchema = z.array(z.string()).optional(); +export const KeywordsSchema = z.array(z.string()); // === CONTEXT FIELD SCHEMAS === export const BusinessContextSchema = z.string(); diff --git a/packages/mcp/src/schemas/devlog-schemas.ts b/packages/mcp/src/schemas/devlog-schemas.ts index 626b8c20..5b6708ff 100644 --- a/packages/mcp/src/schemas/devlog-schemas.ts +++ b/packages/mcp/src/schemas/devlog-schemas.ts @@ -4,23 +4,22 @@ import { z } from 'zod'; import { - DevlogIdSchema, - TitleSchema, + AcceptanceCriteriaSchema, + BusinessContextSchema, DescriptionSchema, - DevlogTypeSchema, + DevlogIdSchema, + DevlogNoteCategorySchema, + DevlogNoteContentSchema, DevlogPrioritySchema, + DevlogsSortBySchema, DevlogStatusSchema, - NoteCategorySchema, - NoteContentSchema, - FilesSchema, + DevlogTypeSchema, KeywordsSchema, LimitSchema, - BusinessContextSchema, - TechnicalContextSchema, - AcceptanceCriteriaSchema, PageSchema, - DevlogsSortBySchema, SortOrderSchema, + TechnicalContextSchema, + TitleSchema, } from './base.js'; // === CREATE DEVLOG === @@ -29,8 +28,8 @@ export const CreateDevlogSchema = z.object({ description: DescriptionSchema, type: DevlogTypeSchema, priority: DevlogPrioritySchema, - businessContext: BusinessContextSchema, - technicalContext: TechnicalContextSchema, + businessContext: BusinessContextSchema.optional(), + technicalContext: TechnicalContextSchema.optional(), acceptanceCriteria: AcceptanceCriteriaSchema, }); @@ -44,14 +43,18 @@ export const UpdateDevlogSchema = z.object({ id: DevlogIdSchema, status: DevlogStatusSchema.optional(), priority: DevlogPrioritySchema.optional(), - note: z.string().optional(), - files: FilesSchema, - businessContext: BusinessContextSchema, - technicalContext: TechnicalContextSchema, - acceptanceCriteria: AcceptanceCriteriaSchema, + businessContext: BusinessContextSchema.optional(), + technicalContext: TechnicalContextSchema.optional(), + acceptanceCriteria: AcceptanceCriteriaSchema.optional(), + note: z + .object({ + content: DevlogNoteContentSchema, + category: DevlogNoteCategorySchema, + }) + .optional(), }); -// === LIST/SEARCH DEVLOGS === +// === LIST DEVLOGS === export const ListDevlogSchema = z.object({ status: DevlogStatusSchema.optional(), type: DevlogTypeSchema.optional(), @@ -62,25 +65,25 @@ export const ListDevlogSchema = z.object({ sortOrder: SortOrderSchema, }); -// === ADD NOTE === -export const AddNoteSchema = z.object({ - id: DevlogIdSchema, - note: NoteContentSchema, - category: NoteCategorySchema, - files: FilesSchema, +// === FIND RELATED DEVLOGS === +export const FindRelatedDevlogsSchema = z.object({ + description: DescriptionSchema, + type: DevlogTypeSchema.optional(), + keywords: KeywordsSchema, + limit: LimitSchema, }); -// === COMPLETE DEVLOG === -export const CompleteDevlogSchema = z.object({ +// === ADD DEVLOG NOTE === +export const AddDevlogNoteSchema = z.object({ id: DevlogIdSchema, - summary: z.string().optional(), + content: DevlogNoteContentSchema, + category: DevlogNoteCategorySchema, }); -// === FIND RELATED === -export const FindRelatedSchema = z.object({ - description: DescriptionSchema, - type: DevlogTypeSchema.optional(), - keywords: KeywordsSchema, +// === LIST DEVLOG NOTES === +export const ListDevlogNotesSchema = z.object({ + id: DevlogIdSchema, + category: DevlogNoteCategorySchema, limit: LimitSchema, }); @@ -89,6 +92,6 @@ export type CreateDevlogArgs = z.infer; export type GetDevlogArgs = z.infer; export type UpdateDevlogArgs = z.infer; export type ListDevlogArgs = z.infer; -export type AddNoteArgs = z.infer; -export type CompleteDevlogArgs = z.infer; -export type FindRelatedArgs = z.infer; +export type FindRelatedDevlogsArgs = z.infer; +export type AddDevlogNoteArgs = z.infer; +export type ListDevlogNotesArgs = z.infer; diff --git a/packages/mcp/src/tools/devlog-tools.ts b/packages/mcp/src/tools/devlog-tools.ts index 6d117c79..311d60a1 100644 --- a/packages/mcp/src/tools/devlog-tools.ts +++ b/packages/mcp/src/tools/devlog-tools.ts @@ -1,13 +1,12 @@ import { Tool } from '@modelcontextprotocol/sdk/types.js'; import { zodToJsonSchema } from '../utils/schema-converter.js'; import { + AddDevlogNoteSchema, CreateDevlogSchema, + FindRelatedDevlogsSchema, GetDevlogSchema, - UpdateDevlogSchema, ListDevlogSchema, - AddNoteSchema, - CompleteDevlogSchema, - FindRelatedSchema, + UpdateDevlogSchema, } from '../schemas/index.js'; /** @@ -36,7 +35,7 @@ export const devlogTools: Tool[] = [ { name: 'update_devlog', - description: 'Update entry status, priority, or add progress notes', + description: 'Update entry status, priority, or add a note', inputSchema: zodToJsonSchema(UpdateDevlogSchema), }, @@ -49,18 +48,12 @@ export const devlogTools: Tool[] = [ { name: 'add_devlog_note', description: 'Add a timestamped progress note to an entry', - inputSchema: zodToJsonSchema(AddNoteSchema), - }, - - { - name: 'complete_devlog', - description: 'Mark entry as completed (automatically archives)', - inputSchema: zodToJsonSchema(CompleteDevlogSchema), + inputSchema: zodToJsonSchema(AddDevlogNoteSchema), }, { name: 'find_related_devlogs', description: 'Find existing entries related to planned work (prevents duplicates)', - inputSchema: zodToJsonSchema(FindRelatedSchema), + inputSchema: zodToJsonSchema(FindRelatedDevlogsSchema), }, ]; diff --git a/packages/web/app/AppLayout.tsx b/packages/web/app/AppLayout.tsx index 1f235ea4..9d02397d 100644 --- a/packages/web/app/AppLayout.tsx +++ b/packages/web/app/AppLayout.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { AppLayoutSkeleton, ErrorBoundary, NavigationSidebar, TopNavbar } from '@/components'; import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'; +import { Toaster } from 'sonner'; interface AppLayoutProps { children: React.ReactNode; @@ -23,6 +24,7 @@ export function AppLayout({ children }: AppLayoutProps) { return ( +
diff --git a/packages/web/app/api/projects/[id]/devlogs/[devlogId]/notes/[noteId]/route.ts b/packages/web/app/api/projects/[id]/devlogs/[devlogId]/notes/[noteId]/route.ts index 3e09ec3c..d50b2acb 100644 --- a/packages/web/app/api/projects/[id]/devlogs/[devlogId]/notes/[noteId]/route.ts +++ b/packages/web/app/api/projects/[id]/devlogs/[devlogId]/notes/[noteId]/route.ts @@ -1,7 +1,8 @@ import { NextRequest } from 'next/server'; -import type { NoteCategory } from '@codervisor/devlog-core'; +import type { DevlogNoteCategory } from '@codervisor/devlog-core'; import { DevlogService, ProjectService } from '@codervisor/devlog-core'; -import { ApiErrors, createSuccessResponse, RouteParams, SSEEventType } from '@/lib'; +import { ApiErrors, createSuccessResponse, RouteParams } from '@/lib'; +import { RealtimeEventType } from '@/lib/realtime'; import { z } from 'zod'; // Mark this route as dynamic to prevent static generation @@ -88,10 +89,12 @@ export async function PUT( // Update the note const updatedNote = await devlogService.updateNote(noteId, { ...updates, - category: updates.category as NoteCategory | undefined, + category: updates.category as DevlogNoteCategory | undefined, }); - return createSuccessResponse(updatedNote, { sseEventType: SSEEventType.DEVLOG_NOTE_UPDATED }); + return createSuccessResponse(updatedNote, { + sseEventType: RealtimeEventType.DEVLOG_NOTE_UPDATED, + }); } catch (error) { console.error('Error updating note:', error); if (error instanceof Error && error.message.includes('not found')) { @@ -135,7 +138,7 @@ export async function DELETE( devlogId, noteId, }, - { sseEventType: SSEEventType.DEVLOG_NOTE_DELETED }, + { sseEventType: RealtimeEventType.DEVLOG_NOTE_DELETED }, ); } catch (error) { console.error('Error deleting note:', error); diff --git a/packages/web/app/api/projects/[id]/devlogs/[devlogId]/notes/route.ts b/packages/web/app/api/projects/[id]/devlogs/[devlogId]/notes/route.ts index 989d7999..790c15a2 100644 --- a/packages/web/app/api/projects/[id]/devlogs/[devlogId]/notes/route.ts +++ b/packages/web/app/api/projects/[id]/devlogs/[devlogId]/notes/route.ts @@ -1,7 +1,8 @@ import { NextRequest } from 'next/server'; -import type { NoteCategory } from '@codervisor/devlog-core'; +import type { DevlogNoteCategory } from '@codervisor/devlog-core'; import { DevlogService, ProjectService } from '@codervisor/devlog-core'; -import { ApiErrors, createSuccessResponse, RouteParams, SSEEventType } from '@/lib'; +import { ApiErrors, createSuccessResponse, RouteParams } from '@/lib'; +import { RealtimeEventType } from '@/lib/realtime'; import { DevlogAddNoteBodySchema, DevlogUpdateWithNoteBodySchema } from '@/schemas'; // Mark this route as dynamic to prevent static generation @@ -102,12 +103,12 @@ export async function POST( // Add the note directly using the new addNote method const newNote = await devlogService.addNote(devlogId, { content: note, - category: (category || 'progress') as NoteCategory, + category: (category || 'progress') as DevlogNoteCategory, }); return createSuccessResponse(newNote, { status: 201, - sseEventType: SSEEventType.DEVLOG_NOTE_CREATED, + sseEventType: RealtimeEventType.DEVLOG_NOTE_CREATED, }); } catch (error) { console.error('Error adding devlog note:', error); @@ -167,12 +168,12 @@ export async function PUT( // Add the note using the dedicated method await devlogService.addNote(devlogId, { content: note, - category: (category || 'progress') as NoteCategory, + category: (category || 'progress') as DevlogNoteCategory, }); // Return the updated entry with the note const finalEntry = await devlogService.get(devlogId, true); // Load with notes - return createSuccessResponse(finalEntry, { sseEventType: SSEEventType.DEVLOG_UPDATED }); + return createSuccessResponse(finalEntry, { sseEventType: RealtimeEventType.DEVLOG_UPDATED }); } catch (error) { console.error('Error updating devlog with note:', error); return ApiErrors.internalError('Failed to update devlog entry with note'); diff --git a/packages/web/app/api/projects/[id]/devlogs/[devlogId]/route.ts b/packages/web/app/api/projects/[id]/devlogs/[devlogId]/route.ts index e433cb41..7787c34d 100644 --- a/packages/web/app/api/projects/[id]/devlogs/[devlogId]/route.ts +++ b/packages/web/app/api/projects/[id]/devlogs/[devlogId]/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { DevlogService, ProjectService } from '@codervisor/devlog-core'; -import { ApiErrors, createSuccessResponse, SSEEventType, RouteParams } from '@/lib'; +import { ApiErrors, createSuccessResponse, RouteParams } from '@/lib'; +import { RealtimeEventType } from '@/lib/realtime'; // Mark this route as dynamic to prevent static generation export const dynamic = 'force-dynamic'; @@ -94,7 +95,7 @@ export async function PUT( await devlogService.save(updatedEntry); // Transform and return updated entry - return createSuccessResponse(updatedEntry, { sseEventType: SSEEventType.DEVLOG_UPDATED }); + return createSuccessResponse(updatedEntry, { sseEventType: RealtimeEventType.DEVLOG_UPDATED }); } catch (error) { console.error('Error updating devlog:', error); const message = error instanceof Error ? error.message : 'Failed to update devlog'; @@ -135,7 +136,7 @@ export async function DELETE( return createSuccessResponse( { deleted: true, devlogId }, - { sseEventType: SSEEventType.DEVLOG_DELETED }, + { sseEventType: RealtimeEventType.DEVLOG_DELETED }, ); } catch (error) { console.error('Error deleting devlog:', error); diff --git a/packages/web/app/api/projects/[id]/devlogs/route.ts b/packages/web/app/api/projects/[id]/devlogs/route.ts index e5a456a6..adbe29d0 100644 --- a/packages/web/app/api/projects/[id]/devlogs/route.ts +++ b/packages/web/app/api/projects/[id]/devlogs/route.ts @@ -16,8 +16,8 @@ import { createCollectionResponse, createSimpleCollectionResponse, createSuccessResponse, - SSEEventType, } from '@/lib'; +import { RealtimeEventType } from '@/lib/realtime'; // Mark this route as dynamic to prevent static generation export const dynamic = 'force-dynamic'; @@ -144,7 +144,7 @@ export async function POST(request: NextRequest, { params }: { params: { id: str // Transform and return the actual saved devlog return createSuccessResponse(savedEntry, { status: 201, - sseEventType: SSEEventType.DEVLOG_CREATED, + sseEventType: RealtimeEventType.DEVLOG_CREATED, }); } catch (error) { console.error('Error creating devlog:', error); diff --git a/packages/web/app/api/projects/[id]/route.ts b/packages/web/app/api/projects/[id]/route.ts index 8d60e61a..db42c770 100644 --- a/packages/web/app/api/projects/[id]/route.ts +++ b/packages/web/app/api/projects/[id]/route.ts @@ -3,7 +3,7 @@ import { createSuccessResponse, RouteParams, ServiceHelper, - SSEEventType, + RealtimeEventType, withErrorHandling, } from '@/lib'; import { ApiValidator, UpdateProjectBodySchema } from '@/schemas'; @@ -56,13 +56,10 @@ export const PUT = withErrorHandling( return projectResult.response; } - // Parse request body - const data = await request.json(); - // Update project - const updatedProject = await projectResult.data.projectService.update(projectId, data); + const updatedProject = await projectResult.data.projectService.update(projectId, bodyValidation.data); - return createSuccessResponse(updatedProject, { sseEventType: SSEEventType.PROJECT_UPDATED }); + return createSuccessResponse(updatedProject, { sseEventType: RealtimeEventType.PROJECT_UPDATED }); }, ); @@ -88,7 +85,7 @@ export const DELETE = withErrorHandling( return createSuccessResponse( { deleted: true, projectId }, - { sseEventType: SSEEventType.PROJECT_DELETED }, + { sseEventType: RealtimeEventType.PROJECT_DELETED }, ); }, ); diff --git a/packages/web/app/api/projects/route.ts b/packages/web/app/api/projects/route.ts index 6b907bf6..506a5210 100644 --- a/packages/web/app/api/projects/route.ts +++ b/packages/web/app/api/projects/route.ts @@ -1,12 +1,7 @@ import { NextRequest } from 'next/server'; import { ProjectService } from '@codervisor/devlog-core'; import { ApiValidator, CreateProjectBodySchema, WebToServiceProjectCreateSchema } from '@/schemas'; -import { - ApiErrors, - createSimpleCollectionResponse, - createSuccessResponse, - SSEEventType, -} from '@/lib'; +import { ApiErrors, createSimpleCollectionResponse, createSuccessResponse } from '@/lib'; import { RealtimeEventType } from '@/lib/realtime'; // Mark this route as dynamic to prevent static generation diff --git a/packages/web/app/api/realtime/config/route.ts b/packages/web/app/api/realtime/config/route.ts new file mode 100644 index 00000000..e1f7c27f --- /dev/null +++ b/packages/web/app/api/realtime/config/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { serverRealtimeService } from '@/lib/api/server-realtime'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/realtime/config + * + * Returns the server's realtime configuration so clients know which provider to use + */ +export async function GET() { + try { + const config = serverRealtimeService.getRealtimeConfig(); + + return NextResponse.json({ + success: true, + data: config, + }); + } catch (error) { + console.error('[Realtime Config API] Error:', error); + + return NextResponse.json( + { + success: false, + error: 'Failed to get realtime configuration', + }, + { status: 500 } + ); + } +} diff --git a/packages/web/app/components/common/project-card-skeleton/ProjectCardSkeleton.tsx b/packages/web/app/components/common/project-card-skeleton/ProjectCardSkeleton.tsx index 7c83deab..e0d57bea 100644 --- a/packages/web/app/components/common/project-card-skeleton/ProjectCardSkeleton.tsx +++ b/packages/web/app/components/common/project-card-skeleton/ProjectCardSkeleton.tsx @@ -6,7 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton'; export function ProjectCardSkeleton() { return ( - +
@@ -28,7 +28,7 @@ export function ProjectCardSkeleton() { export function ProjectGridSkeleton({ count = 6 }: { count?: number }) { return ( -
+
{Array.from({ length: count }).map((_, index) => ( ))} diff --git a/packages/web/app/components/features/devlogs/DevlogDetails.tsx b/packages/web/app/components/features/devlogs/DevlogDetails.tsx index 98051f9d..a04cbe08 100644 --- a/packages/web/app/components/features/devlogs/DevlogDetails.tsx +++ b/packages/web/app/components/features/devlogs/DevlogDetails.tsx @@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { + AlertTriangleIcon, Briefcase, CheckCircle, ChevronRight, @@ -14,7 +15,7 @@ import { Network, Wrench, } from 'lucide-react'; -import { DevlogEntry, DevlogNote, NoteCategory } from '@codervisor/devlog-core'; +import { DevlogEntry, DevlogNote, DevlogNoteCategory } from '@codervisor/devlog-core'; import { EditableField } from '@/components/custom/EditableField'; import { MarkdownRenderer } from '@/components/custom/MarkdownRenderer'; import { @@ -25,7 +26,13 @@ import { statusOptions, typeOptions, } from '@/lib'; -import { DevlogPriorityTag, DevlogStatusTag, DevlogTypeTag } from '@/components'; +import { + Alert, + AlertDescription, + DevlogPriorityTag, + DevlogStatusTag, + DevlogTypeTag, +} from '@/components'; import { DevlogAnchorNav } from './DevlogAnchorNav'; import { DataContext } from '@/stores/base'; @@ -196,6 +203,18 @@ export function DevlogDetails({ } }, [hasUnsavedChanges, handleSave, handleDiscard, onUnsavedChangesChange]); + if (devlogContext.error) { + return ( + + +
+
Error Loading Devlog
+ {devlogContext.error} +
+
+ ); + } + const skeletonHeaders = (
@@ -531,7 +550,7 @@ export function DevlogDetails({ )} >
- {getCategoryIconRaw(note.category as NoteCategory)} + {getCategoryIconRaw(note.category as DevlogNoteCategory)} {note.category} diff --git a/packages/web/app/components/features/devlogs/DevlogList.tsx b/packages/web/app/components/features/devlogs/DevlogList.tsx index 54581012..d70f7811 100644 --- a/packages/web/app/components/features/devlogs/DevlogList.tsx +++ b/packages/web/app/components/features/devlogs/DevlogList.tsx @@ -42,7 +42,7 @@ import { } from '@/components/ui/alert-dialog'; import { toast } from 'sonner'; import { Edit, Eye, Search, Trash2, X } from 'lucide-react'; -import { DevlogEntry, DevlogFilter, DevlogId, NoteCategory } from '@codervisor/devlog-core'; +import { DevlogEntry, DevlogFilter, DevlogId, DevlogNoteCategory } from '@codervisor/devlog-core'; import { DevlogPriorityTag, DevlogStatusTag, DevlogTypeTag, Pagination } from '@/components'; import { cn, formatTimeAgoWithTooltip, priorityOptions, statusOptions, typeOptions } from '@/lib'; import { TableDataContext } from '@/stores/base'; @@ -82,7 +82,7 @@ export function DevlogList({ }); const [batchNoteForm, setBatchNoteForm] = useState({ content: '', - category: 'progress' as NoteCategory, + category: 'progress' as DevlogNoteCategory, }); const [batchOperationProgress, setBatchOperationProgress] = useState<{ visible: boolean; diff --git a/packages/web/app/components/layout/NavigationBreadcrumb.tsx b/packages/web/app/components/layout/NavigationBreadcrumb.tsx index 3fcbaaf6..52fdfb4d 100644 --- a/packages/web/app/components/layout/NavigationBreadcrumb.tsx +++ b/packages/web/app/components/layout/NavigationBreadcrumb.tsx @@ -1,10 +1,15 @@ 'use client'; import React from 'react'; -import { useRouter, usePathname } from 'next/navigation'; -import { useProjectStore } from '@/stores'; -import { CheckIcon, ChevronDownIcon, Package } from 'lucide-react'; -import { Breadcrumb, BreadcrumbItem, BreadcrumbList } from '@/components/ui/breadcrumb'; +import { usePathname, useRouter } from 'next/navigation'; +import { useDevlogStore, useProjectStore } from '@/stores'; +import { CheckIcon, ChevronsUpDown, NotepadText, Package } from 'lucide-react'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; import { DropdownMenu, DropdownMenuContent, @@ -19,20 +24,13 @@ export function NavigationBreadcrumb() { const pathname = usePathname(); const { currentProjectContext, currentProjectId, projectsContext, fetchProjects } = useProjectStore(); + const { currentDevlogContext, currentDevlogId } = useDevlogStore(); // Don't show breadcrumb on the home or project list page if (['/', '/projects'].includes(pathname)) { return null; } - const getProjectInitials = (name: string) => { - return name - .split(' ') - .map((word) => word.charAt(0).toUpperCase()) - .join('') - .substring(0, 2); - }; - const switchProject = async (projectId: number) => { if (currentProjectId === projectId) return; @@ -64,9 +62,7 @@ export function NavigationBreadcrumb() { if (currentProjectContext.loading) { return (
- - - +
); } @@ -77,7 +73,7 @@ export function NavigationBreadcrumb() {
{currentProjectContext.data?.name} - +
@@ -117,10 +113,34 @@ export function NavigationBreadcrumb() { ); }; + const renderDevlogDropdown = () => { + if (currentDevlogContext.loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + {currentDevlogContext.data?.id} + +
+ ); + }; + return ( - {renderProjectDropdown()} + {currentProjectId && {renderProjectDropdown()}} + {currentDevlogId && ( + <> + + {renderDevlogDropdown()} + + )} ); diff --git a/packages/web/app/components/layout/NavigationSidebar.tsx b/packages/web/app/components/layout/NavigationSidebar.tsx index 7f02de14..1ffc01c2 100644 --- a/packages/web/app/components/layout/NavigationSidebar.tsx +++ b/packages/web/app/components/layout/NavigationSidebar.tsx @@ -13,7 +13,7 @@ import { SidebarTrigger, useSidebar, } from '@/components/ui/sidebar'; -import { Boxes, Home, SquareKanban } from 'lucide-react'; +import { Boxes, Home, Settings, SquareKanban } from 'lucide-react'; interface SidebarItem { key: string; @@ -62,6 +62,12 @@ export function NavigationSidebar() { icon: , onClick: () => router.push(`/projects/${getProjectId()}/devlogs`), }, + { + key: 'settings', + label: 'Settings', + icon: , + onClick: () => router.push(`/projects/${getProjectId()}/settings`), + }, ]; // Get contextual menu items based on current path @@ -85,13 +91,11 @@ export function NavigationSidebar() { if (pathname === '/' || pathname === '/projects') return 'projects'; if (pathParts.length === 2 && pathParts[0] === 'projects') return 'overview'; - if (pathParts.length === 3 && pathParts[2] === 'devlogs') return 'list'; - if (pathParts.length === 4 && pathParts[2] === 'devlogs') return 'list'; + if (pathParts.length >= 3 && pathParts[2] === 'devlogs') return 'list'; + if (pathParts.length >= 3 && pathParts[2] === 'settings') return 'settings'; return 'overview'; - }; - - // Don't render menu items until mounted to prevent hydration issues + }; // Don't render menu items until mounted to prevent hydration issues if (!mounted) { return null; } diff --git a/packages/web/app/hooks/use-realtime.ts b/packages/web/app/hooks/use-realtime.ts index fd6e907a..76a73eda 100644 --- a/packages/web/app/hooks/use-realtime.ts +++ b/packages/web/app/hooks/use-realtime.ts @@ -9,26 +9,19 @@ import { useRealtimeStore } from '@/stores/realtime-store'; import { RealtimeEventType } from '@/lib/realtime'; export function useRealtime() { - const { connected, connect, disconnect, subscribe, unsubscribe, getProviderType } = useRealtimeStore(); - - useEffect(() => { - // Auto-connect when hook is used - connect(); - - // Cleanup on unmount - return () => { - disconnect(); - }; - }, [connect, disconnect]); - - const subscribeToEvent = useCallback((eventType: string, callback: (data: any) => void) => { - subscribe(eventType, callback); - - // Return unsubscribe function - return () => { - unsubscribe(eventType); - }; - }, [subscribe, unsubscribe]); + const { connected, subscribe, unsubscribe, getProviderType } = useRealtimeStore(); + + const subscribeToEvent = useCallback( + (eventType: string, callback: (data: any) => void) => { + subscribe(eventType, callback); + + // Return unsubscribe function + return () => { + unsubscribe(eventType); + }; + }, + [subscribe, unsubscribe], + ); return { connected, @@ -44,17 +37,26 @@ export function useRealtime() { export function useDevlogEvents() { const { subscribe } = useRealtime(); - const onDevlogCreated = useCallback((callback: (devlog: any) => void) => { - return subscribe(RealtimeEventType.DEVLOG_CREATED, callback); - }, [subscribe]); - - const onDevlogUpdated = useCallback((callback: (devlog: any) => void) => { - return subscribe(RealtimeEventType.DEVLOG_UPDATED, callback); - }, [subscribe]); - - const onDevlogDeleted = useCallback((callback: (data: { id: number }) => void) => { - return subscribe(RealtimeEventType.DEVLOG_DELETED, callback); - }, [subscribe]); + const onDevlogCreated = useCallback( + (callback: (devlog: any) => void) => { + return subscribe(RealtimeEventType.DEVLOG_CREATED, callback); + }, + [subscribe], + ); + + const onDevlogUpdated = useCallback( + (callback: (devlog: any) => void) => { + return subscribe(RealtimeEventType.DEVLOG_UPDATED, callback); + }, + [subscribe], + ); + + const onDevlogDeleted = useCallback( + (callback: (data: { id: number }) => void) => { + return subscribe(RealtimeEventType.DEVLOG_DELETED, callback); + }, + [subscribe], + ); return { onDevlogCreated, @@ -69,17 +71,26 @@ export function useDevlogEvents() { export function useProjectEvents() { const { subscribe } = useRealtime(); - const onProjectCreated = useCallback((callback: (project: any) => void) => { - return subscribe(RealtimeEventType.PROJECT_CREATED, callback); - }, [subscribe]); - - const onProjectUpdated = useCallback((callback: (project: any) => void) => { - return subscribe(RealtimeEventType.PROJECT_UPDATED, callback); - }, [subscribe]); - - const onProjectDeleted = useCallback((callback: (data: { id: number }) => void) => { - return subscribe(RealtimeEventType.PROJECT_DELETED, callback); - }, [subscribe]); + const onProjectCreated = useCallback( + (callback: (project: any) => void) => { + return subscribe(RealtimeEventType.PROJECT_CREATED, callback); + }, + [subscribe], + ); + + const onProjectUpdated = useCallback( + (callback: (project: any) => void) => { + return subscribe(RealtimeEventType.PROJECT_UPDATED, callback); + }, + [subscribe], + ); + + const onProjectDeleted = useCallback( + (callback: (data: { id: number }) => void) => { + return subscribe(RealtimeEventType.PROJECT_DELETED, callback); + }, + [subscribe], + ); return { onProjectCreated, @@ -87,3 +98,37 @@ export function useProjectEvents() { onProjectDeleted, }; } + +/** + * Hook for subscribing to note events + */ +export function useNoteEvents() { + const { subscribe } = useRealtime(); + + const onNoteCreated = useCallback( + (callback: (note: any) => void) => { + return subscribe(RealtimeEventType.DEVLOG_NOTE_CREATED, callback); + }, + [subscribe], + ); + + const onNoteUpdated = useCallback( + (callback: (note: any) => void) => { + return subscribe(RealtimeEventType.DEVLOG_NOTE_UPDATED, callback); + }, + [subscribe], + ); + + const onNoteDeleted = useCallback( + (callback: (data: { id: string }) => void) => { + return subscribe(RealtimeEventType.DEVLOG_NOTE_DELETED, callback); + }, + [subscribe], + ); + + return { + onNoteCreated, + onNoteUpdated, + onNoteDeleted, + }; +} diff --git a/packages/web/app/lib/api/index.ts b/packages/web/app/lib/api/index.ts index 858a26d0..90d0c0c7 100644 --- a/packages/web/app/lib/api/index.ts +++ b/packages/web/app/lib/api/index.ts @@ -7,5 +7,4 @@ export * from './api-client'; export * from './api-utils'; export * from './devlog-api-client'; export * from './note-api-client'; -export * from './sse-utils'; export * from './server-realtime'; diff --git a/packages/web/app/lib/api/note-api-client.ts b/packages/web/app/lib/api/note-api-client.ts index 2b98a3eb..7ed7f630 100644 --- a/packages/web/app/lib/api/note-api-client.ts +++ b/packages/web/app/lib/api/note-api-client.ts @@ -1,14 +1,14 @@ -import type { DevlogNote, NoteCategory } from '@codervisor/devlog-core'; +import type { DevlogNote, DevlogNoteCategory } from '@codervisor/devlog-core'; import { apiClient } from './api-client'; export interface CreateNoteRequest { content: string; - category?: NoteCategory; + category?: DevlogNoteCategory; } export interface UpdateNoteRequest { content?: string; - category?: NoteCategory; + category?: DevlogNoteCategory; } export class NoteApiClient { diff --git a/packages/web/app/lib/api/server-realtime.ts b/packages/web/app/lib/api/server-realtime.ts index 5f605995..a033658a 100644 --- a/packages/web/app/lib/api/server-realtime.ts +++ b/packages/web/app/lib/api/server-realtime.ts @@ -3,9 +3,9 @@ */ import Pusher from 'pusher'; -import { RealtimeEventType } from '../realtime/types'; +import { RealtimeConfig, RealtimeEventType } from '../realtime/types'; -// Keep track of active SSE connections (moved from sse-utils.ts) +// Keep track of active SSE connections export const activeConnections = new Set(); interface BroadcastMessage { @@ -19,9 +19,11 @@ export class ServerRealtimeService { private pusher: Pusher | null = null; private usePusher = false; private channelName = 'devlog-updates'; + private activeProvider: 'sse' | 'pusher' = 'sse'; private constructor() { this.initializePusher(); + this.selectActiveProvider(); } static getInstance(): ServerRealtimeService { @@ -54,11 +56,42 @@ export class ServerRealtimeService { this.usePusher = false; } } else { - console.log('[Server Realtime] Pusher not configured, using SSE only'); + console.log('[Server Realtime] Pusher not configured'); this.usePusher = false; } } + /** + * Selects the active provider based on deployment environment + */ + private selectActiveProvider(): void { + // Check for explicit provider override + const forceProvider = process.env.NEXT_PUBLIC_REALTIME_PROVIDER; + if (forceProvider === 'sse' || forceProvider === 'pusher') { + if (forceProvider === 'pusher' && this.usePusher) { + this.activeProvider = 'pusher'; + } else { + this.activeProvider = 'sse'; + } + console.log(`[Server Realtime] Using forced provider: ${this.activeProvider}`); + return; + } + + // Auto-detect based on deployment environment + const isVercel = process.env.VERCEL === '1'; + const isNetlify = process.env.NETLIFY === 'true'; + const isServerless = isVercel || isNetlify; + + // Use Pusher for serverless deployments if configured, otherwise SSE + if (isServerless && this.usePusher) { + this.activeProvider = 'pusher'; + console.log('[Server Realtime] Detected serverless environment, using Pusher'); + } else { + this.activeProvider = 'sse'; + console.log('[Server Realtime] Using SSE for traditional deployment'); + } + } + /** * Broadcast a message to all connected clients */ @@ -69,16 +102,18 @@ export class ServerRealtimeService { timestamp: new Date().toISOString(), }; - // Broadcast via SSE to active connections - this.broadcastSSE(message); - - // Broadcast via Pusher if configured - if (this.usePusher && this.pusher) { + // Only broadcast via the active provider + if (this.activeProvider === 'pusher' && this.usePusher && this.pusher) { try { await this.broadcastPusher(message); + console.log(`[Server Realtime] Broadcast sent via Pusher: ${type}`); } catch (error) { - console.error('[Server Realtime] Pusher broadcast failed:', error); + console.error('[Server Realtime] Pusher broadcast failed, falling back to SSE:', error); + this.broadcastSSE(message); } + } else { + this.broadcastSSE(message); + console.log(`[Server Realtime] Broadcast sent via SSE: ${type}`); } } @@ -87,15 +122,20 @@ export class ServerRealtimeService { */ private broadcastSSE(message: BroadcastMessage): void { const sseMessage = JSON.stringify(message); - - console.log(`[Server Realtime] Broadcasting SSE: ${message.type} to ${activeConnections.size} connections`); + + console.log( + `[Server Realtime] Broadcasting SSE: ${message.type} to ${activeConnections.size} connections`, + ); // Send to all active SSE connections for (const controller of activeConnections) { try { controller.enqueue(`data: ${sseMessage}\n\n`); } catch (error) { - console.error('[Server Realtime] Error sending SSE message, removing dead connection:', error); + console.error( + '[Server Realtime] Error sending SSE message, removing dead connection:', + error, + ); // Remove dead connections activeConnections.delete(controller); } @@ -121,18 +161,50 @@ export class ServerRealtimeService { * Get the current broadcasting method(s) */ getBroadcastMethods(): string[] { - const methods: string[] = ['sse']; - if (this.usePusher) { - methods.push('pusher'); - } - return methods; + return [this.activeProvider]; } /** * Check if Pusher is enabled */ isPusherEnabled(): boolean { - return this.usePusher; + return this.activeProvider === 'pusher' && this.usePusher; + } + + /** + * Get the active provider + */ + getActiveProvider(): 'sse' | 'pusher' { + return this.activeProvider; + } + + /** + * Get realtime configuration for client consumption + */ + getRealtimeConfig(): RealtimeConfig { + if (this.activeProvider === 'pusher') { + return { + provider: 'pusher', + pusher: { + appId: process.env.NEXT_PUBLIC_PUSHER_APP_ID, + key: process.env.NEXT_PUBLIC_PUSHER_KEY, + cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER, + useTLS: process.env.PUSHER_USE_TLS !== 'false', + channelName: this.channelName, + }, + }; + } + + if (this.activeProvider === 'sse') { + return { + provider: 'sse', + sse: { + endpoint: '/api/events', + }, + }; + } + + throw new Error('No active realtime provider configured'); } /** @@ -172,3 +244,11 @@ export class ServerRealtimeService { // Export singleton instance export const serverRealtimeService = ServerRealtimeService.getInstance(); + +// Function to broadcast updates to all connected clients +export function broadcastUpdate(type: string, data: any) { + // Use the new server realtime service + serverRealtimeService.broadcast(type, data).catch((error) => { + console.error('Error broadcasting update:', error); + }); +} diff --git a/packages/web/app/lib/api/sse-utils.ts b/packages/web/app/lib/api/sse-utils.ts deleted file mode 100644 index 2cbbe690..00000000 --- a/packages/web/app/lib/api/sse-utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * SSE Utilities (Legacy - now using server-realtime.ts) - */ - -import { serverRealtimeService } from './server-realtime'; - -export class SSEEventType { - static PROJECT_CREATED = 'project-created'; - static PROJECT_UPDATED = 'project-updated'; - static PROJECT_DELETED = 'project-deleted'; - static DEVLOG_CREATED = 'devlog-created'; - static DEVLOG_UPDATED = 'devlog-updated'; - static DEVLOG_DELETED = 'devlog-deleted'; - static DEVLOG_NOTE_CREATED = 'devlog-note-created'; - static DEVLOG_NOTE_UPDATED = 'devlog-note-updated'; - static DEVLOG_NOTE_DELETED = 'devlog-note-deleted'; -} - -// Re-export active connections for backward compatibility -export { activeConnections } from './server-realtime'; - -// Function to broadcast updates to all connected clients -export function broadcastUpdate(type: string, data: any) { - // Use the new server realtime service - serverRealtimeService.broadcast(type, data).catch((error) => { - console.error('Error broadcasting update:', error); - }); -} diff --git a/packages/web/app/lib/devlog/note-utils.tsx b/packages/web/app/lib/devlog/note-utils.tsx index 0b7cbb7c..c4f12415 100644 --- a/packages/web/app/lib/devlog/note-utils.tsx +++ b/packages/web/app/lib/devlog/note-utils.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { NoteCategory } from '@codervisor/devlog-core'; +import { DevlogNoteCategory } from '@codervisor/devlog-core'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { BellIcon, @@ -27,7 +27,7 @@ export interface NoteCategoryConfig { /** * Complete configuration for all note categories */ -export const noteCategoryConfig: Record = { +export const noteCategoryConfig: Record = { progress: { icon: , label: 'Progress', @@ -75,7 +75,7 @@ export const noteCategoryConfig: Record = { * @param category - The note category * @returns React node containing the appropriate icon with color and tooltip */ -export const getCategoryIcon = (category: NoteCategory): React.ReactNode => { +export const getCategoryIcon = (category: DevlogNoteCategory): React.ReactNode => { const config = noteCategoryConfig[category]; const icon = config?.icon || ; const label = config?.label || category; @@ -95,7 +95,7 @@ export const getCategoryIcon = (category: NoteCategory): React.ReactNode => { * @param category - The note category * @returns React node containing the appropriate icon with color */ -export const getCategoryIconRaw = (category: NoteCategory): React.ReactNode => { +export const getCategoryIconRaw = (category: DevlogNoteCategory): React.ReactNode => { const config = noteCategoryConfig[category]; return config?.icon || ; }; @@ -105,7 +105,7 @@ export const getCategoryIconRaw = (category: NoteCategory): React.ReactNode => { * @param category - The note category * @returns Human-readable label for the category */ -export const getCategoryLabel = (category: NoteCategory): string => { +export const getCategoryLabel = (category: DevlogNoteCategory): string => { return noteCategoryConfig[category]?.label || category; }; @@ -114,7 +114,7 @@ export const getCategoryLabel = (category: NoteCategory): string => { * @param category - The note category * @returns Description of when to use this category */ -export const getCategoryDescription = (category: NoteCategory): string => { +export const getCategoryDescription = (category: DevlogNoteCategory): string => { return noteCategoryConfig[category]?.description || ''; }; @@ -123,7 +123,7 @@ export const getCategoryDescription = (category: NoteCategory): string => { * @param category - The note category * @returns Tailwind color class for the category */ -export const getCategoryColor = (category: NoteCategory): string => { +export const getCategoryColor = (category: DevlogNoteCategory): string => { return noteCategoryConfig[category]?.color || 'muted-foreground'; }; @@ -133,7 +133,7 @@ export const getCategoryColor = (category: NoteCategory): string => { */ export const getNoteCategoryOptions = () => { return Object.entries(noteCategoryConfig).map(([value, config]) => ({ - value: value as NoteCategory, + value: value as DevlogNoteCategory, label: config.label, description: config.description, icon: config.icon, diff --git a/packages/web/app/lib/index.ts b/packages/web/app/lib/index.ts index a8689ee6..0b8edcc3 100644 --- a/packages/web/app/lib/index.ts +++ b/packages/web/app/lib/index.ts @@ -18,5 +18,8 @@ export * from './devlog'; // Routing utilities export * from './routing'; +// Realtime utilities +export * from './realtime'; + // General utilities export * from './utils'; diff --git a/packages/web/app/lib/realtime/config.ts b/packages/web/app/lib/realtime/config.ts index b1f03656..13ae0439 100644 --- a/packages/web/app/lib/realtime/config.ts +++ b/packages/web/app/lib/realtime/config.ts @@ -3,16 +3,17 @@ */ import type { RealtimeConfig, RealtimeProviderType } from './types'; +import { apiClient } from '@/lib/api/api-client'; export interface RealtimeEnvironmentConfig { // Auto-detection settings autoDetect?: boolean; forceProvider?: RealtimeProviderType; - + // SSE settings sseEndpoint?: string; sseReconnectInterval?: number; - + // Pusher settings pusherAppId?: string; pusherKey?: string; @@ -22,77 +23,109 @@ export interface RealtimeEnvironmentConfig { pusherChannelName?: string; } +// Cache for server configuration to avoid repeated API calls +let serverConfigCache: RealtimeConfig | null = null; +let serverConfigPromise: Promise | null = null; + /** - * Detects the deployment environment and returns the appropriate realtime provider type + * Fetches realtime configuration from the server */ -export function detectRealtimeProvider(): RealtimeProviderType { - // Check if we're in a browser environment - if (typeof window === 'undefined') { - return 'sse'; // Default for server-side +async function fetchServerRealtimeConfig(): Promise { + if (serverConfigCache) { + return serverConfigCache; + } + + if (serverConfigPromise) { + return serverConfigPromise; } + serverConfigPromise = (async () => { + try { + const data = await apiClient.get('/api/realtime/config'); + + if (data) { + serverConfigCache = data; + + console.log('[Realtime Config] Fetched from server:', serverConfigCache); + return serverConfigCache; + } else { + throw new Error('Invalid server response: no data'); + } + } catch (error) { + console.error( + '[Realtime Config] Failed to fetch from server, falling back to client detection:', + error, + ); + // Fall back to client-side detection if server is unreachable + return detectRealtimeProvider(); + } finally { + serverConfigPromise = null; + } + })(); + + return serverConfigPromise; +} + +/** + * Legacy client-side detection (fallback only) + * @deprecated Use fetchServerRealtimeConfig instead + */ +function detectRealtimeProvider(): RealtimeConfig { // Check for explicit environment variables (client-side) const forceProvider = process.env.NEXT_PUBLIC_REALTIME_PROVIDER as RealtimeProviderType; if (forceProvider && (forceProvider === 'sse' || forceProvider === 'pusher')) { - return forceProvider; + return createRealtimeConfigFromProvider(forceProvider); } // Auto-detect based on deployment platform - const isVercel = process.env.NEXT_PUBLIC_VERCEL === '1' || - process.env.VERCEL === '1' || - typeof window !== 'undefined' && window.location.hostname.includes('vercel.app'); - - const isNetlify = process.env.NEXT_PUBLIC_NETLIFY === 'true' || - typeof window !== 'undefined' && window.location.hostname.includes('netlify.app'); - + const isVercel = + process.env.NEXT_PUBLIC_VERCEL === '1' || + process.env.VERCEL === '1' || + (typeof window !== 'undefined' && window.location.hostname.includes('vercel.app')); + + const isNetlify = + process.env.NEXT_PUBLIC_NETLIFY === 'true' || + (typeof window !== 'undefined' && window.location.hostname.includes('netlify.app')); + const isServerless = isVercel || isNetlify; // Check if Pusher is configured const hasPusherConfig = !!( - process.env.NEXT_PUBLIC_PUSHER_KEY && - process.env.NEXT_PUBLIC_PUSHER_CLUSTER + process.env.NEXT_PUBLIC_PUSHER_KEY && process.env.NEXT_PUBLIC_PUSHER_CLUSTER ); // Use Pusher for serverless deployments if configured, otherwise fall back to SSE - if (isServerless && hasPusherConfig) { - return 'pusher'; - } + const provider: RealtimeProviderType = isServerless && hasPusherConfig ? 'pusher' : 'sse'; - // Default to SSE for all other cases - return 'sse'; + return createRealtimeConfigFromProvider(provider); } /** - * Creates a realtime configuration object from environment variables + * Creates configuration object for a specific provider */ -export function createRealtimeConfig(overrides: RealtimeEnvironmentConfig = {}): RealtimeConfig { - const provider = overrides.forceProvider || (overrides.autoDetect !== false ? detectRealtimeProvider() : 'sse'); - - const config: RealtimeConfig = { - provider, - }; +function createRealtimeConfigFromProvider(provider: RealtimeProviderType): RealtimeConfig { + const config: RealtimeConfig = { provider }; if (provider === 'pusher') { config.pusher = { - appId: overrides.pusherAppId || process.env.NEXT_PUBLIC_PUSHER_APP_ID || '', - key: overrides.pusherKey || process.env.NEXT_PUBLIC_PUSHER_KEY || '', - secret: overrides.pusherSecret || process.env.PUSHER_SECRET || '', - cluster: overrides.pusherCluster || process.env.NEXT_PUBLIC_PUSHER_CLUSTER || 'us2', - useTLS: overrides.pusherUseTLS ?? (process.env.NEXT_PUBLIC_PUSHER_USE_TLS !== 'false'), + appId: process.env.NEXT_PUBLIC_PUSHER_APP_ID || '', + key: process.env.NEXT_PUBLIC_PUSHER_KEY || '', + secret: process.env.PUSHER_SECRET || '', + cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER || 'us2', + useTLS: process.env.NEXT_PUBLIC_PUSHER_USE_TLS !== 'false', }; // Validate required Pusher configuration if (!config.pusher.key || !config.pusher.cluster) { console.warn('[Realtime] Pusher configuration incomplete, falling back to SSE'); - config.provider = 'sse'; + return createRealtimeConfigFromProvider('sse'); } } - if (config.provider === 'sse') { + if (provider === 'sse') { config.sse = { - endpoint: overrides.sseEndpoint || process.env.NEXT_PUBLIC_SSE_ENDPOINT || '/api/events', - reconnectInterval: overrides.sseReconnectInterval || - parseInt(process.env.NEXT_PUBLIC_SSE_RECONNECT_INTERVAL || '3000', 10), + endpoint: process.env.NEXT_PUBLIC_SSE_ENDPOINT || '/api/events', + reconnectInterval: parseInt(process.env.NEXT_PUBLIC_SSE_RECONNECT_INTERVAL || '3000', 10), }; } @@ -100,10 +133,44 @@ export function createRealtimeConfig(overrides: RealtimeEnvironmentConfig = {}): } /** - * Gets the current realtime configuration + * Creates a realtime configuration object from environment variables + * @deprecated Use getRealtimeConfig() which fetches from server */ -export function getRealtimeConfig(): RealtimeConfig { - return createRealtimeConfig(); +export function createRealtimeConfig(overrides: RealtimeEnvironmentConfig = {}): RealtimeConfig { + const provider = overrides.forceProvider; + + if (provider) { + return createRealtimeConfigFromProvider(provider); + } + + // Fall back to client detection + return detectRealtimeProvider(); +} + +/** + * Gets the current realtime configuration from the server + */ +export async function getRealtimeConfig(): Promise { + // On server side, fall back to client detection + if (typeof window === 'undefined') { + return detectRealtimeProvider(); + } + + // On client side, fetch from server + return await fetchServerRealtimeConfig(); +} + +/** + * Synchronous version that uses cached config or falls back to client detection + * @deprecated Use getRealtimeConfig() for better server synchronization + */ +export function getRealtimeConfigSync(): RealtimeConfig { + if (serverConfigCache) { + return serverConfigCache; + } + + // Fall back to client detection if no cached config + return detectRealtimeProvider(); } /** @@ -118,17 +185,14 @@ export function isSSESupported(): boolean { * Checks if Pusher is properly configured */ export function isPusherConfigured(): boolean { - return !!( - process.env.NEXT_PUBLIC_PUSHER_KEY && - process.env.NEXT_PUBLIC_PUSHER_CLUSTER - ); + return !!(process.env.NEXT_PUBLIC_PUSHER_KEY && process.env.NEXT_PUBLIC_PUSHER_CLUSTER); } /** * Logs the current realtime configuration (for debugging) */ export function logRealtimeConfig(): void { - const config = getRealtimeConfig(); + const config = getRealtimeConfigSync(); console.log('[Realtime] Current configuration:', { provider: config.provider, isVercel: process.env.NEXT_PUBLIC_VERCEL === '1', diff --git a/packages/web/app/lib/realtime/index.ts b/packages/web/app/lib/realtime/index.ts index bdfd8ad9..8d587760 100644 --- a/packages/web/app/lib/realtime/index.ts +++ b/packages/web/app/lib/realtime/index.ts @@ -6,9 +6,9 @@ export { RealtimeService } from './realtime-service'; export { SSEProvider } from './sse-provider'; export { PusherProvider } from './pusher-provider'; export { - detectRealtimeProvider, createRealtimeConfig, getRealtimeConfig, + getRealtimeConfigSync, isSSESupported, isPusherConfigured, logRealtimeConfig, diff --git a/packages/web/app/lib/realtime/realtime-service.ts b/packages/web/app/lib/realtime/realtime-service.ts index 1d0e218e..d91a2365 100644 --- a/packages/web/app/lib/realtime/realtime-service.ts +++ b/packages/web/app/lib/realtime/realtime-service.ts @@ -1,11 +1,11 @@ /** - * Main realtime service that manages provider selection and operation + * Frontend realtime service that manages provider selection and operation */ -import type { RealtimeProvider, RealtimeConnection } from './types'; +import type { RealtimeConnection, RealtimeProvider } from './types'; import { SSEProvider } from './sse-provider'; import { PusherProvider } from './pusher-provider'; -import { getRealtimeConfig, logRealtimeConfig } from './config'; +import { getRealtimeConfig, getRealtimeConfigSync } from './config'; export class RealtimeService { private static instance: RealtimeService | null = null; @@ -29,11 +29,11 @@ export class RealtimeService { return; } - const config = getRealtimeConfig(); - + const config = await getRealtimeConfig(); + // Log configuration for debugging if (process.env.NODE_ENV === 'development') { - logRealtimeConfig(); + console.log('[Realtime] Configuration received from server:', config); } try { @@ -43,6 +43,9 @@ export class RealtimeService { if (!config.pusher) { throw new Error('Pusher configuration missing'); } + if (!config.pusher.key || !config.pusher.cluster) { + throw new Error('Pusher key or cluster not configured'); + } this.provider = new PusherProvider({ key: config.pusher.key, cluster: config.pusher.cluster, @@ -53,10 +56,7 @@ export class RealtimeService { case 'sse': default: - this.provider = new SSEProvider( - config.sse?.endpoint, - config.sse?.reconnectInterval - ); + this.provider = new SSEProvider(config.sse?.endpoint, config.sse?.reconnectInterval); break; } @@ -67,7 +67,7 @@ export class RealtimeService { console.log(`[Realtime] Initialized with ${config.provider} provider`); } catch (error) { console.error('[Realtime] Failed to initialize provider:', error); - + // Fallback to SSE if Pusher fails if (config.provider === 'pusher') { console.log('[Realtime] Falling back to SSE provider'); @@ -150,7 +150,7 @@ export class RealtimeService { * Get the current provider type */ getProviderType(): string | null { - const config = getRealtimeConfig(); + const config = getRealtimeConfigSync(); return config.provider; } diff --git a/packages/web/app/lib/realtime/types.ts b/packages/web/app/lib/realtime/types.ts index ff883b88..ccb217c0 100644 --- a/packages/web/app/lib/realtime/types.ts +++ b/packages/web/app/lib/realtime/types.ts @@ -28,11 +28,12 @@ export type RealtimeProviderType = 'sse' | 'pusher'; export interface RealtimeConfig { provider: RealtimeProviderType; pusher?: { - appId: string; - key: string; - secret: string; - cluster: string; + appId?: string; + key?: string; + secret?: string; + cluster?: string; useTLS?: boolean; + channelName?: string; }; sse?: { endpoint: string; diff --git a/packages/web/app/lib/utils/debounce.ts b/packages/web/app/lib/utils/debounce.ts new file mode 100644 index 00000000..0ed68234 --- /dev/null +++ b/packages/web/app/lib/utils/debounce.ts @@ -0,0 +1,18 @@ +/** + * Debounce utility function + * + * Creates a debounced function that delays invoking func until after wait milliseconds + * have elapsed since the last time the debounced function was invoked. + */ + +export function debounce any>( + func: T, + wait: number = 100, +): (...args: Parameters) => void { + let timeoutId: NodeJS.Timeout; + + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), wait); + }; +} diff --git a/packages/web/app/lib/utils/index.ts b/packages/web/app/lib/utils/index.ts index 17b06228..6ad1de4a 100644 --- a/packages/web/app/lib/utils/index.ts +++ b/packages/web/app/lib/utils/index.ts @@ -3,5 +3,6 @@ * Shared utility functions and helpers */ +export * from './debounce'; export * from './time-utils'; export * from './utils'; diff --git a/packages/web/app/projects/ProjectManagementPage.tsx b/packages/web/app/projects/ProjectListPage.tsx similarity index 84% rename from packages/web/app/projects/ProjectManagementPage.tsx rename to packages/web/app/projects/ProjectListPage.tsx index 0a5bb6bf..5809c0b7 100644 --- a/packages/web/app/projects/ProjectManagementPage.tsx +++ b/packages/web/app/projects/ProjectListPage.tsx @@ -24,31 +24,33 @@ import { LoaderIcon, PlusIcon, Search, + Settings, } from 'lucide-react'; import { toast } from 'sonner'; -import { SSEEventType } from '@/lib'; +import { RealtimeEventType } from '@/lib'; interface ProjectFormData { name: string; description?: string; } -export function ProjectManagementPage() { +export function ProjectListPage() { const { projectsContext, fetchProjects } = useProjectStore(); const [isModalVisible, setIsModalVisible] = useState(false); const [creating, setCreating] = useState(false); const [formData, setFormData] = useState({ name: '', description: '' }); const router = useRouter(); - const { connect, disconnect, subscribe, unsubscribe } = useRealtimeStore(); + const { subscribe, unsubscribe } = useRealtimeStore(); useEffect(() => { - connect(); - subscribe(SSEEventType.PROJECT_CREATED, fetchProjects); + subscribe(RealtimeEventType.PROJECT_CREATED, async () => { + await fetchProjects(); + toast.success('Project created successfully'); + }); return () => { - unsubscribe(SSEEventType.PROJECT_CREATED); - disconnect(); + unsubscribe(RealtimeEventType.PROJECT_CREATED); }; - }, []); + }, [fetchProjects]); useEffect(() => { fetchProjects(); @@ -97,6 +99,11 @@ export function ProjectManagementPage() { router.push(`/projects/${projectId}`); }; + const handleProjectSettings = (e: React.MouseEvent, projectId: number) => { + e.stopPropagation(); // Prevent card click from triggering + router.push(`/projects/${projectId}/settings`); + }; + if (projectsContext.error) { return ( @@ -114,7 +121,9 @@ export function ProjectManagementPage() {
- +
+ ) : ( <> {/* Projects grid */} -
+
{projects?.map((project) => ( handleViewProject(project.id)} >
{project.name}
+
diff --git a/packages/web/app/projects/[id]/ProjectDetailsPage.tsx b/packages/web/app/projects/[id]/ProjectDetailsPage.tsx index 9c7e847e..4a5d0920 100644 --- a/packages/web/app/projects/[id]/ProjectDetailsPage.tsx +++ b/packages/web/app/projects/[id]/ProjectDetailsPage.tsx @@ -2,10 +2,10 @@ import React, { useEffect } from 'react'; import { Dashboard } from '@/components'; -import { useDevlogStore, useProjectStore, useRealtimeStore } from '@/stores'; +import { useDevlogStore, useProjectStore } from '@/stores'; +import { useDevlogEvents } from '@/hooks/use-realtime'; import { DevlogEntry } from '@codervisor/devlog-core'; import { useRouter } from 'next/navigation'; -import { SSEEventType } from '@/lib'; interface ProjectDetailsPageProps { projectId: number; @@ -25,24 +25,23 @@ export function ProjectDetailsPage({ projectId }: ProjectDetailsPageProps) { fetchTimeSeriesStats, } = useDevlogStore(); - const { connect, disconnect, subscribe, unsubscribe } = useRealtimeStore(); + const { onDevlogCreated, onDevlogUpdated, onDevlogDeleted } = useDevlogEvents(); const fetchAll = async () => { return await Promise.all([fetchTimeSeriesStats(), fetchStats(), fetchDevlogs()]); }; useEffect(() => { - connect(); - subscribe(SSEEventType.DEVLOG_CREATED, fetchAll); - subscribe(SSEEventType.DEVLOG_UPDATED, fetchAll); - subscribe(SSEEventType.DEVLOG_DELETED, fetchAll); + const unsubscribeCreated = onDevlogCreated(fetchAll); + const unsubscribeUpdated = onDevlogUpdated(fetchAll); + const unsubscribeDeleted = onDevlogDeleted(fetchAll); + return () => { - unsubscribe(SSEEventType.DEVLOG_CREATED); - unsubscribe(SSEEventType.DEVLOG_UPDATED); - unsubscribe(SSEEventType.DEVLOG_DELETED); - disconnect(); + unsubscribeCreated(); + unsubscribeUpdated(); + unsubscribeDeleted(); }; - }, []); + }, [onDevlogCreated, onDevlogUpdated, onDevlogDeleted]); useEffect(() => { setCurrentProjectId(projectId); diff --git a/packages/web/app/projects/[id]/devlogs/ProjectDevlogListPage.tsx b/packages/web/app/projects/[id]/devlogs/ProjectDevlogListPage.tsx index 2bb2f0fe..7a44337f 100644 --- a/packages/web/app/projects/[id]/devlogs/ProjectDevlogListPage.tsx +++ b/packages/web/app/projects/[id]/devlogs/ProjectDevlogListPage.tsx @@ -2,10 +2,10 @@ import React, { useEffect } from 'react'; import { DevlogList } from '@/components'; -import { useDevlogStore, useProjectStore, useRealtimeStore } from '@/stores'; +import { useDevlogStore, useProjectStore } from '@/stores'; +import { useDevlogEvents } from '@/hooks/use-realtime'; import { DevlogEntry, DevlogId } from '@codervisor/devlog-core'; import { useRouter } from 'next/navigation'; -import { SSEEventType } from '@/lib'; interface ProjectDevlogListPageProps { projectId: number; @@ -26,20 +26,19 @@ export function ProjectDevlogListPage({ projectId }: ProjectDevlogListPageProps) batchDelete, } = useDevlogStore(); - const { connect, disconnect, subscribe, unsubscribe } = useRealtimeStore(); + const { onDevlogCreated, onDevlogUpdated, onDevlogDeleted } = useDevlogEvents(); useEffect(() => { - connect(); - subscribe(SSEEventType.DEVLOG_CREATED, fetchDevlogs); - subscribe(SSEEventType.DEVLOG_UPDATED, fetchDevlogs); - subscribe(SSEEventType.DEVLOG_DELETED, fetchDevlogs); + const unsubscribeCreated = onDevlogCreated(fetchDevlogs); + const unsubscribeUpdated = onDevlogUpdated(fetchDevlogs); + const unsubscribeDeleted = onDevlogDeleted(fetchDevlogs); + return () => { - unsubscribe(SSEEventType.DEVLOG_CREATED); - unsubscribe(SSEEventType.DEVLOG_UPDATED); - unsubscribe(SSEEventType.DEVLOG_DELETED); - disconnect(); + unsubscribeCreated(); + unsubscribeUpdated(); + unsubscribeDeleted(); }; - }, []); + }, [onDevlogCreated, onDevlogUpdated, onDevlogDeleted, fetchDevlogs]); useEffect(() => { setCurrentProjectId(projectId); diff --git a/packages/web/app/projects/[id]/devlogs/[devlogId]/ProjectDevlogDetailsPage.tsx b/packages/web/app/projects/[id]/devlogs/[devlogId]/ProjectDevlogDetailsPage.tsx index f9ea2e2b..13741558 100644 --- a/packages/web/app/projects/[id]/devlogs/[devlogId]/ProjectDevlogDetailsPage.tsx +++ b/packages/web/app/projects/[id]/devlogs/[devlogId]/ProjectDevlogDetailsPage.tsx @@ -2,12 +2,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Button, DevlogDetails, Popover, PopoverContent, PopoverTrigger } from '@/components'; -import { useDevlogStore, useProjectStore, useRealtimeStore } from '@/stores'; +import { useDevlogStore, useProjectStore } from '@/stores'; +import { useDevlogEvents, useNoteEvents } from '@/hooks/use-realtime'; import { useRouter } from 'next/navigation'; import { ArrowLeftIcon, SaveIcon, TrashIcon, UndoIcon } from 'lucide-react'; import { toast } from 'sonner'; -import { SSEEventType } from '@/lib'; import { DevlogEntry } from '@codervisor/devlog-core'; +import { RealtimeEventType } from '@/lib/realtime'; interface ProjectDevlogDetailsPageProps { projectId: number; @@ -31,7 +32,8 @@ export function ProjectDevlogDetailsPage({ projectId, devlogId }: ProjectDevlogD clearCurrentDevlog, } = useDevlogStore(); - const { connect, disconnect, subscribe, unsubscribe } = useRealtimeStore(); + const { onDevlogUpdated, onDevlogDeleted } = useDevlogEvents(); + const { onNoteCreated, onNoteUpdated, onNoteDeleted } = useNoteEvents(); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -41,29 +43,42 @@ export function ProjectDevlogDetailsPage({ projectId, devlogId }: ProjectDevlogD const discardHandlerRef = useRef<(() => void) | null>(null); useEffect(() => { - connect(); - subscribe(SSEEventType.DEVLOG_UPDATED, ({ id }: DevlogEntry) => { - if (id === currentDevlogId) { + const unsubscribeUpdated = onDevlogUpdated((devlog: DevlogEntry) => { + if (devlog.id === currentDevlogId) { fetchCurrentDevlog(); } }); - subscribe(SSEEventType.DEVLOG_DELETED, ({ id }: DevlogEntry) => { + + const unsubscribeDeleted = onDevlogDeleted(({ id }: { id: number }) => { if (id === currentDevlogId) { router.push(`/projects/${projectId}/devlogs`); } }); - subscribe(SSEEventType.DEVLOG_NOTE_CREATED, fetchCurrentDevlogNotes); - subscribe(SSEEventType.DEVLOG_NOTE_UPDATED, fetchCurrentDevlogNotes); - subscribe(SSEEventType.DEVLOG_NOTE_DELETED, fetchCurrentDevlogNotes); + + // Subscribe to note events + const unsubscribeNoteCreated = onNoteCreated(fetchCurrentDevlogNotes); + const unsubscribeNoteUpdated = onNoteUpdated(fetchCurrentDevlogNotes); + const unsubscribeNoteDeleted = onNoteDeleted(fetchCurrentDevlogNotes); + return () => { - unsubscribe(SSEEventType.DEVLOG_UPDATED); - unsubscribe(SSEEventType.DEVLOG_DELETED); - unsubscribe(SSEEventType.DEVLOG_NOTE_CREATED); - unsubscribe(SSEEventType.DEVLOG_NOTE_UPDATED); - unsubscribe(SSEEventType.DEVLOG_NOTE_DELETED); - disconnect(); + unsubscribeUpdated(); + unsubscribeDeleted(); + unsubscribeNoteCreated(); + unsubscribeNoteUpdated(); + unsubscribeNoteDeleted(); }; - }, []); + }, [ + currentDevlogId, + fetchCurrentDevlog, + fetchCurrentDevlogNotes, + onDevlogUpdated, + onDevlogDeleted, + onNoteCreated, + onNoteUpdated, + onNoteDeleted, + router, + projectId, + ]); useEffect(() => { setCurrentProjectId(projectId); @@ -77,8 +92,12 @@ export function ProjectDevlogDetailsPage({ projectId, devlogId }: ProjectDevlogD useEffect(() => { if (!currentDevlogId) return; - fetchCurrentDevlog(); - fetchCurrentDevlogNotes(); + try { + fetchCurrentDevlog(); + fetchCurrentDevlogNotes(); + } catch (error) { + console.warn('Failed to fetch devlog:', error); + } // Clear selected devlog when component unmounts return () => { diff --git a/packages/web/app/projects/[id]/settings/ProjectSettingsPage.tsx b/packages/web/app/projects/[id]/settings/ProjectSettingsPage.tsx new file mode 100644 index 00000000..d1924c20 --- /dev/null +++ b/packages/web/app/projects/[id]/settings/ProjectSettingsPage.tsx @@ -0,0 +1,376 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useProjectStore } from '@/stores'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { LoaderIcon, SaveIcon, TrashIcon, AlertTriangleIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import { Project } from '@codervisor/devlog-core'; + +interface ProjectSettingsPageProps { + projectId: number; +} + +interface ProjectFormData { + name: string; + description?: string; +} + +export function ProjectSettingsPage({ projectId }: ProjectSettingsPageProps) { + const router = useRouter(); + const { + currentProjectContext, + currentProjectId, + setCurrentProjectId, + updateProject, + deleteProject, + fetchCurrentProject, + } = useProjectStore(); + + const [formData, setFormData] = useState({ name: '', description: '' }); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + const project = currentProjectContext.data; + + useEffect(() => { + setCurrentProjectId(projectId); + }, [projectId]); + + // Initialize form data when project loads + useEffect(() => { + if (project) { + setFormData({ + name: project.name, + description: project.description || '', + }); + } + }, [project]); + + // Check for changes + useEffect(() => { + if (project) { + const nameChanged = formData.name !== project.name; + const descriptionChanged = formData.description !== (project.description || ''); + setHasChanges(nameChanged || descriptionChanged); + } + }, [formData, project]); + + // Fetch project data if not loaded + useEffect(() => { + fetchCurrentProject(); + }, [currentProjectId]); + + const handleUpdateProject = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.name.trim()) { + toast.error('Project name is required'); + return; + } + + if (!project) { + toast.error('Project not found'); + return; + } + + try { + setIsUpdating(true); + + const updates: Partial = { + name: formData.name.trim(), + description: formData.description?.trim() || undefined, + }; + + await updateProject(project.id, updates); + toast.success('Project updated successfully'); + setHasChanges(false); + } catch (error) { + console.error('Error updating project:', error); + toast.error('Failed to update project'); + } finally { + setIsUpdating(false); + } + }; + + const handleDeleteProject = async () => { + if (!project) { + toast.error('Project not found'); + return; + } + + try { + setIsDeleting(true); + await deleteProject(project.id); + toast.success(`Project "${project.name}" deleted successfully`); + + // Navigate back to projects list + router.push('/projects'); + } catch (error) { + console.error('Error deleting project:', error); + toast.error('Failed to delete project'); + } finally { + setIsDeleting(false); + } + }; + + const handleResetForm = () => { + if (project) { + setFormData({ + name: project.name, + description: project.description || '', + }); + setHasChanges(false); + } + }; + + if (currentProjectContext.loading || !project) { + return ( +
+
+ {/* Header Skeleton */} +
+ + +
+ + {/* General Settings Skeleton */} + + + + + + +
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Project Information Skeleton */} + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + {/* Danger Zone Skeleton */} + + + + + + + + + +
+
+ ); + } + + if (currentProjectContext.error) { + return ( + + +
+
Error Loading Project
+ {currentProjectContext.error} +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

Project Settings

+

Manage your project information and settings

+
+ + {/* General Settings */} + + + General Information + Update your project name and description + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ +