From 5aa3e8d6856e605c84b15f88f08ea670b541c811 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Thu, 1 Oct 2026 01:43:26 +0200 Subject: [PATCH] Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5 --- .dockerignore | 35 + .env.example | 54 + .gitattributes | 10 + .gitignore | 64 + .nvmrc | 1 + Dockerfile.frontend | 42 + Dockerfile.unraid | 91 + PUBLIC_SOURCE_EXPORT.md | 3 + PUBLIC_SOURCE_MANIFEST.json | 3496 +++++++++ README.md | 114 + SECURITY.md | 13 + backend/.env.example | 40 + backend/.gitignore | 3 + backend/Dockerfile | 31 + backend/README.md | 126 + backend/package.json | 69 + .../migration.sql | 617 ++ .../migration.sql | 40 + .../migration.sql | 113 + .../migration.sql | 147 + .../migration.sql | 36 + .../migration.sql | 117 + .../migration.sql | 40 + .../migration.sql | 38 + .../migration.sql | 63 + .../migration.sql | 49 + .../migration.sql | 42 + .../migration.sql | 36 + .../migration.sql | 220 + .../migration.sql | 1 + .../migration.sql | 76 + .../migration.sql | 129 + .../migration.sql | 30 + .../migration.sql | 1 + .../migration.sql | 25 + .../migration.sql | 19 + .../migration.sql | 15 + .../migration.sql | 164 + .../migration.sql | 16 + backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 1967 +++++ backend/prisma/seed.ts | 828 +++ backend/scripts/audit-backend-contract.mjs | 283 + backend/scripts/docker-entrypoint.sh | 28 + backend/scripts/prisma-generate.mjs | 43 + backend/scripts/prisma-validate.mjs | 27 + backend/src/app.ts | 189 + backend/src/config/env.ts | 154 + backend/src/db/prisma.ts | 17 + backend/src/fixtures/aiActionFixtures.ts | 245 + backend/src/fixtures/buildFixtures.ts | 175 + backend/src/fixtures/captureFixtures.ts | 184 + backend/src/fixtures/checkFixtures.ts | 321 + backend/src/fixtures/contextPackFixtures.ts | 383 + backend/src/fixtures/featureFixtures.ts | 278 + backend/src/fixtures/insightFixtures.ts | 114 + backend/src/fixtures/knowledgeFixtures.ts | 157 + backend/src/fixtures/memoryRuleFixtures.ts | 161 + backend/src/fixtures/projectFixtures.ts | 453 ++ backend/src/fixtures/regressionFixtures.ts | 168 + backend/src/fixtures/relationshipFixtures.ts | 130 + backend/src/fixtures/reviewQueueFixtures.ts | 244 + backend/src/fixtures/snapshotFixtures.ts | 123 + backend/src/integrations/gitea/client.ts | 264 + backend/src/integrations/gitea/policy.ts | 92 + backend/src/integrations/gitea/portfolio.ts | 97 + backend/src/integrations/gitea/service.ts | 419 ++ backend/src/integrations/gitea/types.ts | 140 + backend/src/mcp/analysisWidget.ts | 36 + backend/src/mcp/autonomyServer.ts | 325 + backend/src/mcp/featureChangeset.ts | 27 + backend/src/mcp/portfolioTools.ts | 239 + backend/src/mcp/resultHelpers.ts | 60 + backend/src/mcp/scopeGuards.ts | 23 + backend/src/mcp/server.ts | 799 ++ backend/src/mcp/skills.ts | 72 + backend/src/mcp/widget.ts | 15 + backend/src/plugins/authentication.ts | 52 + backend/src/plugins/errorHandler.ts | 72 + backend/src/plugins/observability.ts | 73 + backend/src/plugins/requestId.ts | 32 + .../src/repositories/analysisJobRepository.ts | 314 + .../repositories/autonomyExecutionEvidence.ts | 18 + .../src/repositories/autonomyRepository.ts | 797 ++ .../src/repositories/blueprintsRepository.ts | 625 ++ .../src/repositories/brainstormRepository.ts | 518 ++ backend/src/repositories/captureRepository.ts | 235 + .../repositories/contextPacksRepository.ts | 367 + .../src/repositories/evidenceRepository.ts | 119 + .../repositories/featureDeliveryRepository.ts | 360 + .../repositories/featureGroupsRepository.ts | 43 + .../src/repositories/featuresRepository.ts | 138 + backend/src/repositories/findingRepository.ts | 455 ++ .../src/repositories/initiativesRepository.ts | 406 ++ .../src/repositories/p7RuntimeRepository.ts | 568 ++ .../p8KnowledgeMemoryRepository.ts | 270 + backend/src/repositories/projectSetupState.ts | 50 + .../src/repositories/projectsRepository.ts | 514 ++ .../repositories/releaseEvidenceRepository.ts | 211 + .../src/repositories/reviewAttentionPolicy.ts | 22 + .../src/repositories/reviewQueueRepository.ts | 448 ++ backend/src/repositories/runtimeRepository.ts | 424 ++ backend/src/routes/activity.ts | 205 + backend/src/routes/aiActions.ts | 331 + backend/src/routes/analysisJobs.ts | 84 + backend/src/routes/auth.ts | 47 + backend/src/routes/autonomy.ts | 201 + backend/src/routes/blueprints.ts | 217 + backend/src/routes/brainstorms.ts | 86 + backend/src/routes/builds.ts | 286 + backend/src/routes/capture.ts | 442 ++ backend/src/routes/checks.ts | 465 ++ backend/src/routes/contextPacks.ts | 415 ++ backend/src/routes/documentation.ts | 30 + backend/src/routes/entraAuth.ts | 74 + backend/src/routes/evidence.ts | 306 + backend/src/routes/features.ts | 293 + backend/src/routes/findings.ts | 106 + backend/src/routes/giteaIntegration.ts | 492 ++ backend/src/routes/health.ts | 26 + backend/src/routes/initiatives.ts | 148 + backend/src/routes/insights.ts | 268 + backend/src/routes/knowledge.ts | 99 + backend/src/routes/mcp.ts | 72 + backend/src/routes/mcpOAuth.ts | 76 + backend/src/routes/memoryRules.ts | 43 + backend/src/routes/metrics.ts | 45 + backend/src/routes/operatingSystem.ts | 193 + backend/src/routes/p7Runtime.ts | 331 + backend/src/routes/portfolio.ts | 116 + backend/src/routes/projects.ts | 414 ++ backend/src/routes/prompts.ts | 195 + backend/src/routes/regressions.ts | 330 + backend/src/routes/relationships.ts | 314 + backend/src/routes/releaseEvidence.ts | 315 + backend/src/routes/reviewQueue.ts | 605 ++ backend/src/routes/runtimePersistence.ts | 400 + backend/src/routes/snapshots.ts | 301 + backend/src/routes/systemReadiness.ts | 237 + backend/src/security/authStore.ts | 111 + backend/src/security/entraOidc.ts | 173 + backend/src/security/mcpOAuth.ts | 82 + backend/src/security/mcpServiceAuth.ts | 14 + backend/src/security/password.ts | 23 + backend/src/server.ts | 30 + backend/src/services/activityRuntime.ts | 248 + backend/src/services/analysisStrategy.ts | 39 + backend/src/services/attention.ts | 39 + backend/src/services/autonomyAssignments.ts | 28 + backend/src/services/autonomyDelivery.ts | 23 + backend/src/services/autonomyRecovery.ts | 74 + backend/src/services/autonomyReplanner.ts | 103 + backend/src/services/autonomyRoadmap.ts | 44 + backend/src/services/autonomyRuntime.ts | 380 + .../src/services/autonomyRuntimeControl.ts | 147 + backend/src/services/autonomyScheduler.ts | 136 + backend/src/services/autonomyWork.ts | 58 + backend/src/services/blueprintEngine.ts | 421 ++ backend/src/services/canonicalLifecycle.ts | 17 + backend/src/services/commandIntent.ts | 64 + backend/src/services/contextPackSelector.ts | 172 + backend/src/services/contextResolver.ts | 128 + .../src/services/deliveryReconciliation.ts | 44 + backend/src/services/diagnostics.ts | 224 + backend/src/services/evidenceGraph.ts | 76 + backend/src/services/featureDeliveryFlow.ts | 36 + backend/src/services/gitSourceSync.ts | 87 + .../src/services/giteaAutonomyExecution.ts | 343 + backend/src/services/portfolioControlPlane.ts | 549 ++ backend/src/services/projectCompletion.ts | 171 + .../src/services/projectHealthSemantics.ts | 24 + backend/src/services/projectKnowledge.ts | 178 + .../services/projectRepositoryProvisioning.ts | 294 + backend/src/services/pulse.ts | 17 + backend/src/services/recoveryPolicy.ts | 35 + backend/src/services/repositoryAnalysis.ts | 556 ++ backend/src/services/repositoryAutonomy.ts | 355 + backend/src/services/workGraph.ts | 573 ++ backend/src/types/api.ts | 1599 ++++ backend/src/utils/envelope.ts | 32 + backend/src/utils/idempotency.ts | 72 + backend/src/workers/analysisWorker.ts | 167 + backend/test/activity-runtime.test.ts | 136 + backend/test/analysis-job-capacity.test.ts | 93 + backend/test/analysisStrategy.test.ts | 18 + backend/test/authentication.test.ts | 187 + backend/test/autonomy.test.ts | 331 + backend/test/autonomyRecovery.test.ts | 43 + backend/test/autonomyReplanner.test.ts | 41 + backend/test/autonomyScheduler.test.ts | 44 + backend/test/autonomyWork.test.ts | 42 + backend/test/canonicalLifecycle.test.ts | 21 + backend/test/database-authentication.test.ts | 300 + backend/test/database-autonomy.test.ts | 165 + backend/test/database-repository-sync.test.ts | 152 + ...elivery-pullrequest-reconciliation.test.ts | 35 + backend/test/deliveryLifecycle.test.ts | 19 + backend/test/entra-oidc.test.ts | 101 + backend/test/feature-delivery-policy.test.ts | 182 + backend/test/featureDeliveryFlow.test.ts | 20 + backend/test/finding-reconciliation.test.ts | 256 + backend/test/gitea-autonomy-execution.test.ts | 24 + backend/test/gitea-integration.test.ts | 519 ++ backend/test/gitea-portfolio-mcp.test.ts | 83 + backend/test/mcp-efficiency.test.ts | 37 + backend/test/mcp-skills.test.ts | 56 + backend/test/native-ci-discovery.test.ts | 78 + backend/test/operatingSystemServices.test.ts | 78 + backend/test/portfolioControlPlane.test.ts | 99 + backend/test/project-create-route.test.ts | 78 + backend/test/project-setup-state.test.ts | 36 + backend/test/projectHealthSemantics.test.ts | 44 + backend/test/projectKnowledge.test.ts | 21 + backend/test/recoveryPolicy.test.ts | 40 + backend/test/remediation-regressions.test.ts | 130 + backend/test/repository-analysis.test.ts | 419 ++ backend/test/repository-autonomy.test.ts | 194 + backend/test/repository-ownership.test.ts | 48 + backend/test/review-attention-policy.test.ts | 27 + .../test/review-capture-state-machine.test.ts | 98 + backend/test/system-readiness.test.ts | 96 + backend/tsconfig.json | 17 + backend/vitest.config.ts | 14 + components.json | 20 + config/architecture-budgets.json | 32 + config/v1-contract-freeze.json | 34 + docker-compose.dev.yml | 20 + docker-compose.release.yml | 18 + docker-compose.yml | 131 + docker/nginx.conf | 66 + docker/nginx.single.conf | 66 + .../unraid/projectbrain-container.env.example | 65 + docker/unraid/projectbrain-icon.png | Bin 0 -> 29208 bytes docker/unraid/projectbrain.env.example | 37 + docs/LICENSING.md | 7 + docs/MICROSOFT_ENTRA_LOGIN.md | 41 + docs/REAL_BACKEND_ARCHITECTURE_DECISION.md | 370 + docs/UX_AUTONOMY_PRODUCT_CONTRACT.md | 403 + docs/screenshots/mock-portfolio.png | Bin 0 -> 91067 bytes docs/screenshots/mock-project-overview.png | Bin 0 -> 123854 bytes e2e/attention-auto-mode.spec.ts | 112 + e2e/core-journeys.spec.ts | 267 + e2e/platform-state.spec.ts | 102 + e2e/project-list-view.spec.ts | 14 + e2e/task-queue.spec.ts | 24 + eslint.config.js | 50 + index.html | 32 + package.json | 303 + packages/contracts/index.d.ts | 57 + packages/contracts/index.js | 189 + packages/contracts/package.json | 19 + playwright.config.ts | 31 + pnpm-lock.yaml | 6452 +++++++++++++++++ pnpm-workspace.yaml | 30 + postcss.config.js | 6 + public/apple-touch-icon.png | Bin 0 -> 56930 bytes public/favicon.ico | Bin 0 -> 147037 bytes public/favicon.svg | 31 + public/icon-180.png | Bin 0 -> 56930 bytes public/icon-192.png | Bin 0 -> 63317 bytes public/icon-512.png | Bin 0 -> 263344 bytes public/placeholder.svg | 1 + public/robots.txt | 14 + public/site.webmanifest | 23 + scripts/check-architecture-budget.mjs | 96 + scripts/check-browser-bridge-readiness.mjs | 56 + scripts/check-build-loop-readiness.mjs | 117 + ...check-build-loop-release-consolidation.mjs | 128 + ...check-build-loop-walkthrough-readiness.mjs | 115 + scripts/check-codex-handoff-readiness.mjs | 147 + scripts/check-database-readiness.mjs | 252 + scripts/check-doc-links.mjs | 23 + scripts/check-docker-build-readiness.mjs | 148 + scripts/check-execution-center-readiness.mjs | 63 + .../check-execution-feedback-readiness.mjs | 133 + scripts/check-frontend-runtime-readiness.mjs | 83 + scripts/check-guided-start-readiness.mjs | 90 + scripts/check-handoff-review-readiness.mjs | 109 + scripts/check-history-center-readiness.mjs | 50 + scripts/check-release-hygiene.mjs | 165 + scripts/check-repo-builder-readiness.mjs | 156 + scripts/check-repository-intake-readiness.mjs | 110 + scripts/check-review-closure-readiness.mjs | 66 + scripts/check-runtime-readiness.mjs | 440 ++ .../check-session-pack-export-readiness.mjs | 122 + scripts/check-session-pack-readiness.mjs | 135 + scripts/check-skill-library-readiness.mjs | 126 + scripts/check-source-library-readiness.mjs | 38 + scripts/check-typescript-gate.mjs | 24 + scripts/check-unraid-template-readiness.mjs | 155 + scripts/check-v1-contract-freeze.mjs | 25 + scripts/check-visual-flow-readiness.mjs | 162 + scripts/check-visual-polish.mjs | 157 + scripts/deployment-proof-state.mjs | 16 + scripts/deployment-proof-state.test.mjs | 27 + scripts/generate-openapi.mjs | 18 + scripts/generate-release-metadata.mjs | 64 + scripts/generate-validation-matrix.mjs | 76 + .../legacy-deployment-migration/.gitignore | 1 + scripts/legacy-deployment-migration/README.md | 206 + .../legacy-deployment-migration/execute.py | 221 + scripts/legacy-deployment-migration/plan.py | 103 + .../legacy-deployment-migration/storage.py | 96 + .../test_compose_contracts.py | 84 + .../test_execute.py | 49 + .../legacy-deployment-migration/test_plan.py | 61 + .../test_storage.py | 62 + .../test_transaction.py | 289 + .../transaction.py | 517 ++ scripts/projectbrain-csi-audit.mjs | 144 + scripts/projectbrain-doctor.mjs | 163 + scripts/release-command-bundle.mjs | 72 + scripts/release-deployment-proof.mjs | 151 + scripts/release-evidence-dossier.mjs | 152 + scripts/release-evidence-retention.mjs | 155 + scripts/release-operator-proof-report.mjs | 183 + scripts/release-proof-freshness.mjs | 90 + scripts/release-proof-index.mjs | 74 + scripts/release-rollback-drill.mjs | 227 + scripts/release-signoff-file.mjs | 111 + scripts/release-tag-ready-gate.mjs | 256 + scripts/release-user-notes.mjs | 150 + scripts/smoke-release-operator.mjs | 50 + scripts/stage-release-oci.sh | 95 + scripts/unraid-single-entrypoint.sh | 109 + scripts/verify-release-metadata.mjs | 21 + src/App.tsx | 36 + src/app/routes.tsx | 269 + src/app/scrollReset.test.ts | 30 + src/app/scrollReset.ts | 19 + src/components/layout/AppLayout.tsx | 33 + src/components/layout/CommandMenu.tsx | 74 + src/components/layout/Header.tsx | 373 + src/components/layout/Sidebar.tsx | 155 + .../layout/commandMenuSearch.test.ts | 70 + src/components/layout/commandMenuSearch.ts | 116 + src/components/made-with-dyad.tsx | 9 + src/components/theme-provider.tsx | 8 + src/components/ui/ConfidenceBadge.tsx | 26 + src/components/ui/EmptyState.tsx | 54 + src/components/ui/EvidenceList.tsx | 38 + src/components/ui/PageHeader.tsx | 21 + src/components/ui/StatusBadge.tsx | 24 + src/components/ui/accordion.tsx | 56 + src/components/ui/alert-dialog.tsx | 139 + src/components/ui/alert.tsx | 59 + src/components/ui/aspect-ratio.tsx | 5 + src/components/ui/avatar.tsx | 48 + src/components/ui/badge.tsx | 37 + src/components/ui/breadcrumb.tsx | 115 + src/components/ui/button.tsx | 57 + src/components/ui/calendar.tsx | 72 + src/components/ui/card.tsx | 98 + src/components/ui/carousel.tsx | 260 + src/components/ui/chart.tsx | 363 + src/components/ui/checkbox.tsx | 28 + src/components/ui/collapsible.tsx | 9 + src/components/ui/command.tsx | 156 + src/components/ui/context-menu.tsx | 198 + src/components/ui/dialog.tsx | 120 + src/components/ui/drawer.tsx | 116 + src/components/ui/dropdown-menu.tsx | 198 + src/components/ui/form.tsx | 177 + src/components/ui/hover-card.tsx | 27 + src/components/ui/index.ts | 30 + src/components/ui/input-otp.tsx | 69 + src/components/ui/input.tsx | 22 + src/components/ui/label.tsx | 24 + src/components/ui/menubar.tsx | 234 + src/components/ui/navigation-menu.tsx | 128 + src/components/ui/pagination.tsx | 117 + src/components/ui/popover.tsx | 29 + src/components/ui/progress.tsx | 33 + src/components/ui/radio-group.tsx | 42 + src/components/ui/resizable.tsx | 43 + src/components/ui/scroll-area.tsx | 46 + src/components/ui/select.tsx | 159 + src/components/ui/separator.tsx | 29 + src/components/ui/sheet.tsx | 139 + src/components/ui/sidebar.tsx | 769 ++ src/components/ui/skeleton.tsx | 15 + src/components/ui/slider.tsx | 26 + src/components/ui/sonner.tsx | 29 + src/components/ui/switch.tsx | 27 + src/components/ui/table.tsx | 117 + src/components/ui/tabs.tsx | 53 + src/components/ui/textarea.tsx | 23 + src/components/ui/toast.tsx | 127 + src/components/ui/toaster.tsx | 33 + src/components/ui/toggle-group.tsx | 59 + src/components/ui/toggle.tsx | 43 + src/components/ui/tooltip.tsx | 28 + src/components/ui/use-toast.ts | 3 + src/contexts/ProjectContext.tsx | 2 + .../architecture/pages/ArchitectureMap.tsx | 110 + .../audit/pages/ProjectAuditCenter.tsx | 511 ++ src/features/auth/lib/authNavigation.test.ts | 17 + src/features/auth/lib/authNavigation.ts | 5 + src/features/auth/pages/Login.tsx | 79 + src/features/autonomy/pages/AutoMode.tsx | 123 + .../blueprint/pages/BlueprintEngine.tsx | 917 +++ .../brainstorm/pages/BrainstormLab.tsx | 864 +++ .../browser-bridge/pages/BrowserBridge.tsx | 488 ++ src/features/bugs/pages/Bugs.tsx | 167 + .../build-loop/pages/BuildLoopDashboard.tsx | 96 + src/features/builds/pages/BuildJournal.tsx | 285 + src/features/checks/pages/AIReview.tsx | 777 ++ .../context-packs/pages/ContextPacks.tsx | 578 ++ src/features/decisions/pages/Decisions.tsx | 10 + .../pages/DevelopmentWorkbench.tsx | 277 + .../pages/ExecutionFeedback.tsx | 786 ++ .../execution/pages/ExecutionCenter.tsx | 586 ++ src/features/features/pages/FeatureDetail.tsx | 384 + src/features/features/pages/FeatureMap.tsx | 304 + src/features/handoffs/pages/CodexHandoffs.tsx | 817 +++ .../history/components/LiveActivityStream.tsx | 116 + .../history/pages/ProjectActivity.tsx | 36 + src/features/history/pages/ProjectHistory.tsx | 1553 ++++ src/features/insights/pages/Insights.tsx | 446 ++ .../components/ProjectKnowledgeCenter.tsx | 71 + .../knowledge/pages/KnowledgeVault.tsx | 27 + src/features/memory/pages/MemoryRules.tsx | 116 + .../components/ReviewActionPanel.tsx | 119 + .../lib/ReviewApprovalStateGuide.tsx | 35 + .../operations/lib/operationRuntime.ts | 1530 ++++ .../operations/lib/reviewQueuePresentation.ts | 22 + .../operations/pages/AIActionCenter.tsx | 252 + .../operations/pages/CaptureCenter.tsx | 244 + .../operations/pages/CodexTaskPreview.tsx | 206 + .../operations/pages/CurrentStatePanel.tsx | 165 + .../operations/pages/DocumentationHub.tsx | 262 + .../operations/pages/FindingInbox.tsx | 274 + .../operations/pages/InitiativeDetail.tsx | 146 + src/features/operations/pages/Initiatives.tsx | 165 + .../operations/pages/OperatingLog.tsx | 194 + .../operations/pages/OperatingStubPage.tsx | 71 + .../pages/RepositoryAnalysisJobs.tsx | 154 + src/features/operations/pages/ReviewQueue.tsx | 1071 +++ .../pages/RuntimeContextPromptPilot.tsx | 139 + .../pages/RuntimeDeliveryPersistencePilot.tsx | 236 + .../operations/pages/RuntimeDeliveryPlan.tsx | 147 + .../pages/RuntimeExecutionQueue.tsx | 203 + .../pages/RuntimeFeatureOverlay.tsx | 199 + .../operations/pages/RuntimeFlowProgress.tsx | 176 + .../operations/pages/RuntimeGateChecklist.tsx | 267 + .../operations/pages/RuntimeGateEvidence.tsx | 243 + .../pages/RuntimeHandoffBuilder.tsx | 202 + .../RuntimeKnowledgePersistencePilot.tsx | 146 + .../pages/RuntimeMissionControl.tsx | 211 + .../pages/RuntimeP3ImplementationPlanner.tsx | 226 + .../pages/RuntimeP3ReadinessAdvisor.tsx | 210 + .../pages/RuntimePersistencePilot.tsx | 332 + .../pages/RuntimeProjectUpdates.tsx | 154 + .../operations/pages/RuntimeStateManager.tsx | 169 + .../operations/pages/RuntimeTodoBoard.tsx | 177 + .../pages/RuntimeTraceInspector.tsx | 150 + .../operations/pages/RuntimeTruthLedger.tsx | 124 + .../pages/RuntimeWorkSessionPanel.tsx | 145 + .../PortfolioAutoRuntimeSummary.tsx | 105 + .../components/PortfolioExecutionPulse.tsx | 144 + .../components/PortfolioPulseSummary.tsx | 66 + .../components/PortfolioRuntimeDashboard.tsx | 213 + .../components/PortfolioSavedViews.tsx | 99 + .../components/ProjectBrainDiagnostics.tsx | 55 + .../portfolio/components/RuntimeJobLane.tsx | 74 + .../pages/PortfolioCommandCenter.tsx | 289 + .../portfolio/pages/PortfolioHome.tsx | 133 + .../components/EvidenceGraphSummary.tsx | 32 + .../projects/components/FirstRunLaunchpad.tsx | 220 + .../projects/components/ProjectFlowMap.tsx | 129 + .../ProjectOperatingSystemPanel.tsx | 92 + .../runtime/RuntimeCapabilitySummary.tsx | 31 + .../runtime/RuntimeReadinessPrimitives.tsx | 66 + .../hooks/useProjectOperatingSystem.ts | 76 + .../projects/pages/FirstRunProjectWizard.tsx | 168 + src/features/projects/pages/ProjectDNA.tsx | 92 + .../projects/pages/ProjectOverview.tsx | 139 + .../projects/pages/ProjectResults.tsx | 76 + src/features/projects/pages/Projects.tsx | 114 + .../projects/pages/RuntimeReadiness.tsx | 170 + src/features/projects/pages/Workspace.tsx | 138 + .../prompts/pages/PromptGenerator.tsx | 594 ++ .../regression/pages/RegressionMemory.tsx | 174 + .../relationships/pages/RelationshipGraph.tsx | 249 + .../components/ReleaseAnalysisSections.tsx | 272 + .../components/ReleaseOperatorStatusCard.tsx | 158 + src/features/release/pages/ReleaseCenter.tsx | 864 +++ .../components/GiteaProjectConnection.tsx | 124 + .../components/RepoBuilderWorkflowSection.tsx | 42 + .../repo-builder/pages/RepoBuilder.tsx | 1837 +++++ .../session-packs/pages/SessionPacks.tsx | 498 ++ .../components/EntraIdentitySettings.tsx | 87 + .../components/ManagementOverview.tsx | 41 + .../components/McpEcosystemStatus.tsx | 56 + src/features/settings/pages/Settings.tsx | 66 + src/features/skills/pages/SkillLibrary.tsx | 315 + .../snapshots/pages/SnapshotDetail.tsx | 182 + src/features/snapshots/pages/Snapshots.tsx | 151 + .../source-library/pages/ExtraSources.tsx | 87 + .../source-library/pages/SourceLibrary.tsx | 300 + src/features/start/pages/GuidedStart.tsx | 151 + src/features/tasks/pages/Tasks.tsx | 226 + src/hooks/use-mobile.test.ts | 15 + src/hooks/use-mobile.tsx | 43 + src/hooks/use-toast.ts | 188 + src/lib/utils.ts | 6 + src/main.tsx | 10 + src/shared/api/autonomyApi.test.ts | 22 + src/shared/api/autonomyApi.ts | 73 + src/shared/api/b1ReadBackend.ts | 4032 ++++++++++ src/shared/api/contracts.ts | 315 + src/shared/api/contractsRuntime.test.ts | 29 + src/shared/api/errors.ts | 12 + src/shared/api/giteaApi.ts | 53 + src/shared/api/httpClient.test.ts | 25 + src/shared/api/httpClient.ts | 139 + src/shared/api/mockClient.ts | 1144 +++ src/shared/api/operatingSystemApi.ts | 23 + src/shared/api/operatingSystemTypes.ts | 88 + src/shared/api/portfolioTypes.ts | 250 + src/shared/api/projectBrainClient.ts | 266 + src/shared/api/projectBrainClientMode.ts | 4 + src/shared/api/realClient.ts | 1458 ++++ src/shared/api/systemTypes.ts | 47 + src/shared/api/types.ts | 492 ++ src/shared/components/ActionGroup.tsx | 37 + .../components/BuildLoopActionRail.test.ts | 30 + src/shared/components/BuildLoopActionRail.tsx | 222 + src/shared/components/ConfirmActionButton.tsx | 51 + .../components/GuidedWorkSessionCard.tsx | 163 + src/shared/components/ProjectFlowRail.tsx | 212 + .../components/ProjectOperatingStepCard.tsx | 104 + .../components/ProjectRouteFallback.tsx | 47 + src/shared/components/ProjectTimeline.tsx | 64 + .../components/RuntimeErrorBoundary.tsx | 97 + src/shared/components/WhyNothingHappening.tsx | 35 + src/shared/contexts/AuthContext.tsx | 77 + src/shared/contexts/ProjectContext.tsx | 620 ++ src/shared/contexts/liveProjectUpdate.test.ts | 21 + src/shared/contexts/liveProjectUpdate.ts | 38 + .../contexts/projectDataBoundary.test.ts | 19 + src/shared/contexts/projectDataBoundary.ts | 8 + src/shared/contexts/useProjectDataSource.ts | 178 + src/shared/hooks/useAsyncResource.test.ts | 69 + src/shared/hooks/useAsyncResource.ts | 111 + src/shared/hooks/useProjectData.ts | 579 ++ src/shared/lib/activeProjectStorage.ts | 26 + .../lib/activityRuntimePresentation.test.ts | 82 + src/shared/lib/activityRuntimePresentation.ts | 95 + src/shared/lib/appLogger.ts | 12 + .../lib/autoModeExecutorContract.test.ts | 31 + src/shared/lib/autoModePresentation.test.ts | 109 + src/shared/lib/autoModePresentation.ts | 82 + .../autoRuntimeSummaryPresentation.test.ts | 48 + .../lib/autoRuntimeSummaryPresentation.ts | 43 + src/shared/lib/autonomyProfiles.test.ts | 12 + src/shared/lib/autonomyProfiles.ts | 33 + src/shared/lib/blueprintEngine.ts | 978 +++ src/shared/lib/brainstormEngine.ts | 631 ++ src/shared/lib/browserBridge.ts | 181 + src/shared/lib/browserStorage.ts | 30 + src/shared/lib/buildLoop.ts | 418 ++ src/shared/lib/buildLoopActionRailStatus.ts | 39 + src/shared/lib/buildLoopWalkthrough.ts | 410 ++ src/shared/lib/buildMetadata.ts | 34 + src/shared/lib/canonicalLifecycle.test.ts | 83 + src/shared/lib/canonicalLifecycle.ts | 68 + src/shared/lib/codexExecutionFeedback.ts | 353 + src/shared/lib/codexHandoff.ts | 432 ++ src/shared/lib/codexSessionPack.ts | 362 + src/shared/lib/codexSessionPackExport.ts | 317 + src/shared/lib/codexSkills.ts | 531 ++ src/shared/lib/contextPackSelector.ts | 210 + src/shared/lib/coreDomain.test.ts | 90 + src/shared/lib/downloads.ts | 18 + src/shared/lib/evidenceClosure.ts | 19 + src/shared/lib/evidenceRouting.ts | 113 + src/shared/lib/featureDeliveryModel.ts | 37 + src/shared/lib/findingEvidenceAge.test.ts | 40 + src/shared/lib/findingEvidenceAge.ts | 35 + .../findingRemediationPresentation.test.ts | 13 + .../lib/findingRemediationPresentation.ts | 22 + src/shared/lib/guidedStart.ts | 159 + src/shared/lib/handoffReview.ts | 272 + src/shared/lib/mock-data.ts | 1471 ++++ src/shared/lib/model.ts | 1699 +++++ src/shared/lib/navigation.test.ts | 81 + src/shared/lib/navigation.tsx | 221 + src/shared/lib/operationalCopy.test.ts | 16 + src/shared/lib/operationalCopy.ts | 1 + src/shared/lib/portfolioAttention.test.ts | 37 + src/shared/lib/portfolioAttention.ts | 10 + src/shared/lib/portfolioViews.test.ts | 53 + src/shared/lib/portfolioViews.ts | 92 + src/shared/lib/postRcOperations.test.ts | 22 + src/shared/lib/postRcOperations.ts | 105 + src/shared/lib/productExperienceAudit.ts | 64 + src/shared/lib/productLanguage.ts | 46 + src/shared/lib/projectArchiveCommands.test.ts | 59 + src/shared/lib/projectArchiveCommands.ts | 121 + src/shared/lib/projectAttention.test.ts | 87 + src/shared/lib/projectAttention.ts | 61 + src/shared/lib/projectAudit.ts | 3649 ++++++++++ src/shared/lib/projectAutonomyStatus.test.ts | 68 + src/shared/lib/projectAutonomyStatus.ts | 145 + .../lib/projectBrainstormCommands.test.ts | 57 + src/shared/lib/projectBrainstormCommands.ts | 86 + .../lib/projectBrowserBridgeCommands.test.ts | 60 + .../lib/projectBrowserBridgeCommands.ts | 86 + .../lib/projectCodexFeedbackCommands.test.ts | 62 + .../lib/projectCodexFeedbackCommands.ts | 148 + .../lib/projectCodexHandoffCommands.test.ts | 60 + src/shared/lib/projectCodexHandoffCommands.ts | 88 + .../lib/projectFirstRunCommands.test.ts | 79 + .../lib/projectFirstRunGenerationCommands.ts | 47 + .../lib/projectFirstRunLifecycleCommands.ts | 31 + .../lib/projectGuidedSessionCommands.test.ts | 49 + .../lib/projectGuidedSessionCommands.ts | 79 + src/shared/lib/projectGuidedWorkSession.ts | 90 + src/shared/lib/projectHistoryCommands.test.ts | 53 + src/shared/lib/projectHistoryCommands.ts | 47 + src/shared/lib/projectListView.test.ts | 28 + src/shared/lib/projectListView.ts | 26 + src/shared/lib/projectOperatingStep.test.ts | 214 + src/shared/lib/projectOperatingStep.ts | 128 + src/shared/lib/projectPresentation.test.ts | 33 + src/shared/lib/projectPresentation.ts | 74 + .../lib/projectRepoDeliveryCommands.test.ts | 45 + src/shared/lib/projectRepoDeliveryCommands.ts | 171 + .../lib/projectRepoDesignCommands.test.ts | 62 + src/shared/lib/projectRepoDesignCommands.ts | 372 + src/shared/lib/projectRepoSnapshotCommands.ts | 187 + .../lib/projectRepoStateCommands.test.ts | 64 + src/shared/lib/projectRepoWorkPlanCommands.ts | 137 + .../lib/projectRepositoryEvidence.test.ts | 83 + .../projectRepositoryIntakeCommands.test.ts | 62 + .../lib/projectRepositoryIntakeCommands.ts | 82 + src/shared/lib/projectResultSemantics.test.ts | 53 + src/shared/lib/projectResultSemantics.ts | 15 + src/shared/lib/projectResume.test.ts | 78 + src/shared/lib/projectResume.ts | 102 + src/shared/lib/projectSearch.test.ts | 26 + src/shared/lib/projectSearch.ts | 580 ++ src/shared/lib/projectSkillCommands.test.ts | 35 + src/shared/lib/projectSkillCommands.ts | 40 + src/shared/lib/projectSourceCommands.test.ts | 65 + src/shared/lib/projectSourceCommands.ts | 177 + src/shared/lib/projectStartFlow.ts | 364 + src/shared/lib/projectWizard.ts | 710 ++ src/shared/lib/projectWizardProgress.test.ts | 53 + src/shared/lib/projectWorkState.test.ts | 166 + src/shared/lib/projectWorkState.ts | 562 ++ src/shared/lib/projectbrainActions.ts | 396 + src/shared/lib/projectbrainSelectors.ts | 173 + src/shared/lib/rcAudit.ts | 234 + .../lib/releaseCandidateFinalization.ts | 93 + src/shared/lib/releaseCenterGuidance.ts | 122 + src/shared/lib/releaseCenterImproveAction.ts | 142 + src/shared/lib/releaseCenterQuality.ts | 136 + src/shared/lib/releaseEvidence.ts | 174 + src/shared/lib/releaseEvidenceHygiene.ts | 56 + src/shared/lib/releaseEvidenceLane.ts | 189 + src/shared/lib/releaseEvidenceReview.ts | 100 + src/shared/lib/releaseNoteArchive.ts | 78 + src/shared/lib/releaseOperatorChecklist.ts | 130 + src/shared/lib/releaseOperatorGuide.test.ts | 28 + src/shared/lib/releaseOperatorGuide.ts | 83 + src/shared/lib/releaseRcGoNoGo.ts | 85 + src/shared/lib/releaseReadiness.ts | 163 + src/shared/lib/releaseReadinessExport.ts | 87 + .../lib/releaseReadinessProofFreshness.ts | 117 + src/shared/lib/releaseSignoff.ts | 210 + src/shared/lib/releaseTagChecklist.ts | 86 + .../lib/releaseValidationContract.test.ts | 15 + src/shared/lib/repositoryIntake.ts | 553 ++ src/shared/lib/reviewDecisionReceipt.ts | 296 + .../lib/routeMotionAccessibility.test.ts | 13 + src/shared/lib/routeRegistry.ts | 66 + .../lib/runnerlessDeliveryPolicy.test.ts | 30 + src/shared/lib/safeData.ts | 135 + src/shared/lib/taskQueue.test.ts | 27 + src/shared/lib/taskQueue.ts | 22 + src/shared/lib/userExperience.test.ts | 83 + src/shared/lib/userExperience.ts | 200 + src/shared/lib/userFacingError.test.ts | 17 + src/shared/lib/userFacingError.ts | 23 + src/shared/lib/utils.ts | 6 + src/shared/lib/uxConsolidationPolicy.test.ts | 73 + src/shared/lib/workGraph.test.ts | 65 + src/shared/lib/workGraph.ts | 102 + src/shared/pages/NotFound.tsx | 31 + src/shared/styles/globals.css | 868 +++ src/utils/toast.ts | 17 + src/vite-env.d.ts | 1 + tailwind.config.ts | 122 + tsconfig.app.json | 30 + tsconfig.json | 19 + tsconfig.node.json | 24 + vite.config.ts | 53 + vitest.config.ts | 15 + 700 files changed, 132801 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 Dockerfile.frontend create mode 100644 Dockerfile.unraid create mode 100644 PUBLIC_SOURCE_EXPORT.md create mode 100644 PUBLIC_SOURCE_MANIFEST.json create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20260606000000_init_project_workspace/migration.sql create mode 100644 backend/prisma/migrations/20260606010000_p5_capture_review_parity/migration.sql create mode 100644 backend/prisma/migrations/20260606020000_p6_truth_updates_operating_log/migration.sql create mode 100644 backend/prisma/migrations/20260606030000_p7_current_state_handoff_codex/migration.sql create mode 100644 backend/prisma/migrations/20260606040000_p8_docs_knowledge_memory/migration.sql create mode 100644 backend/prisma/migrations/20260609070000_v52_blueprint_persistence/migration.sql create mode 100644 backend/prisma/migrations/20260609120000_v68_brainstorm_cpu_persistence/migration.sql create mode 100644 backend/prisma/migrations/20260609130000_v72_initiatives_persistence/migration.sql create mode 100644 backend/prisma/migrations/20260624150000_rc39_release_evidence_records/migration.sql create mode 100644 backend/prisma/migrations/20260729000000_v1_authentication/migration.sql create mode 100644 backend/prisma/migrations/20260821000000_gitea_mcp_ecosystem/migration.sql create mode 100644 backend/prisma/migrations/20260821010000_entra_oidc_login/migration.sql create mode 100644 backend/prisma/migrations/20260825000000_unified_delivery_core/migration.sql create mode 100644 backend/prisma/migrations/20260826020000_delivery_review_feedback/migration.sql create mode 100644 backend/prisma/migrations/20260826030000_async_repository_analysis/migration.sql create mode 100644 backend/prisma/migrations/20260826040000_closed_loop_findings/migration.sql create mode 100644 backend/prisma/migrations/20260826050000_portfolio_control_plane/migration.sql create mode 100644 backend/prisma/migrations/20260829211000_partial_analysis_status/migration.sql create mode 100644 backend/prisma/migrations/20260830000000_git_source_library/migration.sql create mode 100644 backend/prisma/migrations/20260902000000_attention_provenance_cleanup/migration.sql create mode 100644 backend/prisma/migrations/20260902130000_autonomy_executor_heartbeat/migration.sql create mode 100644 backend/prisma/migrations/20260912190000_autonomous_operating_system_foundation/migration.sql create mode 100644 backend/prisma/migrations/20260913090000_portfolio_project_pins/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/prisma/seed.ts create mode 100644 backend/scripts/audit-backend-contract.mjs create mode 100644 backend/scripts/docker-entrypoint.sh create mode 100644 backend/scripts/prisma-generate.mjs create mode 100644 backend/scripts/prisma-validate.mjs create mode 100644 backend/src/app.ts create mode 100644 backend/src/config/env.ts create mode 100644 backend/src/db/prisma.ts create mode 100644 backend/src/fixtures/aiActionFixtures.ts create mode 100644 backend/src/fixtures/buildFixtures.ts create mode 100644 backend/src/fixtures/captureFixtures.ts create mode 100644 backend/src/fixtures/checkFixtures.ts create mode 100644 backend/src/fixtures/contextPackFixtures.ts create mode 100644 backend/src/fixtures/featureFixtures.ts create mode 100644 backend/src/fixtures/insightFixtures.ts create mode 100644 backend/src/fixtures/knowledgeFixtures.ts create mode 100644 backend/src/fixtures/memoryRuleFixtures.ts create mode 100644 backend/src/fixtures/projectFixtures.ts create mode 100644 backend/src/fixtures/regressionFixtures.ts create mode 100644 backend/src/fixtures/relationshipFixtures.ts create mode 100644 backend/src/fixtures/reviewQueueFixtures.ts create mode 100644 backend/src/fixtures/snapshotFixtures.ts create mode 100644 backend/src/integrations/gitea/client.ts create mode 100644 backend/src/integrations/gitea/policy.ts create mode 100644 backend/src/integrations/gitea/portfolio.ts create mode 100644 backend/src/integrations/gitea/service.ts create mode 100644 backend/src/integrations/gitea/types.ts create mode 100644 backend/src/mcp/analysisWidget.ts create mode 100644 backend/src/mcp/autonomyServer.ts create mode 100644 backend/src/mcp/featureChangeset.ts create mode 100644 backend/src/mcp/portfolioTools.ts create mode 100644 backend/src/mcp/resultHelpers.ts create mode 100644 backend/src/mcp/scopeGuards.ts create mode 100644 backend/src/mcp/server.ts create mode 100644 backend/src/mcp/skills.ts create mode 100644 backend/src/mcp/widget.ts create mode 100644 backend/src/plugins/authentication.ts create mode 100644 backend/src/plugins/errorHandler.ts create mode 100644 backend/src/plugins/observability.ts create mode 100644 backend/src/plugins/requestId.ts create mode 100644 backend/src/repositories/analysisJobRepository.ts create mode 100644 backend/src/repositories/autonomyExecutionEvidence.ts create mode 100644 backend/src/repositories/autonomyRepository.ts create mode 100644 backend/src/repositories/blueprintsRepository.ts create mode 100644 backend/src/repositories/brainstormRepository.ts create mode 100644 backend/src/repositories/captureRepository.ts create mode 100644 backend/src/repositories/contextPacksRepository.ts create mode 100644 backend/src/repositories/evidenceRepository.ts create mode 100644 backend/src/repositories/featureDeliveryRepository.ts create mode 100644 backend/src/repositories/featureGroupsRepository.ts create mode 100644 backend/src/repositories/featuresRepository.ts create mode 100644 backend/src/repositories/findingRepository.ts create mode 100644 backend/src/repositories/initiativesRepository.ts create mode 100644 backend/src/repositories/p7RuntimeRepository.ts create mode 100644 backend/src/repositories/p8KnowledgeMemoryRepository.ts create mode 100644 backend/src/repositories/projectSetupState.ts create mode 100644 backend/src/repositories/projectsRepository.ts create mode 100644 backend/src/repositories/releaseEvidenceRepository.ts create mode 100644 backend/src/repositories/reviewAttentionPolicy.ts create mode 100644 backend/src/repositories/reviewQueueRepository.ts create mode 100644 backend/src/repositories/runtimeRepository.ts create mode 100644 backend/src/routes/activity.ts create mode 100644 backend/src/routes/aiActions.ts create mode 100644 backend/src/routes/analysisJobs.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/autonomy.ts create mode 100644 backend/src/routes/blueprints.ts create mode 100644 backend/src/routes/brainstorms.ts create mode 100644 backend/src/routes/builds.ts create mode 100644 backend/src/routes/capture.ts create mode 100644 backend/src/routes/checks.ts create mode 100644 backend/src/routes/contextPacks.ts create mode 100644 backend/src/routes/documentation.ts create mode 100644 backend/src/routes/entraAuth.ts create mode 100644 backend/src/routes/evidence.ts create mode 100644 backend/src/routes/features.ts create mode 100644 backend/src/routes/findings.ts create mode 100644 backend/src/routes/giteaIntegration.ts create mode 100644 backend/src/routes/health.ts create mode 100644 backend/src/routes/initiatives.ts create mode 100644 backend/src/routes/insights.ts create mode 100644 backend/src/routes/knowledge.ts create mode 100644 backend/src/routes/mcp.ts create mode 100644 backend/src/routes/mcpOAuth.ts create mode 100644 backend/src/routes/memoryRules.ts create mode 100644 backend/src/routes/metrics.ts create mode 100644 backend/src/routes/operatingSystem.ts create mode 100644 backend/src/routes/p7Runtime.ts create mode 100644 backend/src/routes/portfolio.ts create mode 100644 backend/src/routes/projects.ts create mode 100644 backend/src/routes/prompts.ts create mode 100644 backend/src/routes/regressions.ts create mode 100644 backend/src/routes/relationships.ts create mode 100644 backend/src/routes/releaseEvidence.ts create mode 100644 backend/src/routes/reviewQueue.ts create mode 100644 backend/src/routes/runtimePersistence.ts create mode 100644 backend/src/routes/snapshots.ts create mode 100644 backend/src/routes/systemReadiness.ts create mode 100644 backend/src/security/authStore.ts create mode 100644 backend/src/security/entraOidc.ts create mode 100644 backend/src/security/mcpOAuth.ts create mode 100644 backend/src/security/mcpServiceAuth.ts create mode 100644 backend/src/security/password.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/services/activityRuntime.ts create mode 100644 backend/src/services/analysisStrategy.ts create mode 100644 backend/src/services/attention.ts create mode 100644 backend/src/services/autonomyAssignments.ts create mode 100644 backend/src/services/autonomyDelivery.ts create mode 100644 backend/src/services/autonomyRecovery.ts create mode 100644 backend/src/services/autonomyReplanner.ts create mode 100644 backend/src/services/autonomyRoadmap.ts create mode 100644 backend/src/services/autonomyRuntime.ts create mode 100644 backend/src/services/autonomyRuntimeControl.ts create mode 100644 backend/src/services/autonomyScheduler.ts create mode 100644 backend/src/services/autonomyWork.ts create mode 100644 backend/src/services/blueprintEngine.ts create mode 100644 backend/src/services/canonicalLifecycle.ts create mode 100644 backend/src/services/commandIntent.ts create mode 100644 backend/src/services/contextPackSelector.ts create mode 100644 backend/src/services/contextResolver.ts create mode 100644 backend/src/services/deliveryReconciliation.ts create mode 100644 backend/src/services/diagnostics.ts create mode 100644 backend/src/services/evidenceGraph.ts create mode 100644 backend/src/services/featureDeliveryFlow.ts create mode 100644 backend/src/services/gitSourceSync.ts create mode 100644 backend/src/services/giteaAutonomyExecution.ts create mode 100644 backend/src/services/portfolioControlPlane.ts create mode 100644 backend/src/services/projectCompletion.ts create mode 100644 backend/src/services/projectHealthSemantics.ts create mode 100644 backend/src/services/projectKnowledge.ts create mode 100644 backend/src/services/projectRepositoryProvisioning.ts create mode 100644 backend/src/services/pulse.ts create mode 100644 backend/src/services/recoveryPolicy.ts create mode 100644 backend/src/services/repositoryAnalysis.ts create mode 100644 backend/src/services/repositoryAutonomy.ts create mode 100644 backend/src/services/workGraph.ts create mode 100644 backend/src/types/api.ts create mode 100644 backend/src/utils/envelope.ts create mode 100644 backend/src/utils/idempotency.ts create mode 100644 backend/src/workers/analysisWorker.ts create mode 100644 backend/test/activity-runtime.test.ts create mode 100644 backend/test/analysis-job-capacity.test.ts create mode 100644 backend/test/analysisStrategy.test.ts create mode 100644 backend/test/authentication.test.ts create mode 100644 backend/test/autonomy.test.ts create mode 100644 backend/test/autonomyRecovery.test.ts create mode 100644 backend/test/autonomyReplanner.test.ts create mode 100644 backend/test/autonomyScheduler.test.ts create mode 100644 backend/test/autonomyWork.test.ts create mode 100644 backend/test/canonicalLifecycle.test.ts create mode 100644 backend/test/database-authentication.test.ts create mode 100644 backend/test/database-autonomy.test.ts create mode 100644 backend/test/database-repository-sync.test.ts create mode 100644 backend/test/delivery-pullrequest-reconciliation.test.ts create mode 100644 backend/test/deliveryLifecycle.test.ts create mode 100644 backend/test/entra-oidc.test.ts create mode 100644 backend/test/feature-delivery-policy.test.ts create mode 100644 backend/test/featureDeliveryFlow.test.ts create mode 100644 backend/test/finding-reconciliation.test.ts create mode 100644 backend/test/gitea-autonomy-execution.test.ts create mode 100644 backend/test/gitea-integration.test.ts create mode 100644 backend/test/gitea-portfolio-mcp.test.ts create mode 100644 backend/test/mcp-efficiency.test.ts create mode 100644 backend/test/mcp-skills.test.ts create mode 100644 backend/test/native-ci-discovery.test.ts create mode 100644 backend/test/operatingSystemServices.test.ts create mode 100644 backend/test/portfolioControlPlane.test.ts create mode 100644 backend/test/project-create-route.test.ts create mode 100644 backend/test/project-setup-state.test.ts create mode 100644 backend/test/projectHealthSemantics.test.ts create mode 100644 backend/test/projectKnowledge.test.ts create mode 100644 backend/test/recoveryPolicy.test.ts create mode 100644 backend/test/remediation-regressions.test.ts create mode 100644 backend/test/repository-analysis.test.ts create mode 100644 backend/test/repository-autonomy.test.ts create mode 100644 backend/test/repository-ownership.test.ts create mode 100644 backend/test/review-attention-policy.test.ts create mode 100644 backend/test/review-capture-state-machine.test.ts create mode 100644 backend/test/system-readiness.test.ts create mode 100644 backend/tsconfig.json create mode 100644 backend/vitest.config.ts create mode 100644 components.json create mode 100644 config/architecture-budgets.json create mode 100644 config/v1-contract-freeze.json create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.release.yml create mode 100644 docker-compose.yml create mode 100644 docker/nginx.conf create mode 100644 docker/nginx.single.conf create mode 100644 docker/unraid/projectbrain-container.env.example create mode 100644 docker/unraid/projectbrain-icon.png create mode 100644 docker/unraid/projectbrain.env.example create mode 100644 docs/LICENSING.md create mode 100644 docs/MICROSOFT_ENTRA_LOGIN.md create mode 100644 docs/REAL_BACKEND_ARCHITECTURE_DECISION.md create mode 100644 docs/UX_AUTONOMY_PRODUCT_CONTRACT.md create mode 100644 docs/screenshots/mock-portfolio.png create mode 100644 docs/screenshots/mock-project-overview.png create mode 100644 e2e/attention-auto-mode.spec.ts create mode 100644 e2e/core-journeys.spec.ts create mode 100644 e2e/platform-state.spec.ts create mode 100644 e2e/project-list-view.spec.ts create mode 100644 e2e/task-queue.spec.ts create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 packages/contracts/index.d.ts create mode 100644 packages/contracts/index.js create mode 100644 packages/contracts/package.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.js create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg create mode 100644 public/icon-180.png create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/placeholder.svg create mode 100644 public/robots.txt create mode 100644 public/site.webmanifest create mode 100644 scripts/check-architecture-budget.mjs create mode 100644 scripts/check-browser-bridge-readiness.mjs create mode 100644 scripts/check-build-loop-readiness.mjs create mode 100644 scripts/check-build-loop-release-consolidation.mjs create mode 100644 scripts/check-build-loop-walkthrough-readiness.mjs create mode 100644 scripts/check-codex-handoff-readiness.mjs create mode 100644 scripts/check-database-readiness.mjs create mode 100644 scripts/check-doc-links.mjs create mode 100644 scripts/check-docker-build-readiness.mjs create mode 100644 scripts/check-execution-center-readiness.mjs create mode 100644 scripts/check-execution-feedback-readiness.mjs create mode 100644 scripts/check-frontend-runtime-readiness.mjs create mode 100644 scripts/check-guided-start-readiness.mjs create mode 100644 scripts/check-handoff-review-readiness.mjs create mode 100644 scripts/check-history-center-readiness.mjs create mode 100644 scripts/check-release-hygiene.mjs create mode 100644 scripts/check-repo-builder-readiness.mjs create mode 100644 scripts/check-repository-intake-readiness.mjs create mode 100644 scripts/check-review-closure-readiness.mjs create mode 100644 scripts/check-runtime-readiness.mjs create mode 100644 scripts/check-session-pack-export-readiness.mjs create mode 100644 scripts/check-session-pack-readiness.mjs create mode 100644 scripts/check-skill-library-readiness.mjs create mode 100644 scripts/check-source-library-readiness.mjs create mode 100644 scripts/check-typescript-gate.mjs create mode 100644 scripts/check-unraid-template-readiness.mjs create mode 100644 scripts/check-v1-contract-freeze.mjs create mode 100644 scripts/check-visual-flow-readiness.mjs create mode 100644 scripts/check-visual-polish.mjs create mode 100644 scripts/deployment-proof-state.mjs create mode 100644 scripts/deployment-proof-state.test.mjs create mode 100644 scripts/generate-openapi.mjs create mode 100644 scripts/generate-release-metadata.mjs create mode 100644 scripts/generate-validation-matrix.mjs create mode 100644 scripts/legacy-deployment-migration/.gitignore create mode 100644 scripts/legacy-deployment-migration/README.md create mode 100644 scripts/legacy-deployment-migration/execute.py create mode 100644 scripts/legacy-deployment-migration/plan.py create mode 100644 scripts/legacy-deployment-migration/storage.py create mode 100644 scripts/legacy-deployment-migration/test_compose_contracts.py create mode 100644 scripts/legacy-deployment-migration/test_execute.py create mode 100644 scripts/legacy-deployment-migration/test_plan.py create mode 100644 scripts/legacy-deployment-migration/test_storage.py create mode 100644 scripts/legacy-deployment-migration/test_transaction.py create mode 100644 scripts/legacy-deployment-migration/transaction.py create mode 100644 scripts/projectbrain-csi-audit.mjs create mode 100644 scripts/projectbrain-doctor.mjs create mode 100644 scripts/release-command-bundle.mjs create mode 100644 scripts/release-deployment-proof.mjs create mode 100644 scripts/release-evidence-dossier.mjs create mode 100644 scripts/release-evidence-retention.mjs create mode 100644 scripts/release-operator-proof-report.mjs create mode 100644 scripts/release-proof-freshness.mjs create mode 100644 scripts/release-proof-index.mjs create mode 100644 scripts/release-rollback-drill.mjs create mode 100644 scripts/release-signoff-file.mjs create mode 100644 scripts/release-tag-ready-gate.mjs create mode 100644 scripts/release-user-notes.mjs create mode 100644 scripts/smoke-release-operator.mjs create mode 100644 scripts/stage-release-oci.sh create mode 100644 scripts/unraid-single-entrypoint.sh create mode 100644 scripts/verify-release-metadata.mjs create mode 100644 src/App.tsx create mode 100644 src/app/routes.tsx create mode 100644 src/app/scrollReset.test.ts create mode 100644 src/app/scrollReset.ts create mode 100644 src/components/layout/AppLayout.tsx create mode 100644 src/components/layout/CommandMenu.tsx create mode 100644 src/components/layout/Header.tsx create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/components/layout/commandMenuSearch.test.ts create mode 100644 src/components/layout/commandMenuSearch.ts create mode 100644 src/components/made-with-dyad.tsx create mode 100644 src/components/theme-provider.tsx create mode 100644 src/components/ui/ConfidenceBadge.tsx create mode 100644 src/components/ui/EmptyState.tsx create mode 100644 src/components/ui/EvidenceList.tsx create mode 100644 src/components/ui/PageHeader.tsx create mode 100644 src/components/ui/StatusBadge.tsx create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/aspect-ratio.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/carousel.tsx create mode 100644 src/components/ui/chart.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/context-menu.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/hover-card.tsx create mode 100644 src/components/ui/index.ts create mode 100644 src/components/ui/input-otp.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/menubar.tsx create mode 100644 src/components/ui/navigation-menu.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/radio-group.tsx create mode 100644 src/components/ui/resizable.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/toggle-group.tsx create mode 100644 src/components/ui/toggle.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/components/ui/use-toast.ts create mode 100644 src/contexts/ProjectContext.tsx create mode 100644 src/features/architecture/pages/ArchitectureMap.tsx create mode 100644 src/features/audit/pages/ProjectAuditCenter.tsx create mode 100644 src/features/auth/lib/authNavigation.test.ts create mode 100644 src/features/auth/lib/authNavigation.ts create mode 100644 src/features/auth/pages/Login.tsx create mode 100644 src/features/autonomy/pages/AutoMode.tsx create mode 100644 src/features/blueprint/pages/BlueprintEngine.tsx create mode 100644 src/features/brainstorm/pages/BrainstormLab.tsx create mode 100644 src/features/browser-bridge/pages/BrowserBridge.tsx create mode 100644 src/features/bugs/pages/Bugs.tsx create mode 100644 src/features/build-loop/pages/BuildLoopDashboard.tsx create mode 100644 src/features/builds/pages/BuildJournal.tsx create mode 100644 src/features/checks/pages/AIReview.tsx create mode 100644 src/features/context-packs/pages/ContextPacks.tsx create mode 100644 src/features/decisions/pages/Decisions.tsx create mode 100644 src/features/development/pages/DevelopmentWorkbench.tsx create mode 100644 src/features/execution-feedback/pages/ExecutionFeedback.tsx create mode 100644 src/features/execution/pages/ExecutionCenter.tsx create mode 100644 src/features/features/pages/FeatureDetail.tsx create mode 100644 src/features/features/pages/FeatureMap.tsx create mode 100644 src/features/handoffs/pages/CodexHandoffs.tsx create mode 100644 src/features/history/components/LiveActivityStream.tsx create mode 100644 src/features/history/pages/ProjectActivity.tsx create mode 100644 src/features/history/pages/ProjectHistory.tsx create mode 100644 src/features/insights/pages/Insights.tsx create mode 100644 src/features/knowledge/components/ProjectKnowledgeCenter.tsx create mode 100644 src/features/knowledge/pages/KnowledgeVault.tsx create mode 100644 src/features/memory/pages/MemoryRules.tsx create mode 100644 src/features/operations/components/ReviewActionPanel.tsx create mode 100644 src/features/operations/lib/ReviewApprovalStateGuide.tsx create mode 100644 src/features/operations/lib/operationRuntime.ts create mode 100644 src/features/operations/lib/reviewQueuePresentation.ts create mode 100644 src/features/operations/pages/AIActionCenter.tsx create mode 100644 src/features/operations/pages/CaptureCenter.tsx create mode 100644 src/features/operations/pages/CodexTaskPreview.tsx create mode 100644 src/features/operations/pages/CurrentStatePanel.tsx create mode 100644 src/features/operations/pages/DocumentationHub.tsx create mode 100644 src/features/operations/pages/FindingInbox.tsx create mode 100644 src/features/operations/pages/InitiativeDetail.tsx create mode 100644 src/features/operations/pages/Initiatives.tsx create mode 100644 src/features/operations/pages/OperatingLog.tsx create mode 100644 src/features/operations/pages/OperatingStubPage.tsx create mode 100644 src/features/operations/pages/RepositoryAnalysisJobs.tsx create mode 100644 src/features/operations/pages/ReviewQueue.tsx create mode 100644 src/features/operations/pages/RuntimeContextPromptPilot.tsx create mode 100644 src/features/operations/pages/RuntimeDeliveryPersistencePilot.tsx create mode 100644 src/features/operations/pages/RuntimeDeliveryPlan.tsx create mode 100644 src/features/operations/pages/RuntimeExecutionQueue.tsx create mode 100644 src/features/operations/pages/RuntimeFeatureOverlay.tsx create mode 100644 src/features/operations/pages/RuntimeFlowProgress.tsx create mode 100644 src/features/operations/pages/RuntimeGateChecklist.tsx create mode 100644 src/features/operations/pages/RuntimeGateEvidence.tsx create mode 100644 src/features/operations/pages/RuntimeHandoffBuilder.tsx create mode 100644 src/features/operations/pages/RuntimeKnowledgePersistencePilot.tsx create mode 100644 src/features/operations/pages/RuntimeMissionControl.tsx create mode 100644 src/features/operations/pages/RuntimeP3ImplementationPlanner.tsx create mode 100644 src/features/operations/pages/RuntimeP3ReadinessAdvisor.tsx create mode 100644 src/features/operations/pages/RuntimePersistencePilot.tsx create mode 100644 src/features/operations/pages/RuntimeProjectUpdates.tsx create mode 100644 src/features/operations/pages/RuntimeStateManager.tsx create mode 100644 src/features/operations/pages/RuntimeTodoBoard.tsx create mode 100644 src/features/operations/pages/RuntimeTraceInspector.tsx create mode 100644 src/features/operations/pages/RuntimeTruthLedger.tsx create mode 100644 src/features/operations/pages/RuntimeWorkSessionPanel.tsx create mode 100644 src/features/portfolio/components/PortfolioAutoRuntimeSummary.tsx create mode 100644 src/features/portfolio/components/PortfolioExecutionPulse.tsx create mode 100644 src/features/portfolio/components/PortfolioPulseSummary.tsx create mode 100644 src/features/portfolio/components/PortfolioRuntimeDashboard.tsx create mode 100644 src/features/portfolio/components/PortfolioSavedViews.tsx create mode 100644 src/features/portfolio/components/ProjectBrainDiagnostics.tsx create mode 100644 src/features/portfolio/components/RuntimeJobLane.tsx create mode 100644 src/features/portfolio/pages/PortfolioCommandCenter.tsx create mode 100644 src/features/portfolio/pages/PortfolioHome.tsx create mode 100644 src/features/projects/components/EvidenceGraphSummary.tsx create mode 100644 src/features/projects/components/FirstRunLaunchpad.tsx create mode 100644 src/features/projects/components/ProjectFlowMap.tsx create mode 100644 src/features/projects/components/ProjectOperatingSystemPanel.tsx create mode 100644 src/features/projects/components/runtime/RuntimeCapabilitySummary.tsx create mode 100644 src/features/projects/components/runtime/RuntimeReadinessPrimitives.tsx create mode 100644 src/features/projects/hooks/useProjectOperatingSystem.ts create mode 100644 src/features/projects/pages/FirstRunProjectWizard.tsx create mode 100644 src/features/projects/pages/ProjectDNA.tsx create mode 100644 src/features/projects/pages/ProjectOverview.tsx create mode 100644 src/features/projects/pages/ProjectResults.tsx create mode 100644 src/features/projects/pages/Projects.tsx create mode 100644 src/features/projects/pages/RuntimeReadiness.tsx create mode 100644 src/features/projects/pages/Workspace.tsx create mode 100644 src/features/prompts/pages/PromptGenerator.tsx create mode 100644 src/features/regression/pages/RegressionMemory.tsx create mode 100644 src/features/relationships/pages/RelationshipGraph.tsx create mode 100644 src/features/release/components/ReleaseAnalysisSections.tsx create mode 100644 src/features/release/components/ReleaseOperatorStatusCard.tsx create mode 100644 src/features/release/pages/ReleaseCenter.tsx create mode 100644 src/features/repo-builder/components/GiteaProjectConnection.tsx create mode 100644 src/features/repo-builder/components/RepoBuilderWorkflowSection.tsx create mode 100644 src/features/repo-builder/pages/RepoBuilder.tsx create mode 100644 src/features/session-packs/pages/SessionPacks.tsx create mode 100644 src/features/settings/components/EntraIdentitySettings.tsx create mode 100644 src/features/settings/components/ManagementOverview.tsx create mode 100644 src/features/settings/components/McpEcosystemStatus.tsx create mode 100644 src/features/settings/pages/Settings.tsx create mode 100644 src/features/skills/pages/SkillLibrary.tsx create mode 100644 src/features/snapshots/pages/SnapshotDetail.tsx create mode 100644 src/features/snapshots/pages/Snapshots.tsx create mode 100644 src/features/source-library/pages/ExtraSources.tsx create mode 100644 src/features/source-library/pages/SourceLibrary.tsx create mode 100644 src/features/start/pages/GuidedStart.tsx create mode 100644 src/features/tasks/pages/Tasks.tsx create mode 100644 src/hooks/use-mobile.test.ts create mode 100644 src/hooks/use-mobile.tsx create mode 100644 src/hooks/use-toast.ts create mode 100644 src/lib/utils.ts create mode 100644 src/main.tsx create mode 100644 src/shared/api/autonomyApi.test.ts create mode 100644 src/shared/api/autonomyApi.ts create mode 100644 src/shared/api/b1ReadBackend.ts create mode 100644 src/shared/api/contracts.ts create mode 100644 src/shared/api/contractsRuntime.test.ts create mode 100644 src/shared/api/errors.ts create mode 100644 src/shared/api/giteaApi.ts create mode 100644 src/shared/api/httpClient.test.ts create mode 100644 src/shared/api/httpClient.ts create mode 100644 src/shared/api/mockClient.ts create mode 100644 src/shared/api/operatingSystemApi.ts create mode 100644 src/shared/api/operatingSystemTypes.ts create mode 100644 src/shared/api/portfolioTypes.ts create mode 100644 src/shared/api/projectBrainClient.ts create mode 100644 src/shared/api/projectBrainClientMode.ts create mode 100644 src/shared/api/realClient.ts create mode 100644 src/shared/api/systemTypes.ts create mode 100644 src/shared/api/types.ts create mode 100644 src/shared/components/ActionGroup.tsx create mode 100644 src/shared/components/BuildLoopActionRail.test.ts create mode 100644 src/shared/components/BuildLoopActionRail.tsx create mode 100644 src/shared/components/ConfirmActionButton.tsx create mode 100644 src/shared/components/GuidedWorkSessionCard.tsx create mode 100644 src/shared/components/ProjectFlowRail.tsx create mode 100644 src/shared/components/ProjectOperatingStepCard.tsx create mode 100644 src/shared/components/ProjectRouteFallback.tsx create mode 100644 src/shared/components/ProjectTimeline.tsx create mode 100644 src/shared/components/RuntimeErrorBoundary.tsx create mode 100644 src/shared/components/WhyNothingHappening.tsx create mode 100644 src/shared/contexts/AuthContext.tsx create mode 100644 src/shared/contexts/ProjectContext.tsx create mode 100644 src/shared/contexts/liveProjectUpdate.test.ts create mode 100644 src/shared/contexts/liveProjectUpdate.ts create mode 100644 src/shared/contexts/projectDataBoundary.test.ts create mode 100644 src/shared/contexts/projectDataBoundary.ts create mode 100644 src/shared/contexts/useProjectDataSource.ts create mode 100644 src/shared/hooks/useAsyncResource.test.ts create mode 100644 src/shared/hooks/useAsyncResource.ts create mode 100644 src/shared/hooks/useProjectData.ts create mode 100644 src/shared/lib/activeProjectStorage.ts create mode 100644 src/shared/lib/activityRuntimePresentation.test.ts create mode 100644 src/shared/lib/activityRuntimePresentation.ts create mode 100644 src/shared/lib/appLogger.ts create mode 100644 src/shared/lib/autoModeExecutorContract.test.ts create mode 100644 src/shared/lib/autoModePresentation.test.ts create mode 100644 src/shared/lib/autoModePresentation.ts create mode 100644 src/shared/lib/autoRuntimeSummaryPresentation.test.ts create mode 100644 src/shared/lib/autoRuntimeSummaryPresentation.ts create mode 100644 src/shared/lib/autonomyProfiles.test.ts create mode 100644 src/shared/lib/autonomyProfiles.ts create mode 100644 src/shared/lib/blueprintEngine.ts create mode 100644 src/shared/lib/brainstormEngine.ts create mode 100644 src/shared/lib/browserBridge.ts create mode 100644 src/shared/lib/browserStorage.ts create mode 100644 src/shared/lib/buildLoop.ts create mode 100644 src/shared/lib/buildLoopActionRailStatus.ts create mode 100644 src/shared/lib/buildLoopWalkthrough.ts create mode 100644 src/shared/lib/buildMetadata.ts create mode 100644 src/shared/lib/canonicalLifecycle.test.ts create mode 100644 src/shared/lib/canonicalLifecycle.ts create mode 100644 src/shared/lib/codexExecutionFeedback.ts create mode 100644 src/shared/lib/codexHandoff.ts create mode 100644 src/shared/lib/codexSessionPack.ts create mode 100644 src/shared/lib/codexSessionPackExport.ts create mode 100644 src/shared/lib/codexSkills.ts create mode 100644 src/shared/lib/contextPackSelector.ts create mode 100644 src/shared/lib/coreDomain.test.ts create mode 100644 src/shared/lib/downloads.ts create mode 100644 src/shared/lib/evidenceClosure.ts create mode 100644 src/shared/lib/evidenceRouting.ts create mode 100644 src/shared/lib/featureDeliveryModel.ts create mode 100644 src/shared/lib/findingEvidenceAge.test.ts create mode 100644 src/shared/lib/findingEvidenceAge.ts create mode 100644 src/shared/lib/findingRemediationPresentation.test.ts create mode 100644 src/shared/lib/findingRemediationPresentation.ts create mode 100644 src/shared/lib/guidedStart.ts create mode 100644 src/shared/lib/handoffReview.ts create mode 100644 src/shared/lib/mock-data.ts create mode 100644 src/shared/lib/model.ts create mode 100644 src/shared/lib/navigation.test.ts create mode 100644 src/shared/lib/navigation.tsx create mode 100644 src/shared/lib/operationalCopy.test.ts create mode 100644 src/shared/lib/operationalCopy.ts create mode 100644 src/shared/lib/portfolioAttention.test.ts create mode 100644 src/shared/lib/portfolioAttention.ts create mode 100644 src/shared/lib/portfolioViews.test.ts create mode 100644 src/shared/lib/portfolioViews.ts create mode 100644 src/shared/lib/postRcOperations.test.ts create mode 100644 src/shared/lib/postRcOperations.ts create mode 100644 src/shared/lib/productExperienceAudit.ts create mode 100644 src/shared/lib/productLanguage.ts create mode 100644 src/shared/lib/projectArchiveCommands.test.ts create mode 100644 src/shared/lib/projectArchiveCommands.ts create mode 100644 src/shared/lib/projectAttention.test.ts create mode 100644 src/shared/lib/projectAttention.ts create mode 100644 src/shared/lib/projectAudit.ts create mode 100644 src/shared/lib/projectAutonomyStatus.test.ts create mode 100644 src/shared/lib/projectAutonomyStatus.ts create mode 100644 src/shared/lib/projectBrainstormCommands.test.ts create mode 100644 src/shared/lib/projectBrainstormCommands.ts create mode 100644 src/shared/lib/projectBrowserBridgeCommands.test.ts create mode 100644 src/shared/lib/projectBrowserBridgeCommands.ts create mode 100644 src/shared/lib/projectCodexFeedbackCommands.test.ts create mode 100644 src/shared/lib/projectCodexFeedbackCommands.ts create mode 100644 src/shared/lib/projectCodexHandoffCommands.test.ts create mode 100644 src/shared/lib/projectCodexHandoffCommands.ts create mode 100644 src/shared/lib/projectFirstRunCommands.test.ts create mode 100644 src/shared/lib/projectFirstRunGenerationCommands.ts create mode 100644 src/shared/lib/projectFirstRunLifecycleCommands.ts create mode 100644 src/shared/lib/projectGuidedSessionCommands.test.ts create mode 100644 src/shared/lib/projectGuidedSessionCommands.ts create mode 100644 src/shared/lib/projectGuidedWorkSession.ts create mode 100644 src/shared/lib/projectHistoryCommands.test.ts create mode 100644 src/shared/lib/projectHistoryCommands.ts create mode 100644 src/shared/lib/projectListView.test.ts create mode 100644 src/shared/lib/projectListView.ts create mode 100644 src/shared/lib/projectOperatingStep.test.ts create mode 100644 src/shared/lib/projectOperatingStep.ts create mode 100644 src/shared/lib/projectPresentation.test.ts create mode 100644 src/shared/lib/projectPresentation.ts create mode 100644 src/shared/lib/projectRepoDeliveryCommands.test.ts create mode 100644 src/shared/lib/projectRepoDeliveryCommands.ts create mode 100644 src/shared/lib/projectRepoDesignCommands.test.ts create mode 100644 src/shared/lib/projectRepoDesignCommands.ts create mode 100644 src/shared/lib/projectRepoSnapshotCommands.ts create mode 100644 src/shared/lib/projectRepoStateCommands.test.ts create mode 100644 src/shared/lib/projectRepoWorkPlanCommands.ts create mode 100644 src/shared/lib/projectRepositoryEvidence.test.ts create mode 100644 src/shared/lib/projectRepositoryIntakeCommands.test.ts create mode 100644 src/shared/lib/projectRepositoryIntakeCommands.ts create mode 100644 src/shared/lib/projectResultSemantics.test.ts create mode 100644 src/shared/lib/projectResultSemantics.ts create mode 100644 src/shared/lib/projectResume.test.ts create mode 100644 src/shared/lib/projectResume.ts create mode 100644 src/shared/lib/projectSearch.test.ts create mode 100644 src/shared/lib/projectSearch.ts create mode 100644 src/shared/lib/projectSkillCommands.test.ts create mode 100644 src/shared/lib/projectSkillCommands.ts create mode 100644 src/shared/lib/projectSourceCommands.test.ts create mode 100644 src/shared/lib/projectSourceCommands.ts create mode 100644 src/shared/lib/projectStartFlow.ts create mode 100644 src/shared/lib/projectWizard.ts create mode 100644 src/shared/lib/projectWizardProgress.test.ts create mode 100644 src/shared/lib/projectWorkState.test.ts create mode 100644 src/shared/lib/projectWorkState.ts create mode 100644 src/shared/lib/projectbrainActions.ts create mode 100644 src/shared/lib/projectbrainSelectors.ts create mode 100644 src/shared/lib/rcAudit.ts create mode 100644 src/shared/lib/releaseCandidateFinalization.ts create mode 100644 src/shared/lib/releaseCenterGuidance.ts create mode 100644 src/shared/lib/releaseCenterImproveAction.ts create mode 100644 src/shared/lib/releaseCenterQuality.ts create mode 100644 src/shared/lib/releaseEvidence.ts create mode 100644 src/shared/lib/releaseEvidenceHygiene.ts create mode 100644 src/shared/lib/releaseEvidenceLane.ts create mode 100644 src/shared/lib/releaseEvidenceReview.ts create mode 100644 src/shared/lib/releaseNoteArchive.ts create mode 100644 src/shared/lib/releaseOperatorChecklist.ts create mode 100644 src/shared/lib/releaseOperatorGuide.test.ts create mode 100644 src/shared/lib/releaseOperatorGuide.ts create mode 100644 src/shared/lib/releaseRcGoNoGo.ts create mode 100644 src/shared/lib/releaseReadiness.ts create mode 100644 src/shared/lib/releaseReadinessExport.ts create mode 100644 src/shared/lib/releaseReadinessProofFreshness.ts create mode 100644 src/shared/lib/releaseSignoff.ts create mode 100644 src/shared/lib/releaseTagChecklist.ts create mode 100644 src/shared/lib/releaseValidationContract.test.ts create mode 100644 src/shared/lib/repositoryIntake.ts create mode 100644 src/shared/lib/reviewDecisionReceipt.ts create mode 100644 src/shared/lib/routeMotionAccessibility.test.ts create mode 100644 src/shared/lib/routeRegistry.ts create mode 100644 src/shared/lib/runnerlessDeliveryPolicy.test.ts create mode 100644 src/shared/lib/safeData.ts create mode 100644 src/shared/lib/taskQueue.test.ts create mode 100644 src/shared/lib/taskQueue.ts create mode 100644 src/shared/lib/userExperience.test.ts create mode 100644 src/shared/lib/userExperience.ts create mode 100644 src/shared/lib/userFacingError.test.ts create mode 100644 src/shared/lib/userFacingError.ts create mode 100644 src/shared/lib/utils.ts create mode 100644 src/shared/lib/uxConsolidationPolicy.test.ts create mode 100644 src/shared/lib/workGraph.test.ts create mode 100644 src/shared/lib/workGraph.ts create mode 100644 src/shared/pages/NotFound.tsx create mode 100644 src/shared/styles/globals.css create mode 100644 src/utils/toast.ts create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ddd159d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +node_modules +backend/node_modules +dist +backend/dist +.git +.gitea +.dyad +.codex +.claude +.agents +.idea +.vscode +.vs +tmp +coverage +playwright-report +test-results +release-artifacts +*.log +*.zip +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite-shm +*.sqlite-wal +checkpoints +.env +.env.* +!.env.example +!backend/.env.example + +# Obsolete duplicate init migration removed in V60/V81. Keep it out of Docker builds +# even when an older update overlay left the directory on disk. +backend/prisma/migrations/20260606200000_init_project_workspace diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fe1165d --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# ProjectBrain local/self-host defaults +PROJECTBRAIN_WEB_PORT=1209 +PROJECTBRAIN_DATA_SOURCE=database +PROJECTBRAIN_SEED_ON_START=true +LOG_LEVEL=info + +# PostgreSQL values used by docker-compose.yml. +# Copy this file to .env and replace the password before starting production. +POSTGRES_DB=projectbrain +POSTGRES_USER=projectbrain +POSTGRES_PASSWORD=replace-with-a-unique-postgres-password +DATABASE_URL=postgresql://projectbrain:replace-with-a-unique-postgres-password@postgres:5432/projectbrain?schema=public + +# Frontend build-time mode +# mock = use current in-memory frontend client +# real = use the Fastify API through the nginx /api proxy +VITE_PROJECTBRAIN_CLIENT_MODE=real +VITE_PROJECTBRAIN_API_URL= +# Development-only Vite proxy target used when client mode is real. +PROJECTBRAIN_DEV_API_TARGET=http://127.0.0.1:4000 + +# Required authentication bootstrap. Production rejects these placeholder values. +PROJECTBRAIN_OWNER_USERNAME=owner +PROJECTBRAIN_OWNER_PASSWORD=replace-with-a-unique-password-of-at-least-12-characters +PROJECTBRAIN_AUTH_SECRET=replace-with-at-least-32-random-characters +PROJECTBRAIN_SESSION_TTL_HOURS=24 +PROJECTBRAIN_TRUST_PROXY=true + +# Server-side Gitea and ChatGPT MCP integration. The token never reaches the browser. +PROJECTBRAIN_GITEA_URL=https://gitea.example.com +PROJECTBRAIN_GITEA_TOKEN= +# Optional separate token with write:issue and write:repository. Falls back to the service token. +PROJECTBRAIN_GITEA_WRITE_TOKEN= +PROJECTBRAIN_GITEA_WEBHOOK_SECRET= +PROJECTBRAIN_MCP_ENABLED=false +PROJECTBRAIN_ANALYSIS_WORKER_PORT=4001 +PROJECTBRAIN_ANALYSIS_POLL_MS=1000 +PROJECTBRAIN_ANALYSIS_BATCH_SIZE=5 +PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY=2 +PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS=1800 +PROJECTBRAIN_ANALYSIS_STALE_SECONDS=300 +PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440 +PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true +PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60 +PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400 +PROJECTBRAIN_PUBLIC_URL=https://projectbrain.example.com +# Optional server-to-server credential used only by the governed ITWorx MCP Hub. +PROJECTBRAIN_HUB_SERVICE_TOKEN= + +# Optional Microsoft Entra ID login. Register the callback as a Web redirect URI: +# https://projectbrain.example.com/api/auth/entra/callback +PROJECTBRAIN_ENTRA_TENANT_ID= +PROJECTBRAIN_ENTRA_CLIENT_ID= +PROJECTBRAIN_ENTRA_CLIENT_SECRET= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4df905f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.pdf binary +*.zip binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..399c876 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies and build output +node_modules/ +backend/node_modules/ +.opencode/ +dist/ +backend/dist/ +release-artifacts/ +dist-ssr/ +coverage/ +playwright-report/ +test-results/ +checkpoints/ +*.zip +*.local +*.tsbuildinfo + +# Runtime and local durable data +tmp/ +.cache/ +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite-shm +*.sqlite-wal +backups/local/ + +# Local AI/editor workspace state +.dyad/ +.codex/ +.claude/ +.agents/ +.vscode/* +!.vscode/extensions.json +.idea/ +.vs/ +.DS_Store +Thumbs.db +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Local environment files and credentials +.env +.env.* +!.env.example +!backend/.env.example +*.key +*.pem +*.p12 +*.pfx +secrets/ +credentials/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..9076fbc --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,42 @@ +FROM node:24-alpine AS build + +WORKDIR /app +ENV CI=true +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV npm_config_fetch_warn_timeout=60000 +RUN corepack enable + +ARG VITE_PROJECTBRAIN_API_URL="" +ARG VITE_PROJECTBRAIN_CLIENT_MODE="real" +ARG VITE_PROJECTBRAIN_RELEASE_TAG="" +ARG VITE_PROJECTBRAIN_COMMIT="" +ARG VITE_PROJECTBRAIN_BRANCH="" +ARG VITE_PROJECTBRAIN_BUILD_TIME="" +ARG VITE_PROJECTBRAIN_SOURCE="" +ENV VITE_PROJECTBRAIN_API_URL=$VITE_PROJECTBRAIN_API_URL +ENV VITE_PROJECTBRAIN_CLIENT_MODE=$VITE_PROJECTBRAIN_CLIENT_MODE +ENV VITE_PROJECTBRAIN_RELEASE_TAG=$VITE_PROJECTBRAIN_RELEASE_TAG +ENV VITE_PROJECTBRAIN_COMMIT=$VITE_PROJECTBRAIN_COMMIT +ENV VITE_PROJECTBRAIN_BRANCH=$VITE_PROJECTBRAIN_BRANCH +ENV VITE_PROJECTBRAIN_BUILD_TIME=$VITE_PROJECTBRAIN_BUILD_TIME +ENV VITE_PROJECTBRAIN_SOURCE=$VITE_PROJECTBRAIN_SOURCE + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY backend/package.json backend/package.json +COPY packages/contracts/package.json packages/contracts/package.json +RUN pnpm install --frozen-lockfile --filter projectbrain... > /tmp/pnpm-install.log 2>&1 || (cat /tmp/pnpm-install.log; exit 1); awk 'index($0, "[W" "ARN] Request took") == 0 { print }' /tmp/pnpm-install.log + +COPY packages/contracts packages/contracts +COPY index.html components.json postcss.config.js tailwind.config.ts tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts ./ +COPY public public +COPY src src +RUN pnpm run build + +FROM nginxinc/nginx-unprivileged:1.27-alpine + +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 8080 + +USER nginx diff --git a/Dockerfile.unraid b/Dockerfile.unraid new file mode 100644 index 0000000..8726f06 --- /dev/null +++ b/Dockerfile.unraid @@ -0,0 +1,91 @@ +FROM node:24-alpine AS frontend-build + +WORKDIR /app +ENV CI=true +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV npm_config_fetch_warn_timeout=60000 +RUN corepack enable + +ARG VITE_PROJECTBRAIN_API_URL="" +ARG VITE_PROJECTBRAIN_CLIENT_MODE="real" +ARG VITE_PROJECTBRAIN_RELEASE_TAG="" +ARG VITE_PROJECTBRAIN_COMMIT="" +ARG VITE_PROJECTBRAIN_BRANCH="" +ARG VITE_PROJECTBRAIN_BUILD_TIME="" +ARG VITE_PROJECTBRAIN_SOURCE="" +ENV VITE_PROJECTBRAIN_API_URL=$VITE_PROJECTBRAIN_API_URL +ENV VITE_PROJECTBRAIN_CLIENT_MODE=$VITE_PROJECTBRAIN_CLIENT_MODE +ENV VITE_PROJECTBRAIN_RELEASE_TAG=$VITE_PROJECTBRAIN_RELEASE_TAG +ENV VITE_PROJECTBRAIN_COMMIT=$VITE_PROJECTBRAIN_COMMIT +ENV VITE_PROJECTBRAIN_BRANCH=$VITE_PROJECTBRAIN_BRANCH +ENV VITE_PROJECTBRAIN_BUILD_TIME=$VITE_PROJECTBRAIN_BUILD_TIME +ENV VITE_PROJECTBRAIN_SOURCE=$VITE_PROJECTBRAIN_SOURCE + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY backend/package.json backend/package.json +COPY packages/contracts/package.json packages/contracts/package.json +RUN pnpm install --frozen-lockfile --filter projectbrain... > /tmp/pnpm-install.log 2>&1 || (cat /tmp/pnpm-install.log; exit 1); awk 'index($0, "[W" "ARN] Request took") == 0 { print }' /tmp/pnpm-install.log + +COPY packages/contracts packages/contracts +COPY index.html components.json postcss.config.js tailwind.config.ts tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts ./ +COPY public public +COPY src src +RUN pnpm run build + +FROM node:24-alpine AS backend-build + +WORKDIR /app +ENV CI=true +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV npm_config_fetch_warn_timeout=60000 +ENV PRISMA_SKIP_POSTINSTALL_GENERATE=true +RUN apk add --no-cache openssl libc6-compat ca-certificates +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY backend/package.json backend/package.json +COPY packages/contracts/package.json packages/contracts/package.json +RUN pnpm install --frozen-lockfile --filter projectbrain-api... > /tmp/pnpm-install.log 2>&1 || (cat /tmp/pnpm-install.log; exit 1); awk 'index($0, "[W" "ARN] Request took") == 0 { print }' /tmp/pnpm-install.log + +COPY packages/contracts packages/contracts +COPY backend backend +WORKDIR /app/backend +RUN pnpm run prisma:generate && pnpm run build +WORKDIR /app +RUN pnpm --filter projectbrain-api deploy --prod --legacy /runtime +RUN cp -R /app/backend/dist /runtime/dist \ + && cd /runtime \ + && ./node_modules/.bin/prisma generate --schema prisma/schema.prisma + +# trivy:ignore:DS-0002 -- Accepted v1.0 single-container exception: the init +# process must create/chown the persistent PostgreSQL volume and then starts +# PostgreSQL as the dedicated postgres user. Nginx drops worker privileges and +# the Fastify service has no Docker socket or additional Linux capabilities. +FROM node:24-alpine + +WORKDIR /app +ENV NODE_ENV=production +ENV HOST=127.0.0.1 +ENV PORT=4000 +ENV PROJECTBRAIN_DATA_SOURCE=database +ENV PROJECTBRAIN_SEED_ON_START=true +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + +RUN apk add --no-cache nginx openssl libc6-compat ca-certificates curl postgresql16 postgresql16-client +RUN rm -rf /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/npm \ + /usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx \ + /usr/local/bin/pnpm /usr/local/bin/pnpx + +COPY --from=backend-build /runtime /app/backend +COPY --from=frontend-build /app/dist /usr/share/nginx/html +COPY docker/nginx.single.conf /etc/nginx/http.d/default.conf +COPY scripts/unraid-single-entrypoint.sh /usr/local/bin/projectbrain-unraid-entrypoint.sh + +RUN chmod +x /usr/local/bin/projectbrain-unraid-entrypoint.sh + +EXPOSE 80 +VOLUME ["/var/lib/projectbrain"] + +HEALTHCHECK --interval=15s --timeout=5s --start-period=35s --retries=10 CMD curl -fsS http://127.0.0.1/api/system/readiness >/dev/null || exit 1 + +CMD ["sh", "/usr/local/bin/projectbrain-unraid-entrypoint.sh"] diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..7ede916 --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -0,0 +1,3 @@ +# Public source export + +This source snapshot was generated from the private canonical repository at revision `8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..1b154fb --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,3496 @@ +{ + "schemaVersion": 1, + "sourceRevision": "8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5", + "files": [ + { + "path": ".dockerignore", + "sha256": "388008599111292dc6d18e5b376f5120f09c15f1e046366892fdd1b1c2f0c22e", + "bytes": 517 + }, + { + "path": ".env.example", + "sha256": "d336770cf506118024859fb5b9759bf6b7845e9c9c97ff29e3d243082d771b32", + "bytes": 2342 + }, + { + "path": ".gitattributes", + "sha256": "769669fafc924493fdbb342add54a9c2573d7c04d8c5df93abef3ca45fa7012a", + "bytes": 126 + }, + { + "path": ".gitignore", + "sha256": "682db97f44a890c21cfc280e134966987bff3a63b7bb28653e6707c034f82bd0", + "bytes": 739 + }, + { + "path": ".nvmrc", + "sha256": "68ca3fba3b7e864770cb61aeb306d4bd4354b68ab4dd38450860c5d823e42a53", + "bytes": 3 + }, + { + "path": "Dockerfile.frontend", + "sha256": "9bbd09a364393317a84af6155c94697aaf6b7dba5bc29dc72f7777571cd94254", + "bytes": 1602 + }, + { + "path": "Dockerfile.unraid", + "sha256": "e11ff1e58ff2a869d10f80dcb8d41302c8524952e6dc9acb033296906889b3d2", + "bytes": 3824 + }, + { + "path": "README.md", + "sha256": "36b5b7b04ade4ad583372c6da4655f92cdc52aba7f0ac851e0407728e8a6a60e", + "bytes": 7230 + }, + { + "path": "SECURITY.md", + "sha256": "6bbb9c1d82603b4d31d678cc71e4464eb8c52a11487c7f7edc5d8e770ae4aee1", + "bytes": 1297 + }, + { + "path": "backend/.env.example", + "sha256": "d8316a5c50407c5fad71cd659cc18a25ced26b49422ed7ce05b791feb7728cc5", + "bytes": 1632 + }, + { + "path": "backend/.gitignore", + "sha256": "34c786bb2f6ba90362f148a5b04952f1359d8c8cd72769fede502f92345737f9", + "bytes": 30 + }, + { + "path": "backend/Dockerfile", + "sha256": "3adb7914302a981d42b59e2c6bf4b0a00c68da7610043a8b7d6f0ca6c1f9f481", + "bytes": 1319 + }, + { + "path": "backend/README.md", + "sha256": "1bcba489fcc5ea7af892bfda0553495515c7dcd42f2732639adc4b9557f02fc5", + "bytes": 3573 + }, + { + "path": "backend/package.json", + "sha256": "1780b3c1ff961ec432f79d513c99da5b0ce09eb4ae1efaf77d7a58b4bcec73ba", + "bytes": 3044 + }, + { + "path": "backend/prisma/migrations/20260606000000_init_project_workspace/migration.sql", + "sha256": "61e16f082ecd13b939209dd9076b48e85fa36221dd419dc44bd872e7015a3bb8", + "bytes": 21846 + }, + { + "path": "backend/prisma/migrations/20260606010000_p5_capture_review_parity/migration.sql", + "sha256": "deb33c96741520aaedc73d1fc31425a442e8d5df2a26ddbe92c2251f564a390e", + "bytes": 1933 + }, + { + "path": "backend/prisma/migrations/20260606020000_p6_truth_updates_operating_log/migration.sql", + "sha256": "b00cf71cdf98ddda31d31b8dbd72bfa53f4997a9fc541ff151c51ff156b95d07", + "bytes": 5999 + }, + { + "path": "backend/prisma/migrations/20260606030000_p7_current_state_handoff_codex/migration.sql", + "sha256": "c5abd9fdada82a701d271dd0a1fc90452cd75cc8021b81a93c9bb36fe9428b28", + "bytes": 7603 + }, + { + "path": "backend/prisma/migrations/20260606040000_p8_docs_knowledge_memory/migration.sql", + "sha256": "0d027e0845fac1a7300acc9156baa91d1d840834ea84efb694f43a21d4d153cd", + "bytes": 1819 + }, + { + "path": "backend/prisma/migrations/20260609070000_v52_blueprint_persistence/migration.sql", + "sha256": "00eb958e990613a98d147c731e9d2cf871b86932bd6c48e0bb5696aefbafb738", + "bytes": 5964 + }, + { + "path": "backend/prisma/migrations/20260609120000_v68_brainstorm_cpu_persistence/migration.sql", + "sha256": "d00e0ebd88a376a490218bff71bae41697a979395f46b2416971c3dddd75ade4", + "bytes": 1969 + }, + { + "path": "backend/prisma/migrations/20260609130000_v72_initiatives_persistence/migration.sql", + "sha256": "82e0fddf04fb58799d8e25c5947ed460bc7b3ec5f7987fa0a4acc1225a2847d9", + "bytes": 1905 + }, + { + "path": "backend/prisma/migrations/20260624150000_rc39_release_evidence_records/migration.sql", + "sha256": "3f8dc76355fddae5f3249c4a900fc2875a87952ab38167e9f1a2061a1bf39908", + "bytes": 2489 + }, + { + "path": "backend/prisma/migrations/20260729000000_v1_authentication/migration.sql", + "sha256": "5a6f09233da04b0e9480f0beeafe09f21767f4e67a16b105cb6edc70e7708afb", + "bytes": 2269 + }, + { + "path": "backend/prisma/migrations/20260821000000_gitea_mcp_ecosystem/migration.sql", + "sha256": "94b0e270f1efabd6151122acb3051b194cc7b2de0c74e62da98526857a15c1ec", + "bytes": 2031 + }, + { + "path": "backend/prisma/migrations/20260821010000_entra_oidc_login/migration.sql", + "sha256": "3ba207352c6204996a1b7687e589755ffdd099ff2f397a8f77603cee88807ae8", + "bytes": 1683 + }, + { + "path": "backend/prisma/migrations/20260825000000_unified_delivery_core/migration.sql", + "sha256": "44564621b209318efc28b2ed6779b74dc73565cdc8ec29185d32d97996452f89", + "bytes": 11700 + }, + { + "path": "backend/prisma/migrations/20260826020000_delivery_review_feedback/migration.sql", + "sha256": "464e94b08f6389bd1c8dc213e17231b7a73dfd58931cbc60a7af4aea9acfd6fa", + "bytes": 61 + }, + { + "path": "backend/prisma/migrations/20260826030000_async_repository_analysis/migration.sql", + "sha256": "a4ec2979e4675c0b217b0932643307ae402ab170492558721ecec8d42f70c94e", + "bytes": 3472 + }, + { + "path": "backend/prisma/migrations/20260826040000_closed_loop_findings/migration.sql", + "sha256": "29362ff3646c481b3104f9a156e2a4c462c291cb2ad18052dae237f807adff3f", + "bytes": 7254 + }, + { + "path": "backend/prisma/migrations/20260826050000_portfolio_control_plane/migration.sql", + "sha256": "357a73e5675f1d98ac85d5d1491b490ce40d51f030cf26ba918c39afd6706739", + "bytes": 1435 + }, + { + "path": "backend/prisma/migrations/20260829211000_partial_analysis_status/migration.sql", + "sha256": "3f4e79a01d88dbf9aa8d4517bee2c85f1619fb1a925806e430ce8ebff115cf61", + "bytes": 66 + }, + { + "path": "backend/prisma/migrations/20260830000000_git_source_library/migration.sql", + "sha256": "0fc0fc5897808b1334091cbf9331fb13ffe53fa64707783231710d5f3bf038b8", + "bytes": 1270 + }, + { + "path": "backend/prisma/migrations/20260902000000_attention_provenance_cleanup/migration.sql", + "sha256": "607a3892a10900bb110f5fef320520681d3d7aa638cd25d8e6b469f52779ff9a", + "bytes": 735 + }, + { + "path": "backend/prisma/migrations/20260902130000_autonomy_executor_heartbeat/migration.sql", + "sha256": "b46582b0d602c26f7676e2b1a98ce00c3db289b00aa7026f486ea99eef514bc7", + "bytes": 484 + }, + { + "path": "backend/prisma/migrations/20260912190000_autonomous_operating_system_foundation/migration.sql", + "sha256": "b475f7135942b9e96fd29209fd1988e26f362451472dbe461478baefb1e1b33e", + "bytes": 9096 + }, + { + "path": "backend/prisma/migrations/20260913090000_portfolio_project_pins/migration.sql", + "sha256": "c0bf8ca5ea56075b2453c619ec293ba625a9ba0196f6bc4c644d7f5f38808289", + "bytes": 897 + }, + { + "path": "backend/prisma/migrations/migration_lock.toml", + "sha256": "1a87768ca81682f16500f618caaab39c836e990dbcb15757721c1d044b91be0b", + "bytes": 127 + }, + { + "path": "backend/prisma/schema.prisma", + "sha256": "732782755683059eb783c7b24e7c4e1e152ad7c05e6adfd247c35c2e4cb4e876", + "bytes": 61787 + }, + { + "path": "backend/prisma/seed.ts", + "sha256": "ef45ffb09824551b94d5582b4a7e1c923a7d1b12a6ab5bf3d66d5f2282bbfe59", + "bytes": 31183 + }, + { + "path": "backend/scripts/audit-backend-contract.mjs", + "sha256": "202f4752474e0afd06bbb15c86b58670cb2d102e942adad1fa4c893f0c285c80", + "bytes": 9532 + }, + { + "path": "backend/scripts/docker-entrypoint.sh", + "sha256": "f34d230c8298c34a57dbc6d834ffc8679fe4748361fc7884f29df83d97797f0d", + "bytes": 1278 + }, + { + "path": "backend/scripts/prisma-generate.mjs", + "sha256": "4292e09ec65e065fda92bfce663700ba76306ff1ab639821957c318610f6cf64", + "bytes": 1200 + }, + { + "path": "backend/scripts/prisma-validate.mjs", + "sha256": "81f881bee566c071f5edc32b3bd1bac68c4490c717285a3d6aab1006c48bf163", + "bytes": 776 + }, + { + "path": "backend/src/app.ts", + "sha256": "77e4e4589dcebf5a4baf76b19a37e7eb9407b1dda58fc94c77c4f7e24fdb99ab", + "bytes": 8230 + }, + { + "path": "backend/src/config/env.ts", + "sha256": "c9de76c76754fe0625c192790ced90828efb1cccf2b5e4a2a99074f045eee8d5", + "bytes": 9132 + }, + { + "path": "backend/src/db/prisma.ts", + "sha256": "1fcc25e4e893ccb8489da18770432c0dc65d30a006891a53721bd885e9cd7610", + "bytes": 536 + }, + { + "path": "backend/src/fixtures/aiActionFixtures.ts", + "sha256": "d2556a4294aa66de9f738f432acef543e40e7e212e5ea36b94ae3d53bdd7f39d", + "bytes": 7531 + }, + { + "path": "backend/src/fixtures/buildFixtures.ts", + "sha256": "6a4e175333a79cf785bea04f6b052165c953da8091fc9aa24e39985b4a9751e9", + "bytes": 5677 + }, + { + "path": "backend/src/fixtures/captureFixtures.ts", + "sha256": "b58a1b5584589b48181458990c81a2069962e39a1e422d53f6fec1042bd1fe62", + "bytes": 5407 + }, + { + "path": "backend/src/fixtures/checkFixtures.ts", + "sha256": "70b5ff890242bbe38f2bf68aae7297865ca8f66a650af7a7052fde59c901a315", + "bytes": 9254 + }, + { + "path": "backend/src/fixtures/contextPackFixtures.ts", + "sha256": "d2a0a3015653e0ea0d2dc495bcf38e7c49b9e09f80530253101998ce092c787d", + "bytes": 11931 + }, + { + "path": "backend/src/fixtures/featureFixtures.ts", + "sha256": "670e01ebe4df8d3aa5dede77ac3d9637c4ae502209787019d53a73f3e3021ca2", + "bytes": 7589 + }, + { + "path": "backend/src/fixtures/insightFixtures.ts", + "sha256": "ee8f9e1b2187945c6c35348d21f60d366e98d3c90bbc564e10fbdd1f3a45e5ce", + "bytes": 3443 + }, + { + "path": "backend/src/fixtures/knowledgeFixtures.ts", + "sha256": "d88a908a06c9b937bc6f19109c832cb5e0adee9740fcb06e42e49a4f2d3ad4df", + "bytes": 4789 + }, + { + "path": "backend/src/fixtures/memoryRuleFixtures.ts", + "sha256": "2c89784a27ba7fe64e5161644c6ebd450813175d7cefa9ced67282db01ecfe96", + "bytes": 5105 + }, + { + "path": "backend/src/fixtures/projectFixtures.ts", + "sha256": "3a12db3d537adbdb3b1a77ac1fda19bc6f89bc0817b0e3860cf09caefb1e9e12", + "bytes": 13070 + }, + { + "path": "backend/src/fixtures/regressionFixtures.ts", + "sha256": "eb4c4627fc9a3f2535c1ecb3ac4c2d26092f02a455bd9b3a38826ea5be3d131e", + "bytes": 4693 + }, + { + "path": "backend/src/fixtures/relationshipFixtures.ts", + "sha256": "59120cfdea44c108969d729dd34998af4ee849ebe7ad9d09abd88e37ba8df126", + "bytes": 3512 + }, + { + "path": "backend/src/fixtures/reviewQueueFixtures.ts", + "sha256": "8357660170aff871ba1e36d60d56db9353d78c1ffd6f7eb9ce32c84d80c3b70c", + "bytes": 7756 + }, + { + "path": "backend/src/fixtures/snapshotFixtures.ts", + "sha256": "8c7cb41093629208bd92a2f2c98f6c4fc57d59ab8cac9b427bfab49e529632ce", + "bytes": 3569 + }, + { + "path": "backend/src/integrations/gitea/client.ts", + "sha256": "642e4260e52c5ede4eff5596ef66f36d990c991a40a75cd611099d2975fbccb4", + "bytes": 16274 + }, + { + "path": "backend/src/integrations/gitea/policy.ts", + "sha256": "a6c2fe82f9ca0d9a4420922cdff014945cc88a101cf38a369712f40dd415ae5e", + "bytes": 4676 + }, + { + "path": "backend/src/integrations/gitea/portfolio.ts", + "sha256": "927f5ed76ab6a27102e0e2dd0bcd34e858a7ae61b07b597d2d09862d045f2d93", + "bytes": 3294 + }, + { + "path": "backend/src/integrations/gitea/service.ts", + "sha256": "e48a5a445ca729403fe47fe836c4e3e625968d52992bcca7a5bbd6b12d8f22a0", + "bytes": 20907 + }, + { + "path": "backend/src/integrations/gitea/types.ts", + "sha256": "96d115230cc462d1c0bf353ab92530708c0430f98a14b38440c70287a520c45b", + "bytes": 3525 + }, + { + "path": "backend/src/mcp/analysisWidget.ts", + "sha256": "23e46249bf68f70d7e3cbdfc0cc1876adb674a2bd835c93f178a9b783e337fa9", + "bytes": 5442 + }, + { + "path": "backend/src/mcp/autonomyServer.ts", + "sha256": "a92e7714865862c7f218cc04f5eee23ec8fd6d51fecc9987703c165523d69240", + "bytes": 22572 + }, + { + "path": "backend/src/mcp/featureChangeset.ts", + "sha256": "d75ec47f3a2f50cf87eeeb1bef6f683fd1a015d19e915dbe18ff120336fac4f0", + "bytes": 1650 + }, + { + "path": "backend/src/mcp/portfolioTools.ts", + "sha256": "2272c2e71ea63eae06530413df3f65e6de1e0a0e6f9e3f77219ffbb333c353d0", + "bytes": 8301 + }, + { + "path": "backend/src/mcp/resultHelpers.ts", + "sha256": "aab730cb3239992b037e5210f0f5297ebdcc1888c5844f4b5b28f62935fb365e", + "bytes": 4707 + }, + { + "path": "backend/src/mcp/scopeGuards.ts", + "sha256": "af383e09ccc02e3b527477669e2b82e70fe0711d74df42ad201673d6afdf538d", + "bytes": 1381 + }, + { + "path": "backend/src/mcp/server.ts", + "sha256": "cadbf35742b2857c302acce4d770dc31fb7887b9dc43ad40af44fbaee7fcf009", + "bytes": 78413 + }, + { + "path": "backend/src/mcp/skills.ts", + "sha256": "62f72fff51f45af2f88e52cfc6ee53758298866bb6a8124c585b1dcc18af219f", + "bytes": 10511 + }, + { + "path": "backend/src/mcp/widget.ts", + "sha256": "1a3ae134a49a558674d2bb9be676bf1846381731b46fcbf247d17adaeed88c2b", + "bytes": 6413 + }, + { + "path": "backend/src/plugins/authentication.ts", + "sha256": "31cc2f013b426755da76bbd78998b235337e923259a118a65dd23776935b4eb9", + "bytes": 3073 + }, + { + "path": "backend/src/plugins/errorHandler.ts", + "sha256": "2ced4fe0dc1b4ff193e20b8f9249adb44858a77d926011ee33e44fd6b40b2fa6", + "bytes": 1755 + }, + { + "path": "backend/src/plugins/observability.ts", + "sha256": "f19eb4933cc20a37b27146798f6d189b23280774941e7c2978b9c34a823dd723", + "bytes": 3397 + }, + { + "path": "backend/src/plugins/requestId.ts", + "sha256": "bdbe29d58204bcdabb46c4e67613999b527115dfad89933bdf328791e6d08fa0", + "bytes": 943 + }, + { + "path": "backend/src/repositories/analysisJobRepository.ts", + "sha256": "8b341be60228af80a5b88b035badbf65c83bc5c52a3fa7bc11700f35b7797abf", + "bytes": 14079 + }, + { + "path": "backend/src/repositories/autonomyExecutionEvidence.ts", + "sha256": "c6d6b23d0266bbea9a8318ed9756ef3e8c22b3160834a601952ed1efe5dbcf82", + "bytes": 592 + }, + { + "path": "backend/src/repositories/autonomyRepository.ts", + "sha256": "4afd9ae0d74773bb1d9c8371f430699b95b62ea965570127479faea3d70c2bf3", + "bytes": 43286 + }, + { + "path": "backend/src/repositories/blueprintsRepository.ts", + "sha256": "b86efe397adf82011138017661071f5dc442fa719dd439d118fd412c58fbf796", + "bytes": 29211 + }, + { + "path": "backend/src/repositories/brainstormRepository.ts", + "sha256": "74f3ee4afab774ddf6677d993326b4db65e1fd1d27e1468893103c2c007ee46e", + "bytes": 23311 + }, + { + "path": "backend/src/repositories/captureRepository.ts", + "sha256": "3595420d60341c21fb26779dc17da0671f8955eaead3495dee11a5b9d1046317", + "bytes": 7602 + }, + { + "path": "backend/src/repositories/contextPacksRepository.ts", + "sha256": "c12a593b62d314b8b285d6c4d12fbac8dbdaeb222ad1ac60c716266b6f3e6d31", + "bytes": 13199 + }, + { + "path": "backend/src/repositories/evidenceRepository.ts", + "sha256": "54a9ec60f4c453b0840344b6f033418dae0315e9984762001260769ea1505ea0", + "bytes": 3414 + }, + { + "path": "backend/src/repositories/featureDeliveryRepository.ts", + "sha256": "cc68574003e4ba9d8796f9d16b20a12b4f7b54191e7aeb27bc5e48a9585493ab", + "bytes": 30600 + }, + { + "path": "backend/src/repositories/featureGroupsRepository.ts", + "sha256": "877ed174c32edb24d205264c407611cad5553591eddaf18cf68eccf2c8ced039", + "bytes": 1263 + }, + { + "path": "backend/src/repositories/featuresRepository.ts", + "sha256": "a20c0b20573d13f62e41443c3e82cff60ac6b941a6e4d2dcd2bc33a611416589", + "bytes": 3944 + }, + { + "path": "backend/src/repositories/findingRepository.ts", + "sha256": "b972a92f27a7210fb51cbec3cc9e6d5b2be857a5f10592931f8f1d0edd0d48e7", + "bytes": 32092 + }, + { + "path": "backend/src/repositories/initiativesRepository.ts", + "sha256": "250a144eb44eb5643fef6557940bbba9694aab6f6eea10b47009fc8ab44233fe", + "bytes": 18447 + }, + { + "path": "backend/src/repositories/p7RuntimeRepository.ts", + "sha256": "cfc14e2f814feebac98e162d5294b6584a4076c66ee3b4da6cc5104717194e0c", + "bytes": 29013 + }, + { + "path": "backend/src/repositories/p8KnowledgeMemoryRepository.ts", + "sha256": "17a7e4f2b89704e8bd7698d08a49af99b56616e816a34884321c139f47ffafc6", + "bytes": 20224 + }, + { + "path": "backend/src/repositories/projectSetupState.ts", + "sha256": "2004f7407b3686f7ce8b97d5d837a93822f161332640dbe99d175f3e591c721c", + "bytes": 1501 + }, + { + "path": "backend/src/repositories/projectsRepository.ts", + "sha256": "8b2be54d8c03121cf48d4d5e288bc518a0c8fa081f0316b9cc8dad8ebb229c61", + "bytes": 18235 + }, + { + "path": "backend/src/repositories/releaseEvidenceRepository.ts", + "sha256": "c55596ccd3471db06b2237a784b8b33c47ab0e8aa107556fce6d4dd6cc7b7f55", + "bytes": 7506 + }, + { + "path": "backend/src/repositories/reviewAttentionPolicy.ts", + "sha256": "2483d4c0eef187f19ae0b12ec4a1e988a8d3b07b4cd848ff27c88f67c442a7e4", + "bytes": 916 + }, + { + "path": "backend/src/repositories/reviewQueueRepository.ts", + "sha256": "37d6ab7c6d0d93c1b3def43de92cd0ed06665a4d9238e57bec877858c6e90a84", + "bytes": 18948 + }, + { + "path": "backend/src/repositories/runtimeRepository.ts", + "sha256": "f041ad5879f4bae4e0a1edafb8254d9daadf3ee4d1e373a88c883296fa4329ff", + "bytes": 16355 + }, + { + "path": "backend/src/routes/activity.ts", + "sha256": "60dac17926b995b1e2ffd6ac6775effd76c6a063b88e8c864fddfedbe8913745", + "bytes": 12117 + }, + { + "path": "backend/src/routes/aiActions.ts", + "sha256": "40513c68b43a530e97ea13edfb49cba3e26d6bc22fb0913bfa96f6f919ecffa0", + "bytes": 9987 + }, + { + "path": "backend/src/routes/analysisJobs.ts", + "sha256": "11b39b1b303369d843825bf6ec891c629e6e23d54ff99298691d9ac27912704f", + "bytes": 4628 + }, + { + "path": "backend/src/routes/auth.ts", + "sha256": "72df95331335d08d6c8d8e7fbee986260ee3f1a4a9692f87f9321d57123293df", + "bytes": 3329 + }, + { + "path": "backend/src/routes/autonomy.ts", + "sha256": "67003ed371d177b239731cdc6f5f0562f199a2c0b6bfc9dfa13e7fc62cf17449", + "bytes": 12746 + }, + { + "path": "backend/src/routes/blueprints.ts", + "sha256": "0870154e43173e34a75c6e22e8d6ea6cc85f7498fbe77f12f480d019bc987ec1", + "bytes": 13247 + }, + { + "path": "backend/src/routes/brainstorms.ts", + "sha256": "90da8dd9558c66bf9f033fad28d35ba10f4e0b49084f9723d16288b07ef1f2fa", + "bytes": 7311 + }, + { + "path": "backend/src/routes/builds.ts", + "sha256": "bff31e942e273e8c2d92918578373ac294ad03988f61dde418363ecf05698701", + "bytes": 9344 + }, + { + "path": "backend/src/routes/capture.ts", + "sha256": "50fd01eba8f076f29bd9c9b68d1e9e8c21bb492769b2bba6f59782be4baa57ef", + "bytes": 14723 + }, + { + "path": "backend/src/routes/checks.ts", + "sha256": "359d33608af04b0ff34a528823dff6b178c8799df76c5357660466e523817556", + "bytes": 12969 + }, + { + "path": "backend/src/routes/contextPacks.ts", + "sha256": "ab6caef50147610f75d3a842e136eb1ff05014af0ab25a1d826a380b6382433f", + "bytes": 12959 + }, + { + "path": "backend/src/routes/documentation.ts", + "sha256": "f1365ba01e70584c2adf80662f2afebd5c7525732c000a884dc21d3b56d9732d", + "bytes": 9817 + }, + { + "path": "backend/src/routes/entraAuth.ts", + "sha256": "fb4151e96d7fd8ae6c05b309e5160c9dc60aaec69a4984ab0be419249ed49c33", + "bytes": 5770 + }, + { + "path": "backend/src/routes/evidence.ts", + "sha256": "96aaff54c3af89fa1992ea5ccfe4c56fc9c9d1a24e62d3de44319e248e4bf87d", + "bytes": 8708 + }, + { + "path": "backend/src/routes/features.ts", + "sha256": "2d22bce67ae8720ab8e187314995b16891cef5c6378264e80a0f86c283aa6364", + "bytes": 8584 + }, + { + "path": "backend/src/routes/findings.ts", + "sha256": "e2bfc31d678b32601ab98c64fe196a9f36671d2dea8d553390c6044aa8003126", + "bytes": 6218 + }, + { + "path": "backend/src/routes/giteaIntegration.ts", + "sha256": "b03bab5dcd951f6160c7251809f97db936729cec0b9070607a8b0f4d873ad7ca", + "bytes": 45151 + }, + { + "path": "backend/src/routes/health.ts", + "sha256": "5afe6300a0661cf9033675de6e7b8d5396ebf5dfac97cd008f2f2f309f1c0311", + "bytes": 719 + }, + { + "path": "backend/src/routes/initiatives.ts", + "sha256": "174f431921055fa2d1a50e974242a7381de4cf77dfa5303686bbc9593c8d8930", + "bytes": 11267 + }, + { + "path": "backend/src/routes/insights.ts", + "sha256": "502de4841a0e159737e51526269eb07307c1186c7b0bf5324d9665624eca068e", + "bytes": 8249 + }, + { + "path": "backend/src/routes/knowledge.ts", + "sha256": "50395c4665c0f6e8061fa7a177a47e74a3cdf0c2e02193e4f6fe9eb4752935df", + "bytes": 12094 + }, + { + "path": "backend/src/routes/mcp.ts", + "sha256": "1ff53586370f584db5bba28a0496816d719147c6afb721ef44328a4d20535be2", + "bytes": 5242 + }, + { + "path": "backend/src/routes/mcpOAuth.ts", + "sha256": "d356302f621550ae540bed5ee4efb1cfbcf90ee4e457e335fddafb20d3c84248", + "bytes": 7383 + }, + { + "path": "backend/src/routes/memoryRules.ts", + "sha256": "01cf6218e631693d6c6ce782256fa1ec9c6cb64e334529b6984b789679770b38", + "bytes": 12232 + }, + { + "path": "backend/src/routes/metrics.ts", + "sha256": "df5cb4598f30528cf816105212823db9c44d2b183d60e5e901ec7e2bc308daf8", + "bytes": 2389 + }, + { + "path": "backend/src/routes/operatingSystem.ts", + "sha256": "91b4a4e8d253c272f05fd0f152efe4fc45e03418bebd511c71663609395b0a0b", + "bytes": 20391 + }, + { + "path": "backend/src/routes/p7Runtime.ts", + "sha256": "12fcfe6a7fb365ceda22f84da62fa20417bf644a0aaa5f8df27f5b80ca2d0db4", + "bytes": 20335 + }, + { + "path": "backend/src/routes/portfolio.ts", + "sha256": "ec1357d5bbdbb55942d6c67047145d2e7ce570719580f83d60824a44154f19b4", + "bytes": 6671 + }, + { + "path": "backend/src/routes/projects.ts", + "sha256": "86c8cedb5351c0c16da738bdd1c0690d9f168b5323d3debf3b4fec70811510cd", + "bytes": 16797 + }, + { + "path": "backend/src/routes/prompts.ts", + "sha256": "a9c6feb99d994a130977a25137ff7b27acfc18d4e919b121d328bfb7e66d8674", + "bytes": 5353 + }, + { + "path": "backend/src/routes/regressions.ts", + "sha256": "2d1cb30106b3f483c4574a6e43fbcf44afb8637b11b13f7487c469a8067fc902", + "bytes": 10388 + }, + { + "path": "backend/src/routes/relationships.ts", + "sha256": "166ecdc51fb108d40d8bda4d7beb315335a6752640602c1431b4c2d01a185b3f", + "bytes": 9781 + }, + { + "path": "backend/src/routes/releaseEvidence.ts", + "sha256": "ab6299c47af36a14f718c921be39fc7b7995801639415cb63ec9e451093a8e90", + "bytes": 13393 + }, + { + "path": "backend/src/routes/reviewQueue.ts", + "sha256": "78b939da79ed6b05f5d2e98f8316257225fe2422437e2a29c9a046d05bf0e7ab", + "bytes": 21041 + }, + { + "path": "backend/src/routes/runtimePersistence.ts", + "sha256": "7924710c0d45335ced9748a36b1fb6e87107a0e2e3e88ef43bfe2a763695c535", + "bytes": 19007 + }, + { + "path": "backend/src/routes/snapshots.ts", + "sha256": "967a129c4046ea5b027f6bf2f1c16eab14d31c266fee05761d803aca9d6d48e9", + "bytes": 9405 + }, + { + "path": "backend/src/routes/systemReadiness.ts", + "sha256": "de47f75bb96e4bd490ed38e668cb5cd7365cbe9e26c49974c8aad1666bdd1f84", + "bytes": 10408 + }, + { + "path": "backend/src/security/authStore.ts", + "sha256": "97a641eeb013e8c46d8547deed48d2bd3be035a4bf58c2fc25aa47845fdfaf1a", + "bytes": 6524 + }, + { + "path": "backend/src/security/entraOidc.ts", + "sha256": "dc818a06abfe9940c83ef0eeae0cacfeedba8dddd464089a38db3ff5d99ce6e7", + "bytes": 8192 + }, + { + "path": "backend/src/security/mcpOAuth.ts", + "sha256": "fedb055043b8920b387a55f803735ede9d7f43a9518b7ee5418e9bbf09fcf4f7", + "bytes": 5522 + }, + { + "path": "backend/src/security/mcpServiceAuth.ts", + "sha256": "fc9ae9cd5137771bc54c712fbf24fddf7ab84d79c7501560670b603738a9cdb5", + "bytes": 697 + }, + { + "path": "backend/src/security/password.ts", + "sha256": "0d755a4b0e1b87368ecc71eeddebfba7e83a62409390a6d4ca1d92a16d235405", + "bytes": 979 + }, + { + "path": "backend/src/server.ts", + "sha256": "41c560b6a54235e7ce7251a255ca86e12ca95f619cacb68cad4c7dc96b44e9ca", + "bytes": 673 + }, + { + "path": "backend/src/services/activityRuntime.ts", + "sha256": "5ba4b0cb64392564dadc2b46a3d4724883bd6fe96cd9005c1e17b5763463ec34", + "bytes": 10117 + }, + { + "path": "backend/src/services/analysisStrategy.ts", + "sha256": "0bce581d6ba40437b47f0b82bc7d546aca356714ffda60985d57726c7eddd7bd", + "bytes": 2862 + }, + { + "path": "backend/src/services/attention.ts", + "sha256": "1efa1a6c74ee4a5dc59ac84fc136380c09d5ea4d54fb99f79b742ea466ce8c3c", + "bytes": 5838 + }, + { + "path": "backend/src/services/autonomyAssignments.ts", + "sha256": "76b329bbd9e6cdc72d7f81de59db2d011c5ca697f312068c1a40b9506eb629b3", + "bytes": 1501 + }, + { + "path": "backend/src/services/autonomyDelivery.ts", + "sha256": "694272e98f7da3d4d086be42c4602c4a7104f2e21d0cb38eacbc2c52d3e82ba7", + "bytes": 1967 + }, + { + "path": "backend/src/services/autonomyRecovery.ts", + "sha256": "d571be091f4a684c0f2bb41e0629faded94ba559cc9ff2265f1bd59f28261d17", + "bytes": 3039 + }, + { + "path": "backend/src/services/autonomyReplanner.ts", + "sha256": "488cff9cabbaa52698bf6ec86adb4975132abb14aa2efc5edfa6679b54d2e5d6", + "bytes": 5602 + }, + { + "path": "backend/src/services/autonomyRoadmap.ts", + "sha256": "a1da29c7f86aa42fa107024f131a973dd0ed1c545d9cbc65f161eb16b55d3177", + "bytes": 2970 + }, + { + "path": "backend/src/services/autonomyRuntime.ts", + "sha256": "9fe64c370569e5bcdd82de8a033278cb337b79d51ed7c199a62fd1a3accb1b36", + "bytes": 17147 + }, + { + "path": "backend/src/services/autonomyRuntimeControl.ts", + "sha256": "308f21e573a04aee85cbb7c2c4ac32541efe46736098524535d4508eafb3ed42", + "bytes": 8916 + }, + { + "path": "backend/src/services/autonomyScheduler.ts", + "sha256": "d347b998a1ea84a1207d9a45b3c654648bb3b7061ebfaf747694a61a5f83f8a3", + "bytes": 5338 + }, + { + "path": "backend/src/services/autonomyWork.ts", + "sha256": "ed1a7f2f38141f415e819102ce486074f8067339df0574b6d273b3ed481b0d9a", + "bytes": 4942 + }, + { + "path": "backend/src/services/blueprintEngine.ts", + "sha256": "01c13f266ac6f9eb77f5ce4d36550452e3c18bfc7e26322a3f108dfbd27d288e", + "bytes": 31480 + }, + { + "path": "backend/src/services/canonicalLifecycle.ts", + "sha256": "fbc0007d582edbffab4ca2af953706ed59fe2fe957046370ed4d8cb37594d0b6", + "bytes": 1119 + }, + { + "path": "backend/src/services/commandIntent.ts", + "sha256": "b15bdeb8bebe37e57b80d665a555c044de7ca85e50de0c66c291a571d9e0f4a2", + "bytes": 4854 + }, + { + "path": "backend/src/services/contextPackSelector.ts", + "sha256": "4be7b112d2f99d27b12052944842322cef9c12afe78a4a7973e6e8a9142269a1", + "bytes": 6434 + }, + { + "path": "backend/src/services/contextResolver.ts", + "sha256": "91a4478ef8803e81e012c38f1f182a86caf145bffddf79ce59bde0b34d41d8cd", + "bytes": 10700 + }, + { + "path": "backend/src/services/deliveryReconciliation.ts", + "sha256": "2525065d9e8d82b98699cf26818e3608ddbb5bb5ca3e41f639d7b7aff6a5d2b3", + "bytes": 3746 + }, + { + "path": "backend/src/services/diagnostics.ts", + "sha256": "c9c6e1b36f627d260d147aaaf67ce51441f0e48b96532e3198fe4e6bd15ab175", + "bytes": 22487 + }, + { + "path": "backend/src/services/evidenceGraph.ts", + "sha256": "6d5d344a5415759cfdd8b673a892fb666dfabbeba6914a79ec5966e4911498c0", + "bytes": 6192 + }, + { + "path": "backend/src/services/featureDeliveryFlow.ts", + "sha256": "307ce15a6c80eaae5f565dc26cd515f6095ada1a918748f799a522f9153fdb19", + "bytes": 3356 + }, + { + "path": "backend/src/services/gitSourceSync.ts", + "sha256": "5efb2693c03620a4c4db290ddb5fa5d74cbe166c6d0d8cdfb88ecc5d78088f02", + "bytes": 6352 + }, + { + "path": "backend/src/services/giteaAutonomyExecution.ts", + "sha256": "9b2dc09f91bc450b18840feda198dfe4dec6a1d198975e7b072e8dbc1cc142ed", + "bytes": 12640 + }, + { + "path": "backend/src/services/portfolioControlPlane.ts", + "sha256": "1229871fea6b637652725a2a13b5a7a2363284d1cd3795c2e0b889dcf5fff884", + "bytes": 37870 + }, + { + "path": "backend/src/services/projectCompletion.ts", + "sha256": "e2ec7d03c697d99179908f47c0153c1ae98594c40df323fbc464dfa91e77e1ac", + "bytes": 14172 + }, + { + "path": "backend/src/services/projectHealthSemantics.ts", + "sha256": "e24d0fddb3e1f39640448f28ff65b52ab76173dca3a40acae833bd4b7de51b2c", + "bytes": 1476 + }, + { + "path": "backend/src/services/projectKnowledge.ts", + "sha256": "3efc160a95eaa52e0317aa2119fb3632e37b0e4b71740235aecd773e2f2c06bf", + "bytes": 16369 + }, + { + "path": "backend/src/services/projectRepositoryProvisioning.ts", + "sha256": "abb980c3e8e8529d1d6f664ec20da3765aa9ce8c5366e0448e030e537ba29e6b", + "bytes": 11228 + }, + { + "path": "backend/src/services/pulse.ts", + "sha256": "1c070d043070e9e1bc91628011bdeb8b70ce86b61baa0bd9630f42e329017608", + "bytes": 1249 + }, + { + "path": "backend/src/services/recoveryPolicy.ts", + "sha256": "0a1e90e2949b91a27b72991ef6124b5012051e405ecf0c1322db40707a2f08f8", + "bytes": 1727 + }, + { + "path": "backend/src/services/repositoryAnalysis.ts", + "sha256": "b3554486f1b28f114086f18cd91fbfe4ce8753fb38e50ec66f678b74d7030983", + "bytes": 29237 + }, + { + "path": "backend/src/services/repositoryAutonomy.ts", + "sha256": "f7850ab33bf1735397671271d99d0d2d6dd67cb71ab3434378bd61e43834a352", + "bytes": 14958 + }, + { + "path": "backend/src/services/workGraph.ts", + "sha256": "081e18915f94e404ae688a992d986d19b271a5815fd8ee22deabb66b9290cbe8", + "bytes": 41038 + }, + { + "path": "backend/src/types/api.ts", + "sha256": "ff9c79dca933ac9c9acc837acec8621649c976d4172212cdcecabdcf618630d2", + "bytes": 35331 + }, + { + "path": "backend/src/utils/envelope.ts", + "sha256": "47f7537743ea0644da729b953ec8dcd377c45e5a90ae4ba102ed96039a0d6e34", + "bytes": 686 + }, + { + "path": "backend/src/utils/idempotency.ts", + "sha256": "64b09f3eae3c7848abacdf4eafc5dbc88b07e175dcd65a3b1c1fab965fce70f6", + "bytes": 2227 + }, + { + "path": "backend/src/workers/analysisWorker.ts", + "sha256": "97d921764da84f7ec25c3bdf0acac25f3346ed940746ddd56dabb508f205b184", + "bytes": 7733 + }, + { + "path": "backend/test/activity-runtime.test.ts", + "sha256": "514b2c19069dc219c87af15d227ccfbbbc024226c5772e44d9d39181eae5fa05", + "bytes": 7838 + }, + { + "path": "backend/test/analysis-job-capacity.test.ts", + "sha256": "f0ce9efa898fe666d87ceb460698678112d39702ee2aab5b1906157ebbbdef73", + "bytes": 4884 + }, + { + "path": "backend/test/analysisStrategy.test.ts", + "sha256": "f1a23b96952636542dc52cf1ed38bd53751197f86a4618db2a7455665315c2f8", + "bytes": 1360 + }, + { + "path": "backend/test/authentication.test.ts", + "sha256": "d94edcc4458a94601da92c754bff8e35b125a429c45ea68126edccbab864d0c4", + "bytes": 12124 + }, + { + "path": "backend/test/autonomy.test.ts", + "sha256": "c37274b9912470b84145acd9d58ce3485da5784c7cd79a6d8c9a65747eba6501", + "bytes": 19280 + }, + { + "path": "backend/test/autonomyRecovery.test.ts", + "sha256": "a5dfdb10693d226c55ef7b3dd7787d43ef24a970b59e5e4003b30f43ab27df68", + "bytes": 2669 + }, + { + "path": "backend/test/autonomyReplanner.test.ts", + "sha256": "7b51b39a1f416bfa48233cab5746c512865104f01a466561bbfc0db77806a153", + "bytes": 2758 + }, + { + "path": "backend/test/autonomyScheduler.test.ts", + "sha256": "4edfcba3f357ad49870995c591b008b421d5aab5a6689797302253b930c7d2d6", + "bytes": 2285 + }, + { + "path": "backend/test/autonomyWork.test.ts", + "sha256": "f07247870ba55e5f90c14dea83c08bb13b6576c89e20a5aa548ded914a014edf", + "bytes": 2542 + }, + { + "path": "backend/test/canonicalLifecycle.test.ts", + "sha256": "225e43e531229352e84bc46a5bfa775147c2c3ac3ec3aad6591b1b5f84683781", + "bytes": 1137 + }, + { + "path": "backend/test/database-authentication.test.ts", + "sha256": "1d5651407b1b6f48d552835995d076aeb1709d997f576719e19b704369037420", + "bytes": 21504 + }, + { + "path": "backend/test/database-autonomy.test.ts", + "sha256": "6d12b8b4cc278cef1a186ee0e4e4cb4685ce82c33af9cae9349d54868e7fec55", + "bytes": 14589 + }, + { + "path": "backend/test/database-repository-sync.test.ts", + "sha256": "3ca14be6d6389ee72f260a264f6e6fb7d1fb3ae8c9b8ebe850c89f2e855ddfd7", + "bytes": 9017 + }, + { + "path": "backend/test/delivery-pullrequest-reconciliation.test.ts", + "sha256": "d166a2dbfc1f3512740b6353ff748f3507e421dda8fdf035e53de41212d767e4", + "bytes": 1765 + }, + { + "path": "backend/test/deliveryLifecycle.test.ts", + "sha256": "521821629da247291037145f19546ee5f7dd4cc422a344e10e8e35567e2f2738", + "bytes": 1280 + }, + { + "path": "backend/test/entra-oidc.test.ts", + "sha256": "b0337033a0716b10f5928c560c4518baa806508c1aba4e25da197af445d760b6", + "bytes": 7610 + }, + { + "path": "backend/test/feature-delivery-policy.test.ts", + "sha256": "25fafa92886dd50c2a452452a78e2825082e2871249222deed75e8165cf5d78a", + "bytes": 9160 + }, + { + "path": "backend/test/featureDeliveryFlow.test.ts", + "sha256": "b1640233574667f19eba30a4196d3dc1fdc53995ab0d310ba602b58c3307efbc", + "bytes": 1640 + }, + { + "path": "backend/test/finding-reconciliation.test.ts", + "sha256": "e2b2e1edf9876441a2f957a1afb266ce6ea5c01c8d44b192a28b41d4795dc3c7", + "bytes": 13143 + }, + { + "path": "backend/test/gitea-autonomy-execution.test.ts", + "sha256": "f190c40c5cbebfe78b4705acd687b7a9429d90ca39ac328068c6ce47566adb96", + "bytes": 1127 + }, + { + "path": "backend/test/gitea-integration.test.ts", + "sha256": "0102bce2bd523d082b26ae20934b85b396efb81b1a41961f0ccf0cb53f2d7ef2", + "bytes": 35212 + }, + { + "path": "backend/test/gitea-portfolio-mcp.test.ts", + "sha256": "565e7d253cc946febdca49780c0b2850d9cd2ce5c8647038112b55169d095a47", + "bytes": 3370 + }, + { + "path": "backend/test/mcp-efficiency.test.ts", + "sha256": "10cdb42494cd2b81e383ab542763cbcc196d01b7014e7bd0a0a0d918dd520139", + "bytes": 2242 + }, + { + "path": "backend/test/mcp-skills.test.ts", + "sha256": "82b543592e4a16b836298af017d71d5c045a7ec6ca1d0b6cae2bc2c26814200d", + "bytes": 3107 + }, + { + "path": "backend/test/native-ci-discovery.test.ts", + "sha256": "3b193c88110d7d9b2328920edd0211c16803de901cb81f28d921db78d2fbb42f", + "bytes": 4873 + }, + { + "path": "backend/test/operatingSystemServices.test.ts", + "sha256": "e4dc1f45d052d31aae6796c37ce1fa580597c2ff9bd5a0a82f58934dccb2b4ec", + "bytes": 5016 + }, + { + "path": "backend/test/portfolioControlPlane.test.ts", + "sha256": "0cc684fe52006b0b1608c90bc07807f30a3743a465013d26d192eda2c1748a7b", + "bytes": 4514 + }, + { + "path": "backend/test/project-create-route.test.ts", + "sha256": "f52e54c15a9b73353438fd56667be567461c7b0cf25b8f5de0c067cf51e62c8c", + "bytes": 2867 + }, + { + "path": "backend/test/project-setup-state.test.ts", + "sha256": "71d992919103881fd04cc4463031521549c9d5c604a8a7658fe6b5bc627c0fcd", + "bytes": 1659 + }, + { + "path": "backend/test/projectHealthSemantics.test.ts", + "sha256": "8360581f2edb51cad572f6dd8b61e5a05decc56019306dffa36a3d51a0915a44", + "bytes": 2387 + }, + { + "path": "backend/test/projectKnowledge.test.ts", + "sha256": "acdab04ba38f97ea9fd370d974089664df66c05712e1855d82ce4f431c239439", + "bytes": 1300 + }, + { + "path": "backend/test/recoveryPolicy.test.ts", + "sha256": "2d8a63ffd4c5ca481e55ba1a2032d0e6ff86aaf5195a695c5e1a53df61742dfc", + "bytes": 2172 + }, + { + "path": "backend/test/remediation-regressions.test.ts", + "sha256": "9189c57b5f7dac2a534b9da1c3d64f9ea172e59a828d400874c1acc64e6305ba", + "bytes": 5236 + }, + { + "path": "backend/test/repository-analysis.test.ts", + "sha256": "665f66aa55c82875a9576ceb55851d26c4cfc5002b486278b16bcd0d96d08236", + "bytes": 28243 + }, + { + "path": "backend/test/repository-autonomy.test.ts", + "sha256": "5ebf26082345a59f2fd97a97487d97ebb3deb47b86c4d8a5cab4c60e481fa34c", + "bytes": 7396 + }, + { + "path": "backend/test/repository-ownership.test.ts", + "sha256": "41893c31de60353de10bee14c3974f93b80610beb1c1c27d69eebbc2336b236d", + "bytes": 1826 + }, + { + "path": "backend/test/review-attention-policy.test.ts", + "sha256": "ea6516877d181f788b109c47166420e95dce8b478b3e69e4b452ce49f96354d8", + "bytes": 1146 + }, + { + "path": "backend/test/review-capture-state-machine.test.ts", + "sha256": "55d5478b8896121fc90e5766977b3432fbf0d44d8bbbca120b21efadaf1df7af", + "bytes": 4495 + }, + { + "path": "backend/test/system-readiness.test.ts", + "sha256": "15bfd2d8434d3ec9b1e4cf34256a2fecd3304ebe1865583751612e89d449557b", + "bytes": 4921 + }, + { + "path": "backend/tsconfig.json", + "sha256": "af9e8f413eaf289350f3b5c63c5713e158d3611f0df43f8f8cb281e79134485b", + "bytes": 388 + }, + { + "path": "backend/vitest.config.ts", + "sha256": "abad5c68e95d2181f87677dbddf79d723362cc8e1ca7f0175ffaa1e98e28fe65", + "bytes": 418 + }, + { + "path": "components.json", + "sha256": "9820835141c54e5b5dfbb68b3c3790db73c9ebfb967884ae3fccd058d698a1a6", + "bytes": 414 + }, + { + "path": "config/architecture-budgets.json", + "sha256": "fa12bb9ec2da8e7a0e14939e75c2ad5044d410fee97cc2b2fcf5134956e51249", + "bytes": 3046 + }, + { + "path": "config/v1-contract-freeze.json", + "sha256": "06042e9532ce434647d329eb80bc71f024b38e0d1c6ad55007aabb46d40e96da", + "bytes": 1577 + }, + { + "path": "docker-compose.dev.yml", + "sha256": "4c04c009d0126a6e21c425d2d5d7c1dde618413196d360ade18f082f1153cbbb", + "bytes": 503 + }, + { + "path": "docker-compose.release.yml", + "sha256": "5b8eed726c60ee99bf51bd71e44f239ad0b009992c1b413fa9ae92ac865eaa0c", + "bytes": 683 + }, + { + "path": "docker-compose.yml", + "sha256": "40498a29e0553c1bef0095c3994df6e5d7b203e5c1eff81d76b506c32a25e521", + "bytes": 6539 + }, + { + "path": "docker/nginx.conf", + "sha256": "5fde04e21bc7bca4b29113eba83bc462a419339be83fefd603f77499d6f87cfb", + "bytes": 2202 + }, + { + "path": "docker/nginx.single.conf", + "sha256": "3d6129b4f27e8ff275720b11847eb0dadd33adbaa53d20102433f04dc2c19900", + "bytes": 2208 + }, + { + "path": "docker/unraid/projectbrain-container.env.example", + "sha256": "feac8be05ed029f276e11b6c162aac28db603659d6d7d08c4f9d315526975104", + "bytes": 2317 + }, + { + "path": "docker/unraid/projectbrain-icon.png", + "sha256": "83bcd21e2f2aeb09cb2543d3812a84ad9bd841168cfed7b9b937c25e08231b8e", + "bytes": 29208 + }, + { + "path": "docker/unraid/projectbrain.env.example", + "sha256": "5533af5693c27c18859ceb13f91ed5de1809911065fff17e266c5923a70211ee", + "bytes": 1350 + }, + { + "path": "docs/LICENSING.md", + "sha256": "adbef716c0979f89c3ba25929b5af77cd309b6f31464be1d4f707ca627bca552", + "bytes": 523 + }, + { + "path": "docs/MICROSOFT_ENTRA_LOGIN.md", + "sha256": "a812ed1b605d36764fec1b4c820bbebeef983383cf3bd17b660ce942a7971873", + "bytes": 2159 + }, + { + "path": "docs/REAL_BACKEND_ARCHITECTURE_DECISION.md", + "sha256": "fabf8a61f618d77c1a155c21354033134945dd7eb7e6b5dc805e8516ef9d5b76", + "bytes": 13431 + }, + { + "path": "docs/UX_AUTONOMY_PRODUCT_CONTRACT.md", + "sha256": "38e1ce982bb2980a31f596b07e09ecea97ae8094fbaa7d963e40708cf71b9789", + "bytes": 27052 + }, + { + "path": "docs/screenshots/mock-portfolio.png", + "sha256": "170495cdeeb8d4ef9a5ebc4a056d28c017c4b9859729f9915768f90d43dbf94e", + "bytes": 91067 + }, + { + "path": "docs/screenshots/mock-project-overview.png", + "sha256": "692812ce911adafea6b441b853a0b2f700ed0d51babda8a4c83fb8042d237887", + "bytes": 123854 + }, + { + "path": "e2e/attention-auto-mode.spec.ts", + "sha256": "aefee916bd9e33fa0c80ecb2ab80fa2a02494d6f516c1e8d20697cec40199436", + "bytes": 4867 + }, + { + "path": "e2e/core-journeys.spec.ts", + "sha256": "def78c33854dc5a702f46d95373a4b608b5f6eb7e293e9fdf57bc0c2002fbff4", + "bytes": 16571 + }, + { + "path": "e2e/platform-state.spec.ts", + "sha256": "9ff3d173e88c8a22e2e9737bb97f1cf888ae84ee90d36d03e3f722f25db53972", + "bytes": 5689 + }, + { + "path": "e2e/project-list-view.spec.ts", + "sha256": "be3edad9351285e2e15711cc0db2d49e6f077a650c30f713e58a005c13e88ef7", + "bytes": 1032 + }, + { + "path": "e2e/task-queue.spec.ts", + "sha256": "c5574c93732f6d666e109baa951cc06ec21f8b239211924dabecff15c522964f", + "bytes": 1668 + }, + { + "path": "eslint.config.js", + "sha256": "b9cfe6270a1e97d5992f8c7178c8a4765ce62446c42822d6c886081905167a97", + "bytes": 1837 + }, + { + "path": "index.html", + "sha256": "230c879d5b9ca5e21d21cee2c59fe20c25652a291e38335495a4d26a9511d555", + "bytes": 1491 + }, + { + "path": "package.json", + "sha256": "3009c831e01139490eae648911a278d8fefee14ddf1530b53dbb558eb1852e21", + "bytes": 22914 + }, + { + "path": "packages/contracts/index.d.ts", + "sha256": "a41e4e2ea6eec5187e00dc5978e3b1ab83233156154f9f613f06f9510ebf1c7f", + "bytes": 6172 + }, + { + "path": "packages/contracts/index.js", + "sha256": "94076de904edfb58ae0457c1da536c9b75e71a08239ef318b0ed78f900803594", + "bytes": 19319 + }, + { + "path": "packages/contracts/package.json", + "sha256": "c8f9aa75462bc84173d1292b99ce6f5a39719eb9739c7a73cd839f995957a85e", + "bytes": 311 + }, + { + "path": "playwright.config.ts", + "sha256": "8138eac228153c6af30986cb1dcc1cb216569c25492b185b0350b41ba18cee30", + "bytes": 1018 + }, + { + "path": "pnpm-lock.yaml", + "sha256": "79f49f47a573df14b0d6b4a443279f4b3a6ebf7bfbd0655e239953bc76e1a438", + "bytes": 237449 + }, + { + "path": "pnpm-workspace.yaml", + "sha256": "879bdcde52760783bdcd2d2cadd086468eb6e067b42aadc0eee1fc37f6514ed7", + "bytes": 682 + }, + { + "path": "postcss.config.js", + "sha256": "e32657baf631d7c5f4dc67b4b2ee0ec8e7d5b3c41860e09cddce7c0377cd80bc", + "bytes": 81 + }, + { + "path": "public/apple-touch-icon.png", + "sha256": "10888a6e9cd384764eb4be9c8c15f6541fd53aa427c1dcbd3cc8f087c7efe03b", + "bytes": 56930 + }, + { + "path": "public/favicon.ico", + "sha256": "ed7b363e30a367d4fd6531056f62e51472efeae2b3750bed911be277e0c9cb34", + "bytes": 147037 + }, + { + "path": "public/favicon.svg", + "sha256": "92eaaee90fc3c6f82d406d3f391860618733332df5e4cfbeff41514a6533b250", + "bytes": 1444 + }, + { + "path": "public/icon-180.png", + "sha256": "10888a6e9cd384764eb4be9c8c15f6541fd53aa427c1dcbd3cc8f087c7efe03b", + "bytes": 56930 + }, + { + "path": "public/icon-192.png", + "sha256": "3d8e250e97455119723b27f3370850168674813cda66b2ec65629ca4f4cda364", + "bytes": 63317 + }, + { + "path": "public/icon-512.png", + "sha256": "637a93298c843b584ce1cb4eb71f950163fb010b93b24111a428c188d740af5f", + "bytes": 263344 + }, + { + "path": "public/placeholder.svg", + "sha256": "64badf7aabda0b9630b87020ffb6095cb858ccbcf66b355c2aa08b1063954d3b", + "bytes": 3253 + }, + { + "path": "public/robots.txt", + "sha256": "a3d27b3ca5ce32341fa9f96f01169b521b1657c05d1860c45d1e53a6258e9e8b", + "bytes": 160 + }, + { + "path": "public/site.webmanifest", + "sha256": "e09642559adea9f53dcf9ce031a9c3896b49c0de78cc0a8700aec4e1849b7ae5", + "bytes": 596 + }, + { + "path": "scripts/check-architecture-budget.mjs", + "sha256": "74c9450bbec9973388611ef9bc2593e77920639b5079d60a9b0f34352f0dfde1", + "bytes": 4840 + }, + { + "path": "scripts/check-browser-bridge-readiness.mjs", + "sha256": "cee2207eb4ab7b5a2e5b0b78d0f60861971ed58623d10d08d61c513aa84c6bc2", + "bytes": 5049 + }, + { + "path": "scripts/check-build-loop-readiness.mjs", + "sha256": "0c8653c32a102702130e182eaf24c1a4f35e914e495e76e5cef8b748b2f14a4a", + "bytes": 7313 + }, + { + "path": "scripts/check-build-loop-release-consolidation.mjs", + "sha256": "ea203db4076ed09804cc41ceb471189da5c64d881ea8dbef313474de7afb86e2", + "bytes": 8336 + }, + { + "path": "scripts/check-build-loop-walkthrough-readiness.mjs", + "sha256": "cbf689ffec6ea53e81df49722d41dc13994e5393da78776722b15315cedf3a14", + "bytes": 6297 + }, + { + "path": "scripts/check-codex-handoff-readiness.mjs", + "sha256": "3cddcce642877e350688745af241e808abca6a82c7159470c6f7fccba5ce7c2b", + "bytes": 9924 + }, + { + "path": "scripts/check-database-readiness.mjs", + "sha256": "4fedd07c4818dce28d1eecb7b3dc066b9fe369902aa5641eb8df2a07d76d87cb", + "bytes": 13396 + }, + { + "path": "scripts/check-doc-links.mjs", + "sha256": "9f3b2957ad08bf67eed5ca797ec2004f804f0e5903d9cc24880c0eec801b7075", + "bytes": 1444 + }, + { + "path": "scripts/check-docker-build-readiness.mjs", + "sha256": "1e7d14633dab94d75cc2b7a13f171953334139f468ab9b6b92154d05e600f11b", + "bytes": 8854 + }, + { + "path": "scripts/check-execution-center-readiness.mjs", + "sha256": "30eecf3420b99a75d3abff7a358852df09d0d969fb69bcf5fae85f2a450a8835", + "bytes": 4746 + }, + { + "path": "scripts/check-execution-feedback-readiness.mjs", + "sha256": "1432a2681a8ec4b189348259b72f429af87758fdcadc082aa03c669474f2e97a", + "bytes": 8908 + }, + { + "path": "scripts/check-frontend-runtime-readiness.mjs", + "sha256": "ffeccbe9d3fdf0582c05dd7396818d227fdf052373957109145833ffc1fd4534", + "bytes": 5279 + }, + { + "path": "scripts/check-guided-start-readiness.mjs", + "sha256": "3dc036fa7ccffebd0bcb2eade8667439e8207383998313660374b52e92adae05", + "bytes": 9443 + }, + { + "path": "scripts/check-handoff-review-readiness.mjs", + "sha256": "657dbf6dc2769e9a8c0b3550c19996c63f11a70685a85ee648b0e96d07774db4", + "bytes": 7107 + }, + { + "path": "scripts/check-history-center-readiness.mjs", + "sha256": "a9b221bd8c8a37a65dc6908c6fde68c5b165f812023371f4e46572332a46bcdb", + "bytes": 3128 + }, + { + "path": "scripts/check-release-hygiene.mjs", + "sha256": "4526b48eab3ee43b9c799d2cddbae1714ef28a45221313978a44218c57abd7cd", + "bytes": 5425 + }, + { + "path": "scripts/check-repo-builder-readiness.mjs", + "sha256": "35bb5085a056a1ec4d1be3eaf11aada88819dab4cdba0a6b9b2eb82fdff275d3", + "bytes": 12199 + }, + { + "path": "scripts/check-repository-intake-readiness.mjs", + "sha256": "64bcc6b8adc8209f42690a6f16844a14c19fd9ff68125552d004f1ec5bbda4ab", + "bytes": 7460 + }, + { + "path": "scripts/check-review-closure-readiness.mjs", + "sha256": "65ce804dec2c49e2692e5a3564b89280beebb8f3967b22701ec659db3044603f", + "bytes": 5088 + }, + { + "path": "scripts/check-runtime-readiness.mjs", + "sha256": "ee78cd33e509f0392e7e6c8d823157d53facdf15d7fe5057de32d7b81bac05a0", + "bytes": 23501 + }, + { + "path": "scripts/check-session-pack-export-readiness.mjs", + "sha256": "0aaf4570c4cbda4c54b017f62c82a80fda08d01b3bda7aeed7e52218dc195143", + "bytes": 8065 + }, + { + "path": "scripts/check-session-pack-readiness.mjs", + "sha256": "d97e2bdd31bef7be907efca1a4ee64065be2e9b9498a21e319c786cb976a5a7f", + "bytes": 8664 + }, + { + "path": "scripts/check-skill-library-readiness.mjs", + "sha256": "852e25909dfd6f32e08a4acc6e6ecdc73f6a077bb8a50de3377a4fc28d0b2b16", + "bytes": 6710 + }, + { + "path": "scripts/check-source-library-readiness.mjs", + "sha256": "6dc7776935086f289fb6e7dd949664aba41c7313a614ae57616e50b618b03156", + "bytes": 3003 + }, + { + "path": "scripts/check-typescript-gate.mjs", + "sha256": "1ece1b75dad042f1855efb57278cf60debe888ccf08a73681381c0ca323c848b", + "bytes": 894 + }, + { + "path": "scripts/check-unraid-template-readiness.mjs", + "sha256": "7719d93bc5a948823403f81050396b897a06d0faa44cbc68adb4b8e465e5056a", + "bytes": 13415 + }, + { + "path": "scripts/check-v1-contract-freeze.mjs", + "sha256": "207ffe74bc050c367fc66dcc067c840f33345893a573f5ea4e4ed09653475cc6", + "bytes": 1613 + }, + { + "path": "scripts/check-visual-flow-readiness.mjs", + "sha256": "36fb349dc957b9a908a887b1812f89cbee4eec5c31f17f6753ae3c25d16a3592", + "bytes": 11165 + }, + { + "path": "scripts/check-visual-polish.mjs", + "sha256": "953817b502d00664e1f2ac48f16dd083310e1ccc46e4245d7781b4b8a6d21a73", + "bytes": 11083 + }, + { + "path": "scripts/deployment-proof-state.mjs", + "sha256": "78c027e5c0267ebce10f6cffb1c983b7c3ae8ea3648c6c9a0fbbf75d06dc6763", + "bytes": 759 + }, + { + "path": "scripts/deployment-proof-state.test.mjs", + "sha256": "accfd81ebbd1622d33fc83a34c8fb9439d856c9da89ac6661ecd693b8ade8ad9", + "bytes": 1515 + }, + { + "path": "scripts/generate-openapi.mjs", + "sha256": "b5db80188098c3323f19ed075fb8857318249a569379b110a5e86182f9e9ca05", + "bytes": 872 + }, + { + "path": "scripts/generate-release-metadata.mjs", + "sha256": "6547c0479f3550dd4de6fa81d686ee7f8f0469bfae43f2ce13ba88ded9a3b63a", + "bytes": 3942 + }, + { + "path": "scripts/generate-validation-matrix.mjs", + "sha256": "ed59219421ae09334b5665f465ebb5ca326787e8377bc63d9ba5909c9f8616a4", + "bytes": 4478 + }, + { + "path": "scripts/legacy-deployment-migration/.gitignore", + "sha256": "32dae3052f331ee34d628ef535709b301259a45df7c7522c4d35dcf49873f00b", + "bytes": 13 + }, + { + "path": "scripts/legacy-deployment-migration/README.md", + "sha256": "0dd92b3fe2bfc8b46d12146988603253506aaffca65eb0538f17ca97151d9ab8", + "bytes": 13641 + }, + { + "path": "scripts/legacy-deployment-migration/execute.py", + "sha256": "2ef66e528f775063039d85c61172c5aef5d9444dab741d147abd33789d6ca9a8", + "bytes": 11628 + }, + { + "path": "scripts/legacy-deployment-migration/plan.py", + "sha256": "e742adbf7ee44f9c58b15ac2177458e31738aa20052428eaa373fac76b7d4298", + "bytes": 4830 + }, + { + "path": "scripts/legacy-deployment-migration/storage.py", + "sha256": "5e24816848c1896bf2af90ed1a11781b7124d19776ab416c49bb70f53837fce1", + "bytes": 3917 + }, + { + "path": "scripts/legacy-deployment-migration/test_compose_contracts.py", + "sha256": "6e7927be5048fce2e811475cac6cf4ea0cb5348af759a79dac16d34bc337975b", + "bytes": 4250 + }, + { + "path": "scripts/legacy-deployment-migration/test_execute.py", + "sha256": "d32d50022234f40dd44d1fa7c3986561d7421180890b118ff80b6037439e8352", + "bytes": 1977 + }, + { + "path": "scripts/legacy-deployment-migration/test_plan.py", + "sha256": "e08c1edc475481077f4ed0c90485484775cb11ded2a42b0fff03a0a69e2b741b", + "bytes": 2800 + }, + { + "path": "scripts/legacy-deployment-migration/test_storage.py", + "sha256": "7bfd63323a20b06f459df85cc3e007b51f972f99c0523085a51152ca42af9082", + "bytes": 2725 + }, + { + "path": "scripts/legacy-deployment-migration/test_transaction.py", + "sha256": "b604ffbd57ed51301c2f5b9b7aa24a4f47f85fe9f884adc7835819f8cdde6ee7", + "bytes": 14106 + }, + { + "path": "scripts/legacy-deployment-migration/transaction.py", + "sha256": "c48c7d7ca91f724423c3c1775934027be21a46d776a08ed879c6e6d0c45589fb", + "bytes": 30687 + }, + { + "path": "scripts/projectbrain-csi-audit.mjs", + "sha256": "4a475e634afb2238fa97f327ee3b4156e4e054025330d58669c4fc3f5d97d8eb", + "bytes": 6780 + }, + { + "path": "scripts/projectbrain-doctor.mjs", + "sha256": "0d288367c33d434db15498aecb498af0cba288c0f1b270c8181af32148b35220", + "bytes": 6908 + }, + { + "path": "scripts/release-command-bundle.mjs", + "sha256": "380ba23947d9bdc2a8d21e2fda313e1ffe2471442f4e76006118405012095e52", + "bytes": 3185 + }, + { + "path": "scripts/release-deployment-proof.mjs", + "sha256": "0f63489cfac3b6e4c38042cf59a28f5e078982405559cbb850bc18ae0eb05698", + "bytes": 5127 + }, + { + "path": "scripts/release-evidence-dossier.mjs", + "sha256": "029d8f13620e34ff8c3ac3cae0a93d0c17d5822f6fc4941193b604d4683869c4", + "bytes": 5275 + }, + { + "path": "scripts/release-evidence-retention.mjs", + "sha256": "cc19a70a99a3d3eda6739614c90d400ed8bfa45d9040d98b2f20d3a4d15fbaf8", + "bytes": 5187 + }, + { + "path": "scripts/release-operator-proof-report.mjs", + "sha256": "7a5e145587ca910b1d309c67c839ff194e82f75ae10ab4ebac102a84c8e08d42", + "bytes": 6063 + }, + { + "path": "scripts/release-proof-freshness.mjs", + "sha256": "3120bb99fd3046098f5c8d5cfcec08745af5809e1747da414f6fe022deeba699", + "bytes": 3069 + }, + { + "path": "scripts/release-proof-index.mjs", + "sha256": "f68d59a9ec295d751ac4d8b4b282ab8d74bf00635a4a9c051b4cb216dc250454", + "bytes": 2611 + }, + { + "path": "scripts/release-rollback-drill.mjs", + "sha256": "a18fc9819514ac0c9c63ecd6716f8696a6fd7ea6288839273ec7e30af5eedacd", + "bytes": 8021 + }, + { + "path": "scripts/release-signoff-file.mjs", + "sha256": "b944dd855b14102be11165b270f1fda196dee7d055b26af256dbeb2393b67fba", + "bytes": 3970 + }, + { + "path": "scripts/release-tag-ready-gate.mjs", + "sha256": "4fd3e7180ed4857e5d8bb5443ff082bd5a2045878c9131741702116deb0b7891", + "bytes": 8726 + }, + { + "path": "scripts/release-user-notes.mjs", + "sha256": "7b9d0285311419fa81fa7b5b6f752f4f7022ccde85f1cc98a7fde98786c28f79", + "bytes": 5740 + }, + { + "path": "scripts/smoke-release-operator.mjs", + "sha256": "5dda61d17fa866a922db4606fd253a3c1d2fb8318ef72708caa3e1be80f9d526", + "bytes": 1533 + }, + { + "path": "scripts/stage-release-oci.sh", + "sha256": "db2ce7a2a04acc2a210014a39ab008a2da47cf0ffcfdca722b02224b1cd014dd", + "bytes": 4013 + }, + { + "path": "scripts/unraid-single-entrypoint.sh", + "sha256": "7a724838fa3b2f7550ed9c203d05513107184f236f6eea2d3e527f09adbfb824", + "bytes": 3928 + }, + { + "path": "scripts/verify-release-metadata.mjs", + "sha256": "e1f06193defe11ddcc52691b154c94af7f35e164278340252f1e53831f4d91eb", + "bytes": 1504 + }, + { + "path": "src/App.tsx", + "sha256": "860f30e1a4a1c0c324eac27edb403d0e669395c32b923df044ea62fd65e1ff73", + "bytes": 1247 + }, + { + "path": "src/app/routes.tsx", + "sha256": "8811bb2eeb0a43eba7498aaa392af2a02a00900f0a32cc45b29cbfb191c10752", + "bytes": 18461 + }, + { + "path": "src/app/scrollReset.test.ts", + "sha256": "48f0b4e342a98b22a160e47694a2a9afb127151ddeb420a0469fd2b67a042e49", + "bytes": 1100 + }, + { + "path": "src/app/scrollReset.ts", + "sha256": "96a8026a578b9ac8f63e5adfae479e3cc701ec8ca167a1964da94c75ece05f33", + "bytes": 492 + }, + { + "path": "src/components/layout/AppLayout.tsx", + "sha256": "1131ab57fe0fe6a08b6b528b271404300bff77b7200b296cc23d16aaa527d55f", + "bytes": 1131 + }, + { + "path": "src/components/layout/CommandMenu.tsx", + "sha256": "ad7d6c0712e3e738c3d6a1feef4f36c831071f8bcd02f05ba2adda723eb5b336", + "bytes": 9999 + }, + { + "path": "src/components/layout/Header.tsx", + "sha256": "314be975eef18f06ee10537759b5c7ce799cfbeef46f63cd57389ee1240734cd", + "bytes": 21083 + }, + { + "path": "src/components/layout/Sidebar.tsx", + "sha256": "6d4bee86a250c43a7e3a569e1052a3947b45f1b740095c0a81c67bfcc7fa9143", + "bytes": 12254 + }, + { + "path": "src/components/layout/commandMenuSearch.test.ts", + "sha256": "1a2063f6a610357ee7dd2e6e92286b7008bd5403424ef1c0e2c746e654f9f1f9", + "bytes": 2886 + }, + { + "path": "src/components/layout/commandMenuSearch.ts", + "sha256": "a52419be2d5efaf7524ec60e892d9312b7719cbaa92becea71435f6e357e67d8", + "bytes": 4437 + }, + { + "path": "src/components/made-with-dyad.tsx", + "sha256": "d7a6f9f65ee1fc6bbf65e2a51cbc8386cf0b90d61b59ac7999d4a91b89f80795", + "bytes": 297 + }, + { + "path": "src/components/theme-provider.tsx", + "sha256": "f3fbe682745ac8a0b2424dac45800b9a366d573cb21e97f8e21e913165344afe", + "bytes": 291 + }, + { + "path": "src/components/ui/ConfidenceBadge.tsx", + "sha256": "66b15d10c152a3b28b1cd66a0c14888f0bbc4ab6183f66ba6c022e9e5607459e", + "bytes": 804 + }, + { + "path": "src/components/ui/EmptyState.tsx", + "sha256": "d6fa0abfb842f4e2b3f285ba12a289dcbd767f26e43d94f678eec78735dd2efc", + "bytes": 1758 + }, + { + "path": "src/components/ui/EvidenceList.tsx", + "sha256": "9c70826bc18b570301f9757cd8c6f43e7e15fc7d31bdfd46e4777e0240fa3084", + "bytes": 1394 + }, + { + "path": "src/components/ui/PageHeader.tsx", + "sha256": "537300e7a06c274b9c0b7664d2ca229a83b2e8a5990a76c3f1b184be53bab98c", + "bytes": 738 + }, + { + "path": "src/components/ui/StatusBadge.tsx", + "sha256": "edb0a63a2f70e207d363624385179ea084c4a979a41cf678bb359b4eecd951a2", + "bytes": 696 + }, + { + "path": "src/components/ui/accordion.tsx", + "sha256": "b728b30af5d9f770a34d5b4ae85ea720c90664fa55d83ad3d9835f48f20f5846", + "bytes": 1990 + }, + { + "path": "src/components/ui/alert-dialog.tsx", + "sha256": "266cde2db55e39257a0154c42e81311a441242756967664382e55af6775120d6", + "bytes": 4525 + }, + { + "path": "src/components/ui/alert.tsx", + "sha256": "050c08a3ec850a8fae8579b2bdbb4c9b8aa64744b580f07e2b26f7e6fc17dac0", + "bytes": 1596 + }, + { + "path": "src/components/ui/aspect-ratio.tsx", + "sha256": "d72c73d629e37995a0a71647d851d68d6fdcd7ec120797f36ab83a8ee89f2cab", + "bytes": 143 + }, + { + "path": "src/components/ui/avatar.tsx", + "sha256": "3f1e0c4584f57768a80e3d37bc45046f8773bb87bb8cecd5994007bbe6dfdc8b", + "bytes": 1417 + }, + { + "path": "src/components/ui/badge.tsx", + "sha256": "e3d596378d2db6fd77d5d5a8f060b0a1f4f125dfb09732c7c720e77e80efe4fa", + "bytes": 1139 + }, + { + "path": "src/components/ui/breadcrumb.tsx", + "sha256": "ab1107eca1e4a95589f67b96ac5c58ed72f4e90789bdd796da4789e05756cadc", + "bytes": 2725 + }, + { + "path": "src/components/ui/button.tsx", + "sha256": "416135c60530768b8e089c51b7729d5040ae9df9d1f6b0cbf48465786bc8665a", + "bytes": 2149 + }, + { + "path": "src/components/ui/calendar.tsx", + "sha256": "3a87455bf5f1bb8ce8d2cefeff0c93648071dac1484edb3a598280c2d6bdd18e", + "bytes": 2787 + }, + { + "path": "src/components/ui/card.tsx", + "sha256": "fe83604323ef15049f900acddde2f6558eca9ebcc60f7eb50138a4ee187bab73", + "bytes": 2539 + }, + { + "path": "src/components/ui/carousel.tsx", + "sha256": "668007dd3c85202cd10bdd5f54882c9d69f5037a1e0d9fe67d03be7a2bd3a387", + "bytes": 6289 + }, + { + "path": "src/components/ui/chart.tsx", + "sha256": "e3a4c3882ea44c54e76495eaff69f4d69d019b53d2365f1df781af60c87777ed", + "bytes": 10581 + }, + { + "path": "src/components/ui/checkbox.tsx", + "sha256": "2b80d1d1f7871c1321a58d8846675f4183cd499ccb7b45d343c83362c0ca6551", + "bytes": 1064 + }, + { + "path": "src/components/ui/collapsible.tsx", + "sha256": "fbe07841b794e0d7d08e87ddaaafadf1dc0c63ecc8389cd2c5b4a0a3f5500df1", + "bytes": 320 + }, + { + "path": "src/components/ui/command.tsx", + "sha256": "7ac05c4422d38df1cf7460875781462bed1e048c523dcb506f3f2ea298addb9e", + "bytes": 5139 + }, + { + "path": "src/components/ui/context-menu.tsx", + "sha256": "3f4ebfd3c4b9529ef88edc6ae95de3b247f9f7d7ce5b3e9878dc6b313cadc7f1", + "bytes": 7287 + }, + { + "path": "src/components/ui/dialog.tsx", + "sha256": "bc74580a937ce11619166e04ad1a83ca08949394156e7e0433aa854845f9bdcf", + "bytes": 3950 + }, + { + "path": "src/components/ui/drawer.tsx", + "sha256": "4aa0c46d31a16691240ff23b412219b7834bd95ccc8ffa918d9a00f36847b3fc", + "bytes": 3030 + }, + { + "path": "src/components/ui/dropdown-menu.tsx", + "sha256": "74db29dbb246dd23d22f9b93c36f49e14ff41f5e1616ebb761033590139c99e6", + "bytes": 7335 + }, + { + "path": "src/components/ui/form.tsx", + "sha256": "349eada8b53e7444ae6d6cd19b4edff45f4f87fc7489572ac97844cbd9fd0616", + "bytes": 4139 + }, + { + "path": "src/components/ui/hover-card.tsx", + "sha256": "8d606e166ef5f9bc4fc3efb1f6ce2f4bb376177a2c6fef16f67f87ad2d13d005", + "bytes": 1193 + }, + { + "path": "src/components/ui/index.ts", + "sha256": "7c053769ea896055e66f61e76f2c8b12bd617520ae9eda421863dbc5e98f268e", + "bytes": 866 + }, + { + "path": "src/components/ui/input-otp.tsx", + "sha256": "2afd0fb58ed8ca796dbaaa4a7f2a4c50602df01e5f74e0ace3a3201ff09adc91", + "bytes": 2172 + }, + { + "path": "src/components/ui/input.tsx", + "sha256": "aacef8933fe99ab553e12a82c874a23c97e2b5602eb6f00018454fa0a8d4f7f2", + "bytes": 799 + }, + { + "path": "src/components/ui/label.tsx", + "sha256": "4d790d260bf73221b05e3918a7fe5f86cb36b10d70955df7fdfcea23a2f10a44", + "bytes": 719 + }, + { + "path": "src/components/ui/menubar.tsx", + "sha256": "7d9931a721eae883364ff5892f3e9bbdb686f04a11d72dc22abf635ed47e00bd", + "bytes": 8022 + }, + { + "path": "src/components/ui/navigation-menu.tsx", + "sha256": "b3f93b46642320fcaefbf70a478e3441229a3bd2c364d4e2e4c8352d497faf3c", + "bytes": 5073 + }, + { + "path": "src/components/ui/pagination.tsx", + "sha256": "aefb79477d56ec4f36a3b5503d11766503dd5148b365bb93d972f1c869df9868", + "bytes": 2773 + }, + { + "path": "src/components/ui/popover.tsx", + "sha256": "ee5f78ece57c9e8eb5f9b4186e2bb9ebe46b69b52f03f1edd9a601883bc64772", + "bytes": 1239 + }, + { + "path": "src/components/ui/progress.tsx", + "sha256": "f3a94457750aba96d21d117ebbeb413880df349f730a7b16fbfffe74b43833a8", + "bytes": 1105 + }, + { + "path": "src/components/ui/radio-group.tsx", + "sha256": "d8a176b5ef21def2ab06a7724445f58ee17350b2178eea6b302bc3079a009c5c", + "bytes": 1479 + }, + { + "path": "src/components/ui/resizable.tsx", + "sha256": "18f6398bf83de5bd5d109abd7fa5e02d68f4daec30d9f8772be75f5aa7aa189b", + "bytes": 1719 + }, + { + "path": "src/components/ui/scroll-area.tsx", + "sha256": "55190fb3d0243c6108ff57b5235c29e86ffb1a0879a74c872a91d568ee441671", + "bytes": 1651 + }, + { + "path": "src/components/ui/select.tsx", + "sha256": "b0a51265d2dfdb92c2e379b9a24fc943f82c4f7d3c099ea2bbdeee3fb56a289c", + "bytes": 5775 + }, + { + "path": "src/components/ui/separator.tsx", + "sha256": "687c8b7e2f53672eb3aca4389916a03bbe2c95441719e6c46e7c5143713fc41d", + "bytes": 765 + }, + { + "path": "src/components/ui/sheet.tsx", + "sha256": "7b4dc2d4255cc84ca142798af7b36df125fa3aca06a21e3a89f96888e64cd33c", + "bytes": 4360 + }, + { + "path": "src/components/ui/sidebar.tsx", + "sha256": "0e3ba43b0359700f247903ab9c455aa0c2792abbd81e7447066d32530c4abfbe", + "bytes": 23602 + }, + { + "path": "src/components/ui/skeleton.tsx", + "sha256": "8913724bc2d578e429c2adf993f24bd132696e2c561d80d352706a947bc551d2", + "bytes": 264 + }, + { + "path": "src/components/ui/slider.tsx", + "sha256": "b93ed37b5a78b2d7628abe8e738521a8ed1d19d2a670eb674e11a03069cf4766", + "bytes": 1084 + }, + { + "path": "src/components/ui/sonner.tsx", + "sha256": "dd6d83b7a084015b0f673ff9fc4b63071a40ef62bffd56bc261bdf5f58654d89", + "bytes": 887 + }, + { + "path": "src/components/ui/switch.tsx", + "sha256": "45f67ad2d40d1c43f78d96589b08336a05fa1f1a7f0f36b80d7d5c42d922f567", + "bytes": 1147 + }, + { + "path": "src/components/ui/table.tsx", + "sha256": "f45be6aaffb5c73ad55d7e80e65167028a1ceedf9cafd181ad57f135180edbc2", + "bytes": 2787 + }, + { + "path": "src/components/ui/tabs.tsx", + "sha256": "04163ee0aab4c0bb5004e05749e142623b137cdc76c177c758e440a3a47092c6", + "bytes": 1943 + }, + { + "path": "src/components/ui/textarea.tsx", + "sha256": "11f150ed22d188785e990c6dff12dee72c2efe0b6329c3164b46898e083057e9", + "bytes": 758 + }, + { + "path": "src/components/ui/toast.tsx", + "sha256": "020edff7e045a127e6f2f5cc18fb27fd08bbbf4d5d7958aa5825a601f80463b0", + "bytes": 4872 + }, + { + "path": "src/components/ui/toaster.tsx", + "sha256": "a6df21d77b586ca1468e38052bd58e44c056721a40a48c8a0cc8fb5edbd39f65", + "bytes": 777 + }, + { + "path": "src/components/ui/toggle-group.tsx", + "sha256": "33d4f795fd736c599fcdb18acd29a7c954520e2e1773d5e3daf298dad5906aaa", + "bytes": 1753 + }, + { + "path": "src/components/ui/toggle.tsx", + "sha256": "62aa0dc621e296e3904cbc025d596b6d95321ffeac6c7aec244eba6598860445", + "bytes": 1444 + }, + { + "path": "src/components/ui/tooltip.tsx", + "sha256": "bd9361649477cf9496f4b48415a2eafb9ae1ebca81af58a29b61b1516adc88c1", + "bytes": 1155 + }, + { + "path": "src/components/ui/use-toast.ts", + "sha256": "d48c4c490e5101aa42ceed9b8ca49642d66152ba11daf7ee6c29bee0f5172c37", + "bytes": 82 + }, + { + "path": "src/contexts/ProjectContext.tsx", + "sha256": "87e0f4cc62b25efa3d4f268219ea12a22d1269566c2d3ec17ce62f371eb08379", + "bytes": 150 + }, + { + "path": "src/features/architecture/pages/ArchitectureMap.tsx", + "sha256": "d11da9143043744493b1ae12ac58053f5cdcf8a13473a37b7709ccfdafd1761f", + "bytes": 5793 + }, + { + "path": "src/features/audit/pages/ProjectAuditCenter.tsx", + "sha256": "db9ba4681b4e42a4b405e082e3330d8f2e8672fc7e7e9c221088dc94ae2a560b", + "bytes": 38690 + }, + { + "path": "src/features/auth/lib/authNavigation.test.ts", + "sha256": "23be59dc08bebee86197148d50a224dbd45031250c2c45f89c3e57dcc29dc07d", + "bytes": 761 + }, + { + "path": "src/features/auth/lib/authNavigation.ts", + "sha256": "bb0a79a44413f71fb3213da1794ca5b5cdd9a3c14b00f11ac7f111d0eb04b128", + "bytes": 331 + }, + { + "path": "src/features/auth/pages/Login.tsx", + "sha256": "4766c0de7eb9049eb29e7d4678f986511eb3601a093847c03a915b2a623e6108", + "bytes": 5277 + }, + { + "path": "src/features/autonomy/pages/AutoMode.tsx", + "sha256": "b4b23d9ad9ce1b53d08a1bd3eea581ee318d942d90b5ee9012817211df040e78", + "bytes": 23016 + }, + { + "path": "src/features/blueprint/pages/BlueprintEngine.tsx", + "sha256": "c6a1ae92b24e37479b433b3fc7ed849307015d20869f39167e7fb656b57cbcf7", + "bytes": 48316 + }, + { + "path": "src/features/brainstorm/pages/BrainstormLab.tsx", + "sha256": "b5a7d6a19b9c155a8601e4be01a15908ec1c9e526ed876fad81fea7cef836b3c", + "bytes": 47694 + }, + { + "path": "src/features/browser-bridge/pages/BrowserBridge.tsx", + "sha256": "d98aeaaf643e4d7ec90eec6e9ad1655dcdff64e0dbff2b6ebd6d139c0f194148", + "bytes": 24752 + }, + { + "path": "src/features/bugs/pages/Bugs.tsx", + "sha256": "4d3475fdd577e321631cf491f038a7261c2420e20abf176d566b1a6f601f1c7d", + "bytes": 7048 + }, + { + "path": "src/features/build-loop/pages/BuildLoopDashboard.tsx", + "sha256": "00159f6ea071e4e55706cf9ce282ee5fb8259f3f3dddc947378b0367357ff3b9", + "bytes": 11796 + }, + { + "path": "src/features/builds/pages/BuildJournal.tsx", + "sha256": "72d9f15ca5f9a6714bdeb6c5e14a4c6f4ed5c0361476d81e4cdc0920160abe6b", + "bytes": 12303 + }, + { + "path": "src/features/checks/pages/AIReview.tsx", + "sha256": "feacc4fc9eead5e2d7fb93e9981b24465248739bb8394b8769fe0eb34d0b747e", + "bytes": 32097 + }, + { + "path": "src/features/context-packs/pages/ContextPacks.tsx", + "sha256": "b934825ee23e796c2c69f5be09f29946f333e39e9838faca5c4bf654d7f92152", + "bytes": 24865 + }, + { + "path": "src/features/decisions/pages/Decisions.tsx", + "sha256": "f64cdb6f069619841182c225679116519f3f15276b81c4b256ff172c8e0b4440", + "bytes": 277 + }, + { + "path": "src/features/development/pages/DevelopmentWorkbench.tsx", + "sha256": "85463b098f07f6e419a53a8ee20601a7aaf2cbb23208e5414e05e9cb234c79e0", + "bytes": 26703 + }, + { + "path": "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "sha256": "b4b80af03339f9d5f558b726bdb2e3215e8d49a01c01f9b2d1c7a2af837e4ee0", + "bytes": 46269 + }, + { + "path": "src/features/execution/pages/ExecutionCenter.tsx", + "sha256": "d46241fc2e68cc5c2f85d8e033f205ef1b5cf38b06039144ff8046fc67247372", + "bytes": 32667 + }, + { + "path": "src/features/features/pages/FeatureDetail.tsx", + "sha256": "03e682a39c7094bd6a9a6814de72cf70a5a8eca7ac0a61115669ad317cf63feb", + "bytes": 17957 + }, + { + "path": "src/features/features/pages/FeatureMap.tsx", + "sha256": "7bc923f7c0c4f243e0eac9183f8f4625334aa21ba14b86f846bbb29e81777b13", + "bytes": 14506 + }, + { + "path": "src/features/handoffs/pages/CodexHandoffs.tsx", + "sha256": "fb0f001712a24d3893ddfbaca455103bd17659b561174bcb1aa74d3b5842a2e1", + "bytes": 50900 + }, + { + "path": "src/features/history/components/LiveActivityStream.tsx", + "sha256": "83e00bd2e70c21516bc821b30d9b50c8675dd387ab83476cfec5fd7376cfc904", + "bytes": 16070 + }, + { + "path": "src/features/history/pages/ProjectActivity.tsx", + "sha256": "441397d8eaaefbe90b5916ae6ebc16c7b451f3de5a875a71b90d32d0f343ced6", + "bytes": 3009 + }, + { + "path": "src/features/history/pages/ProjectHistory.tsx", + "sha256": "e01540f24c8d35181f4fd5f29adb2549e278264710ed4ccc8575b878d388954a", + "bytes": 82367 + }, + { + "path": "src/features/insights/pages/Insights.tsx", + "sha256": "b051d3b504c8dcb86e85df5c8a850336bf4dc24e4251c5aca9ba18071db5219f", + "bytes": 22703 + }, + { + "path": "src/features/knowledge/components/ProjectKnowledgeCenter.tsx", + "sha256": "6cbcb32087b0329aa05a28a0f80e4054ba8f2654d62e91d1c521783f8b0f2163", + "bytes": 9024 + }, + { + "path": "src/features/knowledge/pages/KnowledgeVault.tsx", + "sha256": "c06af03144314756f93998317859eb3df57369e3002c4b4e2a09bf485c587405", + "bytes": 3356 + }, + { + "path": "src/features/memory/pages/MemoryRules.tsx", + "sha256": "6adc4a3b0741335bc5f1f1a7878d7b5ea893bc33c215a965d0313f3ca86f7b15", + "bytes": 5426 + }, + { + "path": "src/features/operations/components/ReviewActionPanel.tsx", + "sha256": "419dd502bc3b102dd7d88d999fba017ceb16460a26b91adef43df4ad2e063080", + "bytes": 4875 + }, + { + "path": "src/features/operations/lib/ReviewApprovalStateGuide.tsx", + "sha256": "318e377c983cb2eb3f995d76d5d8b48ef1b2694abe85a2071a2c2c0f26c40a40", + "bytes": 1641 + }, + { + "path": "src/features/operations/lib/operationRuntime.ts", + "sha256": "f3a3c0ab9c7b1acdb82051da1c3f4595fe553ec17cb901ad6bb3210364e677ec", + "bytes": 66362 + }, + { + "path": "src/features/operations/lib/reviewQueuePresentation.ts", + "sha256": "b49696a735b8c859070c2491554edbe2be3f74cd5343f3050628dc91d428d02f", + "bytes": 1468 + }, + { + "path": "src/features/operations/pages/AIActionCenter.tsx", + "sha256": "ac6a59e3a72b2b991eb8de5294c77fdc589324bc8f6056df3b4ec2a1ba751c3c", + "bytes": 12140 + }, + { + "path": "src/features/operations/pages/CaptureCenter.tsx", + "sha256": "c1171b29180ec015e76e117156ca16faae52d7327d5f7102e66118b4958b8f59", + "bytes": 13249 + }, + { + "path": "src/features/operations/pages/CodexTaskPreview.tsx", + "sha256": "d9f4088f62d26c120dd8fc4860c5a85a025cabfb51402c59de9ab3a97205c167", + "bytes": 9855 + }, + { + "path": "src/features/operations/pages/CurrentStatePanel.tsx", + "sha256": "419dc97bf3e64af8e6c5c9116bca9706a5228929959cdfba5179c7a122ea397a", + "bytes": 8539 + }, + { + "path": "src/features/operations/pages/DocumentationHub.tsx", + "sha256": "84a37cf2104c6638d3235a2c51a619b37ddf44df07a54ed386bd1a4c183a0a03", + "bytes": 15398 + }, + { + "path": "src/features/operations/pages/FindingInbox.tsx", + "sha256": "1eb24a74e4fbe6cb345818eb5cc623e3c355491400d55cd29e4ef4654c0dcffb", + "bytes": 25491 + }, + { + "path": "src/features/operations/pages/InitiativeDetail.tsx", + "sha256": "2e544d00a82deae4fcb25db12891b2fa1da36855b041f5ef2421d30c0b862f4b", + "bytes": 14723 + }, + { + "path": "src/features/operations/pages/Initiatives.tsx", + "sha256": "6b08c0d755feacf7dfcd699ddb2502c8e6d5083e1f6df3fb97f5cee001fa2a12", + "bytes": 17030 + }, + { + "path": "src/features/operations/pages/OperatingLog.tsx", + "sha256": "745af3509c31a107dc2c14834df859c00ed93be1e65f13d63dd50bcc2e4d17be", + "bytes": 9927 + }, + { + "path": "src/features/operations/pages/OperatingStubPage.tsx", + "sha256": "560f0a25aed94be5c34ca52bb1716f1779f038050f8a0fb8b77ac80529817288", + "bytes": 2392 + }, + { + "path": "src/features/operations/pages/RepositoryAnalysisJobs.tsx", + "sha256": "7cfae6033c3ff415eef8df93b02a837e7b35de161b52e5c3f200838f16c2ad47", + "bytes": 9621 + }, + { + "path": "src/features/operations/pages/ReviewQueue.tsx", + "sha256": "d475f8cec2c267ff9166bd4624bf1c88728d82b94aa39d14009f15fad581cd25", + "bytes": 58343 + }, + { + "path": "src/features/operations/pages/RuntimeContextPromptPilot.tsx", + "sha256": "f449bc817071de2175d17504bd8da9526c83ee4894cba41332bee6b935c73231", + "bytes": 5980 + }, + { + "path": "src/features/operations/pages/RuntimeDeliveryPersistencePilot.tsx", + "sha256": "dc20a06865326b3c84e7b963047f606962b1c9a335b7c02e9a799ae0a107e786", + "bytes": 11590 + }, + { + "path": "src/features/operations/pages/RuntimeDeliveryPlan.tsx", + "sha256": "db39cd59b995517f441ad0ab84b07f61a7a714c9dcb287c16601bc660b06b184", + "bytes": 6538 + }, + { + "path": "src/features/operations/pages/RuntimeExecutionQueue.tsx", + "sha256": "2690b8f1633886821c2b6cf90c0b72a3e14793c5dae8667befdc1588e1cc6138", + "bytes": 9256 + }, + { + "path": "src/features/operations/pages/RuntimeFeatureOverlay.tsx", + "sha256": "0dbeaf95908de0a77c38e91f99e392dec5e1d08077d6fc2b5f7f9b77a4399333", + "bytes": 8711 + }, + { + "path": "src/features/operations/pages/RuntimeFlowProgress.tsx", + "sha256": "fcbbdb8aed20d836be84c73a8ed578ddbd661cc1cf507832fcef851e845483ac", + "bytes": 6692 + }, + { + "path": "src/features/operations/pages/RuntimeGateChecklist.tsx", + "sha256": "d19a7f7c7b579c44f49032c98defc61d9a02bf3e3691a956d4daddfbabfd5e9a", + "bytes": 11210 + }, + { + "path": "src/features/operations/pages/RuntimeGateEvidence.tsx", + "sha256": "f00c95b1ee42c292be588c0aaa1336674f1a36134d89e46e03378ec553fbd468", + "bytes": 11098 + }, + { + "path": "src/features/operations/pages/RuntimeHandoffBuilder.tsx", + "sha256": "b0fb5736fa78c704e0ab6b7ec6ea75ac57c1c74d35a22cbb632d9258a4a2142f", + "bytes": 9391 + }, + { + "path": "src/features/operations/pages/RuntimeKnowledgePersistencePilot.tsx", + "sha256": "c2d1d81440bd36d315087da00383b0112fcde7fbc11b7564fc38aceaded0d904", + "bytes": 6309 + }, + { + "path": "src/features/operations/pages/RuntimeMissionControl.tsx", + "sha256": "f1afadae47f77982e16f7582f0ae9cb2e2170a23471e16c411fc79a157835598", + "bytes": 11184 + }, + { + "path": "src/features/operations/pages/RuntimeP3ImplementationPlanner.tsx", + "sha256": "15f472011223b98561d029368965248f4c54ab9503c831c16bcfe70816b054ef", + "bytes": 10584 + }, + { + "path": "src/features/operations/pages/RuntimeP3ReadinessAdvisor.tsx", + "sha256": "b2202f57b9958c8fed97467a358d21e4627a92cdbf906eef14448648a7516590", + "bytes": 9968 + }, + { + "path": "src/features/operations/pages/RuntimePersistencePilot.tsx", + "sha256": "9a0c455cdb1fbd60ad39f09f96443404bf0c1fff21aa4be37dcc17be2375fd2e", + "bytes": 14754 + }, + { + "path": "src/features/operations/pages/RuntimeProjectUpdates.tsx", + "sha256": "2ae5fb161a149a2a88f45243162c248b1e09bf4b2f646ea4490d4f15e7495897", + "bytes": 6933 + }, + { + "path": "src/features/operations/pages/RuntimeStateManager.tsx", + "sha256": "6e7c1d0a3fb9dfb26b8c8f7403fd6c0ad6ae7d6f2746f44efa1dd030ed751333", + "bytes": 6647 + }, + { + "path": "src/features/operations/pages/RuntimeTodoBoard.tsx", + "sha256": "26c3947d361446707746fc6c27d1e6245f00abdc6e2a0a46bd9e640bc8940400", + "bytes": 8143 + }, + { + "path": "src/features/operations/pages/RuntimeTraceInspector.tsx", + "sha256": "e5c9fb10050a075541a0fccca75b4725c7248a6240183baf10e2d79fd5343672", + "bytes": 6603 + }, + { + "path": "src/features/operations/pages/RuntimeTruthLedger.tsx", + "sha256": "6edfd38428a9f3aae5e94f620d2292972969ded6d8442684f207599876923b05", + "bytes": 4887 + }, + { + "path": "src/features/operations/pages/RuntimeWorkSessionPanel.tsx", + "sha256": "bb0d3dcb906ea06e0c20d22a01ddbadc86f742269033eba22e1e85bd13d4f35b", + "bytes": 7293 + }, + { + "path": "src/features/portfolio/components/PortfolioAutoRuntimeSummary.tsx", + "sha256": "308a07178c9fe0a0ccbe5605627d3e0d7f4310d80c6e349f1d25fbdb2f474fc3", + "bytes": 9130 + }, + { + "path": "src/features/portfolio/components/PortfolioExecutionPulse.tsx", + "sha256": "aadbc725d8d3f23781188c2a39aee8017cb4ea6b22b7f47d3df6aeed5f09fdaf", + "bytes": 11228 + }, + { + "path": "src/features/portfolio/components/PortfolioPulseSummary.tsx", + "sha256": "9384ce2ddb2eb95fad0db8024f9f52083f85ecc6701053525167eba4b4a61074", + "bytes": 5601 + }, + { + "path": "src/features/portfolio/components/PortfolioRuntimeDashboard.tsx", + "sha256": "2fc6b66ec4c991603344b0368193d2252d37836edba541f27b58d7c9a70d2634", + "bytes": 16223 + }, + { + "path": "src/features/portfolio/components/PortfolioSavedViews.tsx", + "sha256": "1a3a3fca798991c61b6d800185b6cf0dc068aee3ed92ecb46d5c2a14c8e75bfd", + "bytes": 6601 + }, + { + "path": "src/features/portfolio/components/ProjectBrainDiagnostics.tsx", + "sha256": "209dd4e8336f6ffd0c7103de2ee9c93f1d5366e8ccb6cd8cce0e983807e6e5b9", + "bytes": 5420 + }, + { + "path": "src/features/portfolio/components/RuntimeJobLane.tsx", + "sha256": "0b1bc27f733f6f0b63fb5b6413a1f116079d71b91ef1e28185bb574cef6a5230", + "bytes": 4216 + }, + { + "path": "src/features/portfolio/pages/PortfolioCommandCenter.tsx", + "sha256": "f8dfa1852e3ca0679968fb6b6a56efe096ff3438823f27d27dcb9501024155f7", + "bytes": 26111 + }, + { + "path": "src/features/portfolio/pages/PortfolioHome.tsx", + "sha256": "8313b32c18fe6167c0ab04c8ad3b8169e4f11655380e8eafb68ce4b122716487", + "bytes": 17056 + }, + { + "path": "src/features/projects/components/EvidenceGraphSummary.tsx", + "sha256": "6198f77945dd678e168bc2dc2bc71229e4f88aef466b8fb3b1d2f08136b18a3e", + "bytes": 3361 + }, + { + "path": "src/features/projects/components/FirstRunLaunchpad.tsx", + "sha256": "cfccff3a3a5dce93ff1d35dc40733a8910cefa407c35f0ad4e68e709e6390012", + "bytes": 12030 + }, + { + "path": "src/features/projects/components/ProjectFlowMap.tsx", + "sha256": "507fbc341bce837311de489c263d44c71f211048634b91d513bedecd2b8b63f6", + "bytes": 4175 + }, + { + "path": "src/features/projects/components/ProjectOperatingSystemPanel.tsx", + "sha256": "794c06b0fc852617e77fe43009f59af504c5d2f9a9d415f36984b394aea1bf16", + "bytes": 9681 + }, + { + "path": "src/features/projects/components/runtime/RuntimeCapabilitySummary.tsx", + "sha256": "fef63d4412ec65490a1bdb91ed7783df02f131e774a3ee0079e5ba3eb7e4919d", + "bytes": 2716 + }, + { + "path": "src/features/projects/components/runtime/RuntimeReadinessPrimitives.tsx", + "sha256": "dcddb3060778a31a2061033380824c5622741cb9fd6def6942ef7ab07d6c002c", + "bytes": 3177 + }, + { + "path": "src/features/projects/hooks/useProjectOperatingSystem.ts", + "sha256": "1c5af693441e3622e1d868f55094e7d50ffa4cd9189ba3f14352f6392ac91512", + "bytes": 3403 + }, + { + "path": "src/features/projects/pages/FirstRunProjectWizard.tsx", + "sha256": "48f3db77bab6f1d0eeccea190040b9de628bda36c7e8f0b8ea2c6a036461c53a", + "bytes": 16448 + }, + { + "path": "src/features/projects/pages/ProjectDNA.tsx", + "sha256": "0fae1d5cf7a3fb6f44f1a334f1bd4fb4f91ba2a014b2133eb6a0cd8a58ab5ab8", + "bytes": 3311 + }, + { + "path": "src/features/projects/pages/ProjectOverview.tsx", + "sha256": "c5a21909ae5362bfd2df00065553b5e7b95cc1f0c8aa0a00feeac029752e01d2", + "bytes": 14511 + }, + { + "path": "src/features/projects/pages/ProjectResults.tsx", + "sha256": "87f499cc3f6643082d43b342cbe922f4320851d9d8d2b174c78bb5bc2b6980a5", + "bytes": 8174 + }, + { + "path": "src/features/projects/pages/Projects.tsx", + "sha256": "30fd1bd386b4d2456c9e1f439e299e7d35a5ea79aa44f972e97db0d178278f93", + "bytes": 16337 + }, + { + "path": "src/features/projects/pages/RuntimeReadiness.tsx", + "sha256": "3299b5a0cd0c3125340792ca7255439d062f112abc435b1e4e7a48d326d3ca0f", + "bytes": 15150 + }, + { + "path": "src/features/projects/pages/Workspace.tsx", + "sha256": "33d9afe4ceb97b5e5b6c40beefa876fb46c5c067b821cb39c749625e5259b4b8", + "bytes": 15529 + }, + { + "path": "src/features/prompts/pages/PromptGenerator.tsx", + "sha256": "821addc70ad92cab22eadb7749bfc6cd5a4db520f8c0e20d6da6cbf5930c5825", + "bytes": 29334 + }, + { + "path": "src/features/regression/pages/RegressionMemory.tsx", + "sha256": "8eb973d6437061195a282fbc504635d3d69c20b815af6f808aa02d1448c37efa", + "bytes": 7364 + }, + { + "path": "src/features/relationships/pages/RelationshipGraph.tsx", + "sha256": "c253d26e338a1e040a609fd9d79cda6cccc23530fe226aad25476fa5b1538900", + "bytes": 10852 + }, + { + "path": "src/features/release/components/ReleaseAnalysisSections.tsx", + "sha256": "b8909d9ed833d1f173978f6003f45bf56d9430d322811a76bcfd64481c65b58a", + "bytes": 12318 + }, + { + "path": "src/features/release/components/ReleaseOperatorStatusCard.tsx", + "sha256": "c2ae26e2eed1600f71ed3cec9040f606dd63e233684964748aeecdd6ae287d5a", + "bytes": 6872 + }, + { + "path": "src/features/release/pages/ReleaseCenter.tsx", + "sha256": "db277eb58ba04f21eda98c95114537d4132200004363b3a4b43aff092a97f6ab", + "bytes": 46379 + }, + { + "path": "src/features/repo-builder/components/GiteaProjectConnection.tsx", + "sha256": "5583bc115bf7cba43524e5b4934031339ca5aa446831eebf372030d6d96c18e7", + "bytes": 11367 + }, + { + "path": "src/features/repo-builder/components/RepoBuilderWorkflowSection.tsx", + "sha256": "d140729c2a6e0852615f8a8d6e424a5140ce51a3b99ccf43f20acb9672345af6", + "bytes": 1376 + }, + { + "path": "src/features/repo-builder/pages/RepoBuilder.tsx", + "sha256": "fd95df66f6d6e074c678a2e8e9bead5894067c2e0d846120b1e9b6afe3ee30d4", + "bytes": 105424 + }, + { + "path": "src/features/session-packs/pages/SessionPacks.tsx", + "sha256": "e49b71e0445a20d8409cb02131ce82490f555c9d8cfe7d743324f2ecbd5e4dc4", + "bytes": 30349 + }, + { + "path": "src/features/settings/components/EntraIdentitySettings.tsx", + "sha256": "5d668b334aa7d93164160054ccc158d9341c87726eb9a74841e709fe3f13ea2e", + "bytes": 4616 + }, + { + "path": "src/features/settings/components/ManagementOverview.tsx", + "sha256": "b2bc7d48b5dc5284cfd00228b472387c5be7378d048dea6aed06ff7d7b8c1cf3", + "bytes": 4911 + }, + { + "path": "src/features/settings/components/McpEcosystemStatus.tsx", + "sha256": "3618a4e390a9e40c914afc71a264b88678e03943897c604e22a059e9c9210bce", + "bytes": 4961 + }, + { + "path": "src/features/settings/pages/Settings.tsx", + "sha256": "a12e590d0efebfe0384da6e390d546e713e950b7a5b73c7c758c83779ad5861e", + "bytes": 4391 + }, + { + "path": "src/features/skills/pages/SkillLibrary.tsx", + "sha256": "8c9def9ab39b0083ab0f6d97924c11fd79f55f9a793d34f297aea97af74b7384", + "bytes": 16177 + }, + { + "path": "src/features/snapshots/pages/SnapshotDetail.tsx", + "sha256": "46f6854494d993222886bc51c94bce8f635976311adf11da445e6f77f805fb78", + "bytes": 7709 + }, + { + "path": "src/features/snapshots/pages/Snapshots.tsx", + "sha256": "11cf2467546e0ba3c8640c3c55d4654fefd0dafab8fc8eb5e5d25a6eee67d17e", + "bytes": 6184 + }, + { + "path": "src/features/source-library/pages/ExtraSources.tsx", + "sha256": "938abc4ff37233303e3788899127f19b5779dfff23078e6cd50282a0ce1a0ad9", + "bytes": 6395 + }, + { + "path": "src/features/source-library/pages/SourceLibrary.tsx", + "sha256": "d1a0eee21ef2d93fa0ca694f086f1461509b852adf3ec4c5544e8b2aa282c5ae", + "bytes": 18749 + }, + { + "path": "src/features/start/pages/GuidedStart.tsx", + "sha256": "8067dd38302c4c3dc9eb100efc14a4c893e7d9083dc5b8495027c3b93f362a3c", + "bytes": 15159 + }, + { + "path": "src/features/tasks/pages/Tasks.tsx", + "sha256": "3ff6c7b3e3506e4a445e6a1754c37504812f67a0488ba5792518df73c3d390d1", + "bytes": 11230 + }, + { + "path": "src/hooks/use-mobile.test.ts", + "sha256": "a3c7544ce298d9251e30857b56ab3f5dde24152d5d68a0369e8e115a4a4267ee", + "bytes": 689 + }, + { + "path": "src/hooks/use-mobile.tsx", + "sha256": "02abc9f5b76e371380210374c00a4270f5c51735cdff36ccdb7ffea2c487bdda", + "bytes": 1441 + }, + { + "path": "src/hooks/use-toast.ts", + "sha256": "4b70ac2ad6f1c640249bb43667f86d8447985c49be8bd0335897ad0ae7c70694", + "bytes": 3955 + }, + { + "path": "src/lib/utils.ts", + "sha256": "d1f1e0d62cb8d8d1e04c26e14de842d8a151f75812d81b046c65b5d1fe8e4b27", + "bytes": 169 + }, + { + "path": "src/main.tsx", + "sha256": "47d79916b3c40f313666051a07c1967b0e7527d39503c843c7dd84176fd06b2c", + "bytes": 374 + }, + { + "path": "src/shared/api/autonomyApi.test.ts", + "sha256": "7e54109d90f9a059d530d42da67cea2f34fe24fb31f12750e57224f1abf8fdb1", + "bytes": 1378 + }, + { + "path": "src/shared/api/autonomyApi.ts", + "sha256": "93afe8f0a03869c16a49e7cc637af61efce1045c805a4a28913933a82fd6529a", + "bytes": 3971 + }, + { + "path": "src/shared/api/b1ReadBackend.ts", + "sha256": "98999a86691642b858c78a7523f2fe13f6e4e9a26eda9c9ceed2f8f19725923d", + "bytes": 146759 + }, + { + "path": "src/shared/api/contracts.ts", + "sha256": "2dd9e159f1acf7ec864e7fae052341beee027916a5ceb519269a7877dc08be50", + "bytes": 17625 + }, + { + "path": "src/shared/api/contractsRuntime.test.ts", + "sha256": "28ea29c5669ab2318b4c29011353989d2046a198cb704b53642e2dc0882b814b", + "bytes": 2140 + }, + { + "path": "src/shared/api/errors.ts", + "sha256": "a1cf96b458cac02a82370addf0e6649fbc525115d6c60df90d6bf303a4e94ed3", + "bytes": 367 + }, + { + "path": "src/shared/api/giteaApi.ts", + "sha256": "7277661eff2c28b089ae78126ff8d5477ea6e98b8c5b8b904ce317babad9af4e", + "bytes": 6312 + }, + { + "path": "src/shared/api/httpClient.test.ts", + "sha256": "b74d4b99253e1a3d4f721342534f79d53b44e27894cc0b620d3b19d62518cc54", + "bytes": 1378 + }, + { + "path": "src/shared/api/httpClient.ts", + "sha256": "fc41b6b38a4832ac96f706127238e2767e8076b502ad8bcf07debe38a658f9e7", + "bytes": 4814 + }, + { + "path": "src/shared/api/mockClient.ts", + "sha256": "a973894daf302d8ffb2e87ed9fd0a450bb071944ee309db2fc8c3e94d6afdeab", + "bytes": 44477 + }, + { + "path": "src/shared/api/operatingSystemApi.ts", + "sha256": "89367d1662ef87b1d516e6fdf559962fb3a38f4ffc14f16223f1ed8b52818a98", + "bytes": 2509 + }, + { + "path": "src/shared/api/operatingSystemTypes.ts", + "sha256": "0df31f035428c5ac56d8dc3bab4e9abd8d2fdaf4b9759b4c1c665f732e888d6e", + "bytes": 5638 + }, + { + "path": "src/shared/api/portfolioTypes.ts", + "sha256": "76f630cb4d2ca22388cd095937323c160c6cf2196cbbd2af02ba6c8fd7d53184", + "bytes": 7952 + }, + { + "path": "src/shared/api/projectBrainClient.ts", + "sha256": "e340f50dc1b3b0d0d9796e54590ee40cfe4f3acfbcc5854552fa48e0e9fd3a04", + "bytes": 13706 + }, + { + "path": "src/shared/api/projectBrainClientMode.ts", + "sha256": "9d48259f240908385891a9b52509a057a47cb8af51271ea8e4bf0bed2a2e3fba", + "bytes": 195 + }, + { + "path": "src/shared/api/realClient.ts", + "sha256": "1b22e950a843cc9f0de8ebdaa834aa2aef76619dd6205e0882e19c88a9b2fb31", + "bytes": 48104 + }, + { + "path": "src/shared/api/systemTypes.ts", + "sha256": "4813f120b6c878a52b5d8ba043201d4851f0f851002125a6cc56755865ec0a8b", + "bytes": 1164 + }, + { + "path": "src/shared/api/types.ts", + "sha256": "6f7c71617eb5197b756bf639c3dc2d6ba77607a487a078d3d819ff9c9d699366", + "bytes": 10816 + }, + { + "path": "src/shared/components/ActionGroup.tsx", + "sha256": "fb4d45a8b9a1ada954e8a5a0b3a4f344c57b4bc4ed4af802b05a8b5d24a11008", + "bytes": 1100 + }, + { + "path": "src/shared/components/BuildLoopActionRail.test.ts", + "sha256": "b1b8e19bbd6401f1b04b21ae8fb9857b6cc6b65be087f68197fc88fc34df560e", + "bytes": 949 + }, + { + "path": "src/shared/components/BuildLoopActionRail.tsx", + "sha256": "b7c88b344076fea75c3eaeffc614c750b50c0432a81f74aa1ec59097d90f641e", + "bytes": 8211 + }, + { + "path": "src/shared/components/ConfirmActionButton.tsx", + "sha256": "18d3596424fa5fe6630a8e66189790312d34d9feef395d8f4fa4924bba9de6af", + "bytes": 1327 + }, + { + "path": "src/shared/components/GuidedWorkSessionCard.tsx", + "sha256": "d80a5df6361ac7e2cf800ec4fb6882ff26068d858f448c2460b5e97b9e5109c6", + "bytes": 7096 + }, + { + "path": "src/shared/components/ProjectFlowRail.tsx", + "sha256": "d917a0015ae7b02863db89542b7b00837d5aec0d4629b5b37cf6da35bd0ea8d4", + "bytes": 8920 + }, + { + "path": "src/shared/components/ProjectOperatingStepCard.tsx", + "sha256": "aeb13976df496586e7ddda13f67dd23d33002b0257de020b526a5aacd1b0d23a", + "bytes": 3902 + }, + { + "path": "src/shared/components/ProjectRouteFallback.tsx", + "sha256": "220b0294be094000c28fdf967b726e932471f4e6aad3336b581ef38a8297b96c", + "bytes": 1588 + }, + { + "path": "src/shared/components/ProjectTimeline.tsx", + "sha256": "6f6f7a35bfe22a393bc85c8f75011d5055db4665d8902806a0e35b300f8e0bd9", + "bytes": 2147 + }, + { + "path": "src/shared/components/RuntimeErrorBoundary.tsx", + "sha256": "065489145f2203d76836079dc2bc8f04a1e5534192f08d3c3bb4498ade25170a", + "bytes": 4000 + }, + { + "path": "src/shared/components/WhyNothingHappening.tsx", + "sha256": "b7405493792c49177419d4089f2a931d265311f7361e1ecd36ab1730949d3e1d", + "bytes": 1474 + }, + { + "path": "src/shared/contexts/AuthContext.tsx", + "sha256": "93b1dabe18eb01206c20bd689bbbf6a7241261ab99af3e7fa9e01ffb15e5c1df", + "bytes": 3320 + }, + { + "path": "src/shared/contexts/ProjectContext.tsx", + "sha256": "89fb330e3dde9baeb15f3aabd5378a1d29659fe14bd59b0c1d99360dcd644177", + "bytes": 27805 + }, + { + "path": "src/shared/contexts/liveProjectUpdate.test.ts", + "sha256": "a4bd9795bbc0305ca961db76314f6ac7a35ab5c368e453b0f74617788bc5c8ef", + "bytes": 1075 + }, + { + "path": "src/shared/contexts/liveProjectUpdate.ts", + "sha256": "f3d25e1dc1d9c234c90c2597a740cae0615c1979fe64560b943e7699acaf9f48", + "bytes": 1917 + }, + { + "path": "src/shared/contexts/projectDataBoundary.test.ts", + "sha256": "710b0ba6a0cec346b0ba4d6c65dce49a11a90533594f494e56fee67441e42140", + "bytes": 1029 + }, + { + "path": "src/shared/contexts/projectDataBoundary.ts", + "sha256": "975a040d59c71fb08f50dea5108ee5341e325e19d347358573ff01bcf6ffb497", + "bytes": 713 + }, + { + "path": "src/shared/contexts/useProjectDataSource.ts", + "sha256": "eef6e54eef069adc2fa82f9c44f53cb44c657a73288975999e3c5a50263ea9bc", + "bytes": 7367 + }, + { + "path": "src/shared/hooks/useAsyncResource.test.ts", + "sha256": "bdad671d6ea10f759f9a5507238c168592ff0a7c59a283df9310d1cbc768ce57", + "bytes": 2717 + }, + { + "path": "src/shared/hooks/useAsyncResource.ts", + "sha256": "c6e236c264c0f629cd01604304fe822c6f9e7c77097ad574b8cb4d20e04d66c8", + "bytes": 3191 + }, + { + "path": "src/shared/hooks/useProjectData.ts", + "sha256": "9f04cef1d89de5780ae269f1ed8bdcd33ebb3372309b0b2643132e6b780bd861", + "bytes": 18279 + }, + { + "path": "src/shared/lib/activeProjectStorage.ts", + "sha256": "8e2d189346c929c199216013a9d9d613651ff10c9688e80b937b2c51db6e6886", + "bytes": 929 + }, + { + "path": "src/shared/lib/activityRuntimePresentation.test.ts", + "sha256": "6292ed092e473e9843f751b3e7a9dc6994da813a2afa6bd89974b89776a00037", + "bytes": 4469 + }, + { + "path": "src/shared/lib/activityRuntimePresentation.ts", + "sha256": "f98421bb8783955d01b5224858595be4ab9fd2e181a376b112adc41f983f2c33", + "bytes": 4325 + }, + { + "path": "src/shared/lib/appLogger.ts", + "sha256": "d3694e11ff8fa25233922d79e423cf90538653ede0acba4b13a3aa2a90a83453", + "bytes": 288 + }, + { + "path": "src/shared/lib/autoModeExecutorContract.test.ts", + "sha256": "c9e40a0c9612b35e17455f29ccbb80846ab2fd0aa8f4ab7dffbcbae5c9fb8d41", + "bytes": 1521 + }, + { + "path": "src/shared/lib/autoModePresentation.test.ts", + "sha256": "e565e861077e0c972b809221588daa6d1a3636a4eff1af443024704a9397240d", + "bytes": 3093 + }, + { + "path": "src/shared/lib/autoModePresentation.ts", + "sha256": "fe0981176b6b2aeed59e4b10da4ca507120e44d985bb679c9c69f10fb1af2856", + "bytes": 4329 + }, + { + "path": "src/shared/lib/autoRuntimeSummaryPresentation.test.ts", + "sha256": "e2312f2b6dbe22a8895f2e297141c5cf229ca73d67a3233b44ad09c70d2ec5df", + "bytes": 3282 + }, + { + "path": "src/shared/lib/autoRuntimeSummaryPresentation.ts", + "sha256": "5916c9b3780e7c6a64efcb0446326446987e41cbaed588b7453e810ffaf87436", + "bytes": 3339 + }, + { + "path": "src/shared/lib/autonomyProfiles.test.ts", + "sha256": "e7569d64559f98b9d4925d7566b6c8cdff99b90abbac69637bbdbbbda18ee616", + "bytes": 762 + }, + { + "path": "src/shared/lib/autonomyProfiles.ts", + "sha256": "f536f064c82b9981cb29eed0f6909e36639284b0bbb3f22bafeacb31448428dd", + "bytes": 1218 + }, + { + "path": "src/shared/lib/blueprintEngine.ts", + "sha256": "27ecbd0e869f42b6876c2b97781a37ffd1ba09e3bb2ada8b03c3ad78faee98f5", + "bytes": 40216 + }, + { + "path": "src/shared/lib/brainstormEngine.ts", + "sha256": "9d0605ad0475c88e4b2ed745fe5656522d7f37e3bd3ffa37bafb6f75b6cff8cf", + "bytes": 29639 + }, + { + "path": "src/shared/lib/browserBridge.ts", + "sha256": "c20b209992f50020e78cc87a675f27ff6ac50f23183d8e74317997fa4300976b", + "bytes": 6452 + }, + { + "path": "src/shared/lib/browserStorage.ts", + "sha256": "55e6f0c1c9878b5e3a60ae2ba963b18dd8551fa29691784ba32323cd3ee0af03", + "bytes": 944 + }, + { + "path": "src/shared/lib/buildLoop.ts", + "sha256": "52f6f0a0f951ad2448354e2ccafe309ce89475bfe1a762d6fb29eecfa1372d14", + "bytes": 25399 + }, + { + "path": "src/shared/lib/buildLoopActionRailStatus.ts", + "sha256": "988cc3f8cc0a9d76fb8da7e632da36a596691b989501b6590f03e36ecb456c59", + "bytes": 956 + }, + { + "path": "src/shared/lib/buildLoopWalkthrough.ts", + "sha256": "1404b5c5a2871c75ed2bc4255f0b1bca3b53940adf3cba76898b60e91f17462d", + "bytes": 18855 + }, + { + "path": "src/shared/lib/buildMetadata.ts", + "sha256": "91aead503a25a7d31af77c67efd797b48af42177b5c25a56311fd4d206e98dd8", + "bytes": 1328 + }, + { + "path": "src/shared/lib/canonicalLifecycle.test.ts", + "sha256": "f3e4a44d7cd501568e17387209245c97a01875d59df5106537b6e01b1ffe0b87", + "bytes": 3475 + }, + { + "path": "src/shared/lib/canonicalLifecycle.ts", + "sha256": "e3f489cee24c542199353141e66ce8991ceb987d4047f9c40463b53c830cd290", + "bytes": 3073 + }, + { + "path": "src/shared/lib/codexExecutionFeedback.ts", + "sha256": "66822c5155c10d166a791736555b8b458eabf1a1f9b55a6e549362997f5ee6fe", + "bytes": 17792 + }, + { + "path": "src/shared/lib/codexHandoff.ts", + "sha256": "07c9fed2a700ea16cede9842e91f3e05ef0a06f1e28e8b11a3328005bc99823b", + "bytes": 21439 + }, + { + "path": "src/shared/lib/codexSessionPack.ts", + "sha256": "b2b5657c270bf6cf8400b69845144672202afd784905799657c02964cfbcd2db", + "bytes": 17715 + }, + { + "path": "src/shared/lib/codexSessionPackExport.ts", + "sha256": "45d835461a08f7f07cb64cce984e8d0cb0b2a903dfb728ef70ba06bcc1d5e651", + "bytes": 12665 + }, + { + "path": "src/shared/lib/codexSkills.ts", + "sha256": "d68dc6d7b1cb92c2daaa8aae115a8e67f708de0366a1744303eca16be214470d", + "bytes": 33265 + }, + { + "path": "src/shared/lib/contextPackSelector.ts", + "sha256": "6eadb360181ae17c14a4d346b9d381069865e1f81fabcded4bce8ff0f6d92275", + "bytes": 7330 + }, + { + "path": "src/shared/lib/coreDomain.test.ts", + "sha256": "6c23c54f64ffc836205f4c31609ac87b7dec3ed055fc603a421c39aaa3295850", + "bytes": 4865 + }, + { + "path": "src/shared/lib/downloads.ts", + "sha256": "f0377328815fa9de7306f1fd1ed6d7e54cc2c2fb5658cc91b64c4fb2303d9f4c", + "bytes": 656 + }, + { + "path": "src/shared/lib/evidenceClosure.ts", + "sha256": "04311289ee95c6622dc93dcff5de0cdee6d6eb55a0f84d9eef16827f58bb2fe9", + "bytes": 849 + }, + { + "path": "src/shared/lib/evidenceRouting.ts", + "sha256": "834ff49e09a3d2f997b6347f4d7d7bb8f5bedd238d083caed8512ae2bdc7d8dc", + "bytes": 4894 + }, + { + "path": "src/shared/lib/featureDeliveryModel.ts", + "sha256": "23f68c2202422a6b9497b7855d67614cbab1e5d17100f988fe4ee6ddcb9028fe", + "bytes": 1343 + }, + { + "path": "src/shared/lib/findingEvidenceAge.test.ts", + "sha256": "2283a1e49a371d3014c51951219cc4b0f70f9943632985b9179a998c35875f7a", + "bytes": 2206 + }, + { + "path": "src/shared/lib/findingEvidenceAge.ts", + "sha256": "3e7977e5a70c4a1d66cd6ded0b4fcf0ac862a1e79e9565fe4a169d125f41551a", + "bytes": 1606 + }, + { + "path": "src/shared/lib/findingRemediationPresentation.test.ts", + "sha256": "766956326718467bc2452a4f6e6f0da41031bb91f2813732c6ce9d7d371c7359", + "bytes": 857 + }, + { + "path": "src/shared/lib/findingRemediationPresentation.ts", + "sha256": "0fcbaa4b12a8378f57fe2d3497c7cae771d5acc7756ca7c26fb04ad7baab2cbb", + "bytes": 1926 + }, + { + "path": "src/shared/lib/guidedStart.ts", + "sha256": "f19d4adbd0cb1778bfd0a43c47831c5626c2c6cfa9f3bc1c0fb959ce0768c281", + "bytes": 8067 + }, + { + "path": "src/shared/lib/handoffReview.ts", + "sha256": "8445b354491c3c87e5c7662a79886e3404505273dc86100283e50962c4730154", + "bytes": 12842 + }, + { + "path": "src/shared/lib/mock-data.ts", + "sha256": "aa42ebe446220319f4ed559fe53f56356c2358eb25676d8deb7430ae653785f9", + "bytes": 45690 + }, + { + "path": "src/shared/lib/model.ts", + "sha256": "8bbe0fa95af4ae59f05fa18ce8f09bf703ae7bb57cafc9e401f36bcfeba105c2", + "bytes": 43648 + }, + { + "path": "src/shared/lib/navigation.test.ts", + "sha256": "84844369099c353057d23d732538c75cf514802bedf9a1a87fbca59b2ae32f55", + "bytes": 4301 + }, + { + "path": "src/shared/lib/navigation.tsx", + "sha256": "5037f5097275d743567f7e3a409755ee1419ed124892b28821feed2ecba5c1f8", + "bytes": 11519 + }, + { + "path": "src/shared/lib/operationalCopy.test.ts", + "sha256": "f836fae8db1e6f4669ba820282b9684214f476439bfac776fb282f625814431b", + "bytes": 896 + }, + { + "path": "src/shared/lib/operationalCopy.ts", + "sha256": "d30e8232aa1ebf13d94485eb317db97a1809236f7f14ddda1cadd9e6ae8b8821", + "bytes": 92 + }, + { + "path": "src/shared/lib/portfolioAttention.test.ts", + "sha256": "9e1be2727c755c15b9704deaa63654e426660dcebf4ace265720335f28276c94", + "bytes": 1377 + }, + { + "path": "src/shared/lib/portfolioAttention.ts", + "sha256": "81fd8f46871267b8a8d98eccd3807e9d77257917bc2aad9a7f6228b296afe9ac", + "bytes": 354 + }, + { + "path": "src/shared/lib/portfolioViews.test.ts", + "sha256": "c1eab9b406f5fcd5b432c2202738766f4df576c69ad56e8613fe72ca7a0ed94f", + "bytes": 3181 + }, + { + "path": "src/shared/lib/portfolioViews.ts", + "sha256": "4ab5110b269883415adf734cd5caea70a5fa50de3b5f23fd8ee09560af780ea0", + "bytes": 4152 + }, + { + "path": "src/shared/lib/postRcOperations.test.ts", + "sha256": "db8272d0f8ac68c016e28b3619a9e821f2023017fe8c14ef7a3ea749f7865d60", + "bytes": 1025 + }, + { + "path": "src/shared/lib/postRcOperations.ts", + "sha256": "b5048ab7182c3758edf5b41f4b44021af1e311d8f18795705da0667254045809", + "bytes": 4854 + }, + { + "path": "src/shared/lib/productExperienceAudit.ts", + "sha256": "fe0c2ecaed477aba82db7b2718253558d952a21edbbed2b8566dd42fc3503a99", + "bytes": 8586 + }, + { + "path": "src/shared/lib/productLanguage.ts", + "sha256": "b48d2686b2aa1227a4e3eac5a578fe1540b162ebe019e1e8d9131a5fdd9e276d", + "bytes": 1858 + }, + { + "path": "src/shared/lib/projectArchiveCommands.test.ts", + "sha256": "4cb605f27d0cb23f16f6d669fc94ed1f844b2998dd36946ea6246691540988b5", + "bytes": 3284 + }, + { + "path": "src/shared/lib/projectArchiveCommands.ts", + "sha256": "41c642b388bfcab096227cbcb437e8d7e5e4ddd62b4f855a9a0263b307038357", + "bytes": 5716 + }, + { + "path": "src/shared/lib/projectAttention.test.ts", + "sha256": "bd0cda9eb649d0995675fccdf787bfe2aa23dbe3551a10b14d3ead4b1ef40f15", + "bytes": 2920 + }, + { + "path": "src/shared/lib/projectAttention.ts", + "sha256": "baf969daec99041fe59ef350cd9beaf9a131f2ec6e1211a5048042dacd41eaa3", + "bytes": 2785 + }, + { + "path": "src/shared/lib/projectAudit.ts", + "sha256": "e964831372f8276c781723d5dec52d2b96741d505bc8240273817687cca9af64", + "bytes": 143965 + }, + { + "path": "src/shared/lib/projectAutonomyStatus.test.ts", + "sha256": "c2629a27f1d01f0eb0ff4dac6986fc2483043f7df345ae0ea16199d27b6719cf", + "bytes": 3356 + }, + { + "path": "src/shared/lib/projectAutonomyStatus.ts", + "sha256": "4e6caeff06ad63efb5b099089c2c4fcab6e8b91f033755751540661525372bf2", + "bytes": 5133 + }, + { + "path": "src/shared/lib/projectBrainstormCommands.test.ts", + "sha256": "b2ad0310a992b2bdd524db9c5b1ce11968ec970dc96213c8f2b5491f3ff5d22c", + "bytes": 2499 + }, + { + "path": "src/shared/lib/projectBrainstormCommands.ts", + "sha256": "1309c95090fe027ad956a4090f6cbf4810a3ca2170f44776900e2aeab271ff70", + "bytes": 3131 + }, + { + "path": "src/shared/lib/projectBrowserBridgeCommands.test.ts", + "sha256": "52bb7484d9a884e8bafcca337669f22b61cf94406c407be54742e1c70e246b54", + "bytes": 4484 + }, + { + "path": "src/shared/lib/projectBrowserBridgeCommands.ts", + "sha256": "c5fa484758ca488afd957ff2346ba2b8f770855ad12d5c1471afc787c94b9e50", + "bytes": 8112 + }, + { + "path": "src/shared/lib/projectCodexFeedbackCommands.test.ts", + "sha256": "913a738348805338b82eafaea510b4ef855fdc2e368a81dbf37c6dbc6dd1e5ce", + "bytes": 4348 + }, + { + "path": "src/shared/lib/projectCodexFeedbackCommands.ts", + "sha256": "f5bad628688b757cea321c6d211172649b9e118d5118b1e3e72f75a3ab549bf1", + "bytes": 12021 + }, + { + "path": "src/shared/lib/projectCodexHandoffCommands.test.ts", + "sha256": "f3fe224f36ccbb359e2aac7b4a94014ac06bd5ca33be5948a23f8c49eb8a0b92", + "bytes": 4183 + }, + { + "path": "src/shared/lib/projectCodexHandoffCommands.ts", + "sha256": "0099e1cf4f637ae5e8d2df52807b8cc1351ac6b3b03d2445e793cea28693ce87", + "bytes": 4352 + }, + { + "path": "src/shared/lib/projectFirstRunCommands.test.ts", + "sha256": "88a380b230a109f2170d9fc2c12468a1f1bc3b55dcf00d6a92b21392e088725e", + "bytes": 5342 + }, + { + "path": "src/shared/lib/projectFirstRunGenerationCommands.ts", + "sha256": "a9d892afececfb1ed246aa6f5358b49ca40f9835f08588acaa00b0664937100d", + "bytes": 7652 + }, + { + "path": "src/shared/lib/projectFirstRunLifecycleCommands.ts", + "sha256": "82b04dcd181104a3bf86bd4549eb2aa3b7a92dee021f77e31daedbedbea491c1", + "bytes": 2456 + }, + { + "path": "src/shared/lib/projectGuidedSessionCommands.test.ts", + "sha256": "0fe7b6c2f7723c0dc684bbf677b57bff186ed1e2fba898b615319bea9e2b6bad", + "bytes": 3675 + }, + { + "path": "src/shared/lib/projectGuidedSessionCommands.ts", + "sha256": "65ab4b548d989e10a9421c5f8e683e6eb8026daff75ebb07e0fbc032bc937856", + "bytes": 6973 + }, + { + "path": "src/shared/lib/projectGuidedWorkSession.ts", + "sha256": "f59b243e8a1372bafe4a542cb520e9aa1f61231cb05757881c6d2d6088788b32", + "bytes": 2685 + }, + { + "path": "src/shared/lib/projectHistoryCommands.test.ts", + "sha256": "193b99549fad31113f2f032ee60a0bb8ec69bb54232cb04af44c8df71ada39d9", + "bytes": 3806 + }, + { + "path": "src/shared/lib/projectHistoryCommands.ts", + "sha256": "e949ae2f9e443e46dd3db25c16415fc17fe83f24847eddbca7b15179ad4ae8d1", + "bytes": 3326 + }, + { + "path": "src/shared/lib/projectListView.test.ts", + "sha256": "0001aad4e7cf1ed9d43684bb98365f9eeed16a53b1d2f5ff0e11d30f979e0abd", + "bytes": 1657 + }, + { + "path": "src/shared/lib/projectListView.ts", + "sha256": "c536097538db74e7c08199a828138db94c80fd76844144aff72cf5e9e53e918b", + "bytes": 1413 + }, + { + "path": "src/shared/lib/projectOperatingStep.test.ts", + "sha256": "90fe25a0c433e990dd349dd567999a694fa186ff47ee0d131fee5aff32f4221a", + "bytes": 6930 + }, + { + "path": "src/shared/lib/projectOperatingStep.ts", + "sha256": "92f2fd2d0313725efcd2bd7776388599e0e3a679591923b69d48f2c672b090e6", + "bytes": 5094 + }, + { + "path": "src/shared/lib/projectPresentation.test.ts", + "sha256": "b0dcc033bb0c1d1b24ecd1b4eb7b6085f2246e8d9a36cf3a0d83307629db265e", + "bytes": 1043 + }, + { + "path": "src/shared/lib/projectPresentation.ts", + "sha256": "2fcc4db0b5326320946fcfe5ef732e0086ee3cc954ad9486b410fd62ddb1ad12", + "bytes": 3337 + }, + { + "path": "src/shared/lib/projectRepoDeliveryCommands.test.ts", + "sha256": "e3c101d806343b8cfaddd41764a61930d9ae26d89805e1a64aa1860752c519ff", + "bytes": 3158 + }, + { + "path": "src/shared/lib/projectRepoDeliveryCommands.ts", + "sha256": "2a0e935f7c45768f8cb5fa49647a404bab340653c1d340e69a68cda4882889ef", + "bytes": 10694 + }, + { + "path": "src/shared/lib/projectRepoDesignCommands.test.ts", + "sha256": "d421cdbc9e763b004b98f5487e6fb2d581e93248559eb00c97c26f433c3fe69c", + "bytes": 3402 + }, + { + "path": "src/shared/lib/projectRepoDesignCommands.ts", + "sha256": "21ea9a80643abe6a7136b4756521b34792e19792e1834fe72105c8eba08b0f59", + "bytes": 21314 + }, + { + "path": "src/shared/lib/projectRepoSnapshotCommands.ts", + "sha256": "b9a8aa4441818bb483fad0a0ac06bf0357c803c552da82e93d5d236afefac081", + "bytes": 11015 + }, + { + "path": "src/shared/lib/projectRepoStateCommands.test.ts", + "sha256": "397409be45fd09ccc6c77c1c03e8f0317cf6a47b5456206b7c3887992d4faa5d", + "bytes": 4345 + }, + { + "path": "src/shared/lib/projectRepoWorkPlanCommands.ts", + "sha256": "19f19c5c73ccfe62f46e1cc9c6cebbc505cd97da5ba1425b8a87bbfdf42c7ee5", + "bytes": 8150 + }, + { + "path": "src/shared/lib/projectRepositoryEvidence.test.ts", + "sha256": "d56d152cf88dc64803b672ee3c195912b1b7caf69d50df71a09382d283a5dc47", + "bytes": 3066 + }, + { + "path": "src/shared/lib/projectRepositoryIntakeCommands.test.ts", + "sha256": "17c93204888583e5293cabd4244e1b04b64d058aa5c6e9ad9e8c86a64c12dc96", + "bytes": 4867 + }, + { + "path": "src/shared/lib/projectRepositoryIntakeCommands.ts", + "sha256": "81114265ed651048d358d06b14e41094e31d68fa845eb65c5e95b8e9fca24197", + "bytes": 4730 + }, + { + "path": "src/shared/lib/projectResultSemantics.test.ts", + "sha256": "cdf1fe5b4be44617a197f405deb36546b498f19a550ec0fd699442b96e5645be", + "bytes": 1923 + }, + { + "path": "src/shared/lib/projectResultSemantics.ts", + "sha256": "8be2ac8a1c261d965cde1f77a991ef14b853394c88d03572db58017eee048808", + "bytes": 676 + }, + { + "path": "src/shared/lib/projectResume.test.ts", + "sha256": "6b316b57c500279ba11749951c0961137533bbe9ab638afe62ebce2809f65963", + "bytes": 2614 + }, + { + "path": "src/shared/lib/projectResume.ts", + "sha256": "77dc7d9dceb6c7fd9f735d30af99dd4528f237c73bd9d83a269461b1832539e3", + "bytes": 3907 + }, + { + "path": "src/shared/lib/projectSearch.test.ts", + "sha256": "839ba37b3dea541f51ab0e9ec484e19033d2ae119841c87c58ae62877ef356f3", + "bytes": 1217 + }, + { + "path": "src/shared/lib/projectSearch.ts", + "sha256": "7b56e58156be0d5557cd8b448a688ba82dd935b7ba4f51a17f330d9ff80655c0", + "bytes": 21815 + }, + { + "path": "src/shared/lib/projectSkillCommands.test.ts", + "sha256": "05f706e453eecb4e0ccb7215673f08c78836b89b25c127214f180c5897611598", + "bytes": 2586 + }, + { + "path": "src/shared/lib/projectSkillCommands.ts", + "sha256": "741a802ece11262cc14234a5664cd9de82cb451b1f26a4d5d8d7f64fb24a30aa", + "bytes": 2546 + }, + { + "path": "src/shared/lib/projectSourceCommands.test.ts", + "sha256": "21cb21ebc9270d52610502fab0a35bc9c78d9302d644d7b6581bb08331c6d497", + "bytes": 3721 + }, + { + "path": "src/shared/lib/projectSourceCommands.ts", + "sha256": "9727bee4ca4ff9234506feeba3bffaacd0647e5f5c7ebb3ba49e6a11bce90f07", + "bytes": 8868 + }, + { + "path": "src/shared/lib/projectStartFlow.ts", + "sha256": "0a2d148ccf59d1bc13893fb8940694e61f65c1bdfd7f0261ca4e88ce5d572561", + "bytes": 15195 + }, + { + "path": "src/shared/lib/projectWizard.ts", + "sha256": "f39792eb5a4889cda50502bc1d8a5fe3e08d0a85052a056ea96a13edf0dd24a7", + "bytes": 29916 + }, + { + "path": "src/shared/lib/projectWizardProgress.test.ts", + "sha256": "6c7c7aa223f0d8249dafe8baecd38d842648cb69644e48a34c893c4d147c0f40", + "bytes": 1728 + }, + { + "path": "src/shared/lib/projectWorkState.test.ts", + "sha256": "1e066a5464e2fd509f0190f7a986534e85fed0c6b6b26c2965a8546c4144eec8", + "bytes": 10295 + }, + { + "path": "src/shared/lib/projectWorkState.ts", + "sha256": "eb0de603ee687f4efd5e1c2ba91a27bd56bf31b6f4b81aa05a596fd914d159d7", + "bytes": 26091 + }, + { + "path": "src/shared/lib/projectbrainActions.ts", + "sha256": "533fbd588168478c5df0501c93af6b50531771d5fb0fd44d7a94e89f6e7f041d", + "bytes": 12770 + }, + { + "path": "src/shared/lib/projectbrainSelectors.ts", + "sha256": "3d231febc35f9510d0543f9956e7b068844525ecfcc472e2a76400d3a91a3dd8", + "bytes": 6998 + }, + { + "path": "src/shared/lib/rcAudit.ts", + "sha256": "69b857f16c1979595a3387ad5ba5e4ae4e323f54de58f6d18710136596574296", + "bytes": 13609 + }, + { + "path": "src/shared/lib/releaseCandidateFinalization.ts", + "sha256": "a061fc72d36505b4bfc41e4685207037dbe79e53f94af00152a4c7fd28718a40", + "bytes": 3621 + }, + { + "path": "src/shared/lib/releaseCenterGuidance.ts", + "sha256": "34f943c7847d7f254bb2cd5a5187fc9f7cdc613814a37234f8cdc8c9f53d768e", + "bytes": 4415 + }, + { + "path": "src/shared/lib/releaseCenterImproveAction.ts", + "sha256": "d915f4d2d061395f6a30d68fb7008a2f3b4f8106653a07f434bc2969106d5625", + "bytes": 5363 + }, + { + "path": "src/shared/lib/releaseCenterQuality.ts", + "sha256": "d01dbff20ce7fc7da48eeed579029db5543b75c6a8189d35d266c173e9168c7b", + "bytes": 5373 + }, + { + "path": "src/shared/lib/releaseEvidence.ts", + "sha256": "b27d424e7c5dede4907d521123e805f13c6dd0dec9413bfe1ceb805e75d26052", + "bytes": 6743 + }, + { + "path": "src/shared/lib/releaseEvidenceHygiene.ts", + "sha256": "6466bc5c5ebcefc046b6a8e02929b6031d2b750a5fb887da7f04d8423f90a69b", + "bytes": 2527 + }, + { + "path": "src/shared/lib/releaseEvidenceLane.ts", + "sha256": "e27df890e25dcb3e7ba3b6915bd81384dfe0c5cafdcc043e6959ebfb7de894f0", + "bytes": 10087 + }, + { + "path": "src/shared/lib/releaseEvidenceReview.ts", + "sha256": "e43cba25c77b7d194815a196d5fc1fe0af73a918d1cc09508e05a5cffef23096", + "bytes": 4090 + }, + { + "path": "src/shared/lib/releaseNoteArchive.ts", + "sha256": "2082877e0aa931948934ecd3456c2807073179b81daedba770d1d3886f4e062f", + "bytes": 2800 + }, + { + "path": "src/shared/lib/releaseOperatorChecklist.ts", + "sha256": "40b31792922dcdb6bd34559e69cbc0e46da50fe1e5946352173c0e2cde123b84", + "bytes": 6132 + }, + { + "path": "src/shared/lib/releaseOperatorGuide.test.ts", + "sha256": "590ede4aeb31bdd13edbd919165e2daea8e69d593fabe26edebcb69a48766f53", + "bytes": 1347 + }, + { + "path": "src/shared/lib/releaseOperatorGuide.ts", + "sha256": "f4487c68c63d096055150ec3b4f6faf50d601ddc03af40293952488c5d8a3456", + "bytes": 3902 + }, + { + "path": "src/shared/lib/releaseRcGoNoGo.ts", + "sha256": "b12cef4e5ee0bef5d1bcf026183541b809bbe891518890f560f6cf745aef08a0", + "bytes": 2804 + }, + { + "path": "src/shared/lib/releaseReadiness.ts", + "sha256": "36ead7a69b9a6284f37352e11fbde5332dbe21d9ad6bb4deb0f73a6afb4fe995", + "bytes": 6252 + }, + { + "path": "src/shared/lib/releaseReadinessExport.ts", + "sha256": "4acae0c2e3f0727eb8e190cd712b5a334f215f8790ffb02aa984c9b9f492c343", + "bytes": 3312 + }, + { + "path": "src/shared/lib/releaseReadinessProofFreshness.ts", + "sha256": "7a17aefd4c11e2477dd5ddc854202cadf3dc2b5cc36e685b853ff8dd8999fb71", + "bytes": 4807 + }, + { + "path": "src/shared/lib/releaseSignoff.ts", + "sha256": "760fe3cf66054a6229ec2990a3e81b6c2794ef1e9cf08ac02eea22777c71e19c", + "bytes": 8383 + }, + { + "path": "src/shared/lib/releaseTagChecklist.ts", + "sha256": "a406cf5d1afe4f3bc786972a0f9fde85f2fb4a5fa0ba855e028759cb74593d10", + "bytes": 2719 + }, + { + "path": "src/shared/lib/releaseValidationContract.test.ts", + "sha256": "4948660461fad7dcdc9a220f7dc5b0b4e8875d0c936630552d5aba26a2dc1582", + "bytes": 753 + }, + { + "path": "src/shared/lib/repositoryIntake.ts", + "sha256": "ad8a027dfa3343e3258f15f110d3fda82512667e023d78c41b1bb6ff59f8947e", + "bytes": 25953 + }, + { + "path": "src/shared/lib/reviewDecisionReceipt.ts", + "sha256": "2ce628c2bcf06a895143b1aad31a6f9f9366b726f3b29b90c88d4058b65a6525", + "bytes": 11875 + }, + { + "path": "src/shared/lib/routeMotionAccessibility.test.ts", + "sha256": "81dfa20ffe6b71a77b601ce750aabe1280aabb61502abb462ba77a00f187a95c", + "bytes": 546 + }, + { + "path": "src/shared/lib/routeRegistry.ts", + "sha256": "63f045e23dfcde75481dc41139463279261ec36e7f846125a1b9deebbd42d018", + "bytes": 2191 + }, + { + "path": "src/shared/lib/runnerlessDeliveryPolicy.test.ts", + "sha256": "6b7ae8844bd3453ec7346a5c0765c35c7fc0fda17d410a108c062b0bb85f936a", + "bytes": 1424 + }, + { + "path": "src/shared/lib/safeData.ts", + "sha256": "6c725068a13fb8328a8f89a15aaccdae65b1655ea74da1c59736bc859d56bc09", + "bytes": 6645 + }, + { + "path": "src/shared/lib/taskQueue.test.ts", + "sha256": "65e5d169102639b9a5e2902b1ca5d1a1186b761b5a082fe7ee9bcb9f88df65d5", + "bytes": 1670 + }, + { + "path": "src/shared/lib/taskQueue.ts", + "sha256": "f2da5fde660b65a7166a71b99b28c5a528f3614d08dd1704a4668752c25c38b6", + "bytes": 1192 + }, + { + "path": "src/shared/lib/userExperience.test.ts", + "sha256": "c735348515d8b8fd3617e06ab72ad40c130008ae6c06bb4a8baa0720554bffa8", + "bytes": 3099 + }, + { + "path": "src/shared/lib/userExperience.ts", + "sha256": "b35b341ac669c0022783911366ffc867befb295abc51ce8c4d024f3849ddda06", + "bytes": 6989 + }, + { + "path": "src/shared/lib/userFacingError.test.ts", + "sha256": "f97f8243c95597477249e39e470c755123defaf14058e54dc25d11012b481cdb", + "bytes": 753 + }, + { + "path": "src/shared/lib/userFacingError.ts", + "sha256": "50da2b00eef80ec88b55f868a79b76d6b050abc8fd02ba13d4a55408c2b551c4", + "bytes": 1026 + }, + { + "path": "src/shared/lib/utils.ts", + "sha256": "982bf16c5d13d9ab58c3d3a6485c39fe44f3796f8f5163d880f8818432b22ee0", + "bytes": 168 + }, + { + "path": "src/shared/lib/uxConsolidationPolicy.test.ts", + "sha256": "00dbe8ec966b828e7808c0ab41d1343d5e7eb1a56de32a2c26aad71547ed4cd8", + "bytes": 3388 + }, + { + "path": "src/shared/lib/workGraph.test.ts", + "sha256": "54a660793796dc66e83af7d25c47f85d9eba3d53910adac41f5b320b5d135df7", + "bytes": 2439 + }, + { + "path": "src/shared/lib/workGraph.ts", + "sha256": "6a25acf93db14d31358e0cd5531a9764c5d0ed69c7af2834654a335903fa3e60", + "bytes": 3556 + }, + { + "path": "src/shared/pages/NotFound.tsx", + "sha256": "caff8beffe6d3ca660cafbaa15e7c32f5721873f315d6cfdf6b389b5e6c53b0a", + "bytes": 987 + }, + { + "path": "src/shared/styles/globals.css", + "sha256": "a709627793461873923fb43efd40c74374cb3ef1a6d2ec436e55b980ea749646", + "bytes": 20380 + }, + { + "path": "src/utils/toast.ts", + "sha256": "328f94e9c711410b23188484a0a2f38886d7732d5c375cefcba442ab6195783f", + "bytes": 356 + }, + { + "path": "src/vite-env.d.ts", + "sha256": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5", + "bytes": 38 + }, + { + "path": "tailwind.config.ts", + "sha256": "3941e891c078d356f5583c98c1002c94d07a3e54e84db4663c4abf717ab17051", + "bytes": 3656 + }, + { + "path": "tsconfig.app.json", + "sha256": "f4252e00426bac054958a9efc34bbdcb5f9ef1d5b2ed57c93e16243254f7d104", + "bytes": 652 + }, + { + "path": "tsconfig.json", + "sha256": "1914430cf76f7e28628f4a14b7b09e256f5ac5263e816c63691b08cf083994a8", + "bytes": 381 + }, + { + "path": "tsconfig.node.json", + "sha256": "0bb30d58c3ae8abdcad4ccee03cfe6409ff10a8a7c04777467c0095f4e0593dc", + "bytes": 538 + }, + { + "path": "vite.config.ts", + "sha256": "1ba86056e194d3def569e38bd2d13c42a1190bfe067c16ce9dc198cdfce6ad56", + "bytes": 2343 + }, + { + "path": "vitest.config.ts", + "sha256": "efe49c2df73898a5d58070c5ea5a8015b2ef269b2bf17d81fdf5ba1fb0cf5bc7", + "bytes": 547 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..207a29c --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# ProjectBrain + +**A self-hosted operating workspace for software projects, from durable context to validated delivery.** + +ProjectBrain keeps project knowledge, connected repository evidence, work requests, human decisions and delivery results together. Its normal flow starts with a plain-language request and ends with an understandable result and the evidence behind it. Technical work graphs, executors and release details remain available when needed. + +## The project journey + +```mermaid +flowchart LR + A[Project context and repository] --> B[Request and constraints] + B --> C[Plan and governed execution] + C --> D[Validate and recover] + D --> E[Result and evidence] + C --> F[Human decision when required] + F --> C +``` + +## What it provides + +- Portfolio orientation: attention, active work, recovery and recent delivery across projects. +- Project overview, request workspace, attention inbox, results and chronological activity. +- Repository intake and analysis, project knowledge with provenance/freshness/conflict signals, and supplementary source libraries. +- Durable work graphs with dependencies, priorities, bounded execution recovery and explicit completion criteria. +- Governed Gitea execution using deterministic work branches, exact-head Actions evidence, pull requests and controlled merges. +- Review, evidence, release and handoff surfaces that distinguish implemented, validated, merged, deployed and runtime-verified work. +- Session/context packs and MCP integration for coordinating external reasoning and execution clients. +- Database-backed persistence with Prisma/PostgreSQL and separate analysis/reconciliation workers. + +Capabilities depend on configured integrations and verified execution paths. A reachable endpoint, mock success or passing build does not establish production deployment capability. High-risk or destructive work remains a human decision boundary. + +## Start with a preview + +The frontend has a development-only mock client for exploring the product without a database or repository credentials: + +```bash +pnpm install --frozen-lockfile +VITE_PROJECTBRAIN_CLIENT_MODE=mock pnpm dev +``` + +Open the Vite URL shown in the terminal. In PowerShell, set `$env:VITE_PROJECTBRAIN_CLIENT_MODE='mock'` before `pnpm dev`. Mock state is synthetic development data and is not production readiness evidence. Production builds reject explicit mock mode. + +## Interface preview + +These screenshots show the existing synthetic WideFrog/Nebula development fixtures. The current product UI includes Dutch labels; this README is in English. No live project inventory, repository credentials or private runtime data is shown. + +![Synthetic project catalogue and next action](docs/screenshots/mock-portfolio.png) + +![Synthetic project overview and execution state](docs/screenshots/mock-project-overview.png) + +## Deploy your own instance + +Requirements: **Node.js 24**, **pnpm 11.5.2**, Docker Compose and persistent PostgreSQL storage. + +```bash +git clone https://gitea.itworx.tech/Jens/projectbrain-Public.git +cd projectbrain-Public +cp .env.example .env +# Configure independent database, owner-login and authentication secrets. +docker compose up --build +``` + +The default web port is `1209`. The integrated stack runs the React frontend behind nginx, Fastify API, PostgreSQL and background workers. Production uses `PROJECTBRAIN_DATA_SOURCE=database` and `VITE_PROJECTBRAIN_CLIENT_MODE=real`; an empty frontend API URL uses the same-origin `/api` proxy. The server rejects placeholder authentication secrets in production. + +Persist the database and application state independently of the checkout. Before upgrading, back up state, review Prisma migrations and verify authenticated workflows after deployment. The single-container Unraid build remains available in `Dockerfile.unraid`; it is a bootstrap/packaging option, not evidence that a live managed target has been promoted. + +The canonical private deployment uses a separate Deployment Controller: reviewed merges, validation on the exact merge SHA, immutable promotion, controller receipts and runtime checks. Public source synchronization does not deploy that private target or replace its release procedure. + +## Connect repositories and execution + +Set Gitea service credentials in the backend environment. Give the read connector only the intended repository access; configure the separate write token only when governed execution is required. Keep tokens, webhook secrets and MCP service credentials out of frontend builds and source control. + +Create or import a project, connect its repository, review the known context and capabilities, then submit a request with clear scope and constraints. Use **Your attention** for genuine product/risk decisions, **Results** for completed work and evidence, and **Activity** for recovery and history. Repository evidence remains the primary technical source; additional sources supply requirements or references outside that repository. + +Optional Microsoft Entra login requires a registered Web callback and server-side tenant/client credentials; see [Entra configuration](docs/MICROSOFT_ENTRA_LOGIN.md). MCP is opt-in and requires its own authentication and capability policy. + +## Develop and validate + +```bash +pnpm install --frozen-lockfile +pnpm dev +# In another terminal: +pnpm --filter projectbrain-api run dev +``` + +For local database work, start `docker compose -f docker-compose.dev.yml up -d postgres`, set a local `DATABASE_URL`, generate Prisma and apply migrations through the backend scripts. The supplied development database credentials and exposed database port are for isolated development only. + +```bash +pnpm run typecheck +pnpm run lint +pnpm run test +pnpm run build +pnpm --filter projectbrain-api run typecheck +pnpm --filter projectbrain-api run test +``` + +Database/browser release qualification needs disposable infrastructure and authenticated flows. Fixture tests alone prove development behavior. See the [product contract](docs/UX_AUTONOMY_PRODUCT_CONTRACT.md) and [backend architecture decision](docs/REAL_BACKEND_ARCHITECTURE_DECISION.md) for the intended boundaries. + +## Technical map + +| Directory | Responsibility | +| --- | --- | +| `src/` | React product workspace, API clients and synthetic preview data | +| `backend/` | Fastify API, persistence, workers, Prisma schema and migrations | +| `packages/contracts/` | Shared typed contracts | +| `docker/` | nginx and self-hosted packaging templates | +| `e2e/` | Development browser regression tests | +| `docs/` | Selected durable product, integration and licensing documentation | + +## Public source and licensing + +This is the curated public source edition of ProjectBrain 0.7.1. Reviewed changes are synchronized from the private canonical repository after merges to `main`. Private Git history, internal workflows, deployment evidence, operator records and runtime credentials are not part of this distribution. Generic deployment examples must be configured for your environment. + +No repository-wide software license has been selected. Public visibility does not grant reuse, modification or redistribution rights. The existing [licensing status](docs/LICENSING.md) is preserved; third-party dependency and asset terms require separate review. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6eeeb7d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Reporting vulnerabilities + +Report suspected vulnerabilities privately to the repository owner. Do not publish Gitea credentials, repository write tokens, project-private source material, execution/session packs containing sensitive data, operator credentials, private infrastructure details or exploit-sensitive evidence in a public issue. + +Include the affected release/commit, component, minimal reproduction conditions using synthetic project data where possible, expected and observed behaviour, impact, and whether the issue affects authentication, repository delivery, project isolation, approvals, MCP boundaries, archive/session-pack generation, webhook processing, audit history or deployment. + +## Security and publication boundaries + +ProjectBrain manages project context and governed repository delivery. Source material imported from private projects does not become public merely because ProjectBrain can index or package it. Session-pack exports must preserve their documented bounded scope and must not silently become repository backups. + +Repository writes, merges and other external effects must remain attributable, validated and policy-controlled. Never commit live `.env` files, service credentials, private project archives or unredacted operational data. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..fc3b039 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,40 @@ +# ProjectBrain API local development defaults +NODE_ENV=development +HOST=0.0.0.0 +PORT=4000 +LOG_LEVEL=info + +# Database mode is the normal self-hosted default. Use fixture for safe offline API previews. +PROJECTBRAIN_DATA_SOURCE=database +PROJECTBRAIN_SEED_ON_START=true + +# Required for Prisma commands and database mode. +DATABASE_URL=postgresql://projectbrain:projectbrain@localhost:5432/projectbrain?schema=public + +# Required authentication bootstrap. Generate unique production values. +PROJECTBRAIN_OWNER_USERNAME=owner +PROJECTBRAIN_OWNER_PASSWORD=replace-with-a-unique-password-of-at-least-12-characters +PROJECTBRAIN_AUTH_SECRET=replace-with-at-least-32-random-characters +PROJECTBRAIN_SESSION_TTL_HOURS=24 +PROJECTBRAIN_TRUST_PROXY=false + +# Optional Gitea/MCP integration. The webhook secret must match the generic Gitea webhook secret. +PROJECTBRAIN_GITEA_URL= +PROJECTBRAIN_GITEA_TOKEN= +PROJECTBRAIN_GITEA_WRITE_TOKEN= +PROJECTBRAIN_GITEA_WEBHOOK_SECRET= +PROJECTBRAIN_MCP_ENABLED=false +PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE=1200 +PROJECTBRAIN_ANALYSIS_WORKER_PORT=4001 +PROJECTBRAIN_ANALYSIS_POLL_MS=1000 +PROJECTBRAIN_ANALYSIS_BATCH_SIZE=5 +PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY=2 +PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS=1800 +PROJECTBRAIN_ANALYSIS_STALE_SECONDS=300 +PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440 +# When enabled, every writable Gitea repository is idempotently linked to a +# matching ProjectBrain project. Exclusions remain durable and are respected. +PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true +PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60 +PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400 +PROJECTBRAIN_PUBLIC_URL= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..2d0c064 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +coverage/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6eed140 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,31 @@ +FROM node:24-alpine + +WORKDIR /app +ENV CI=true +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV npm_config_fetch_warn_timeout=60000 +ENV PRISMA_SKIP_POSTINSTALL_GENERATE=true +# Prisma engines require OpenSSL at runtime. Alpine images do not always +# include it, which makes prisma migrate deploy fail with schema-engine JSON +# parse errors such as: Error loading shared library libssl. +RUN apk add --no-cache openssl libc6-compat ca-certificates +RUN corepack enable + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY backend/package.json backend/package.json +COPY packages/contracts/package.json packages/contracts/package.json +RUN pnpm install --frozen-lockfile --filter projectbrain-api... > /tmp/pnpm-install.log 2>&1 || (cat /tmp/pnpm-install.log; exit 1); awk 'index($0, "[W" "ARN] Request took") == 0 { print }' /tmp/pnpm-install.log + +COPY --chown=node:node packages/contracts packages/contracts +COPY --chown=node:node backend backend + +WORKDIR /app/backend +RUN pnpm run prisma:generate && pnpm run build && chmod +x scripts/docker-entrypoint.sh + +EXPOSE 4000 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=10 CMD node -e "fetch('http://127.0.0.1:4000/api/system/readiness').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +USER node + +CMD ["sh", "scripts/docker-entrypoint.sh"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..963c206 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,126 @@ +# ProjectBrain API Backend + +This is the dedicated Fastify TypeScript backend for ProjectBrain. + +It currently provides fixture-backed API endpoints matching the frontend/API contract while the persistent PostgreSQL/Prisma transition is being prepared. + +## Current Status + +Implemented backend phases: + +- R1: Fastify skeleton, health endpoint, envelopes, request IDs, error handling. +- R2: Projects, Workspace, and Project DNA read endpoints. +- R3: Feature Groups, Features, and Evidence read endpoints. +- R4: Context Packs and deterministic Prompt generation. +- R5: Checks and Review Board actions with idempotency. +- R6: Builds, Regressions, and Snapshots. +- R7: Knowledge, Memory Rules, Relationships, and Insights. +- R8: Capture Center, Review Queue, and AI Actions. +- P1: Prisma/PostgreSQL scaffold. +- P2: Project/Workspace/DNA database repository path prepared behind a data-source flag, pending local DB smoke verification. + +Fixture mode remains available for deterministic previews. Database mode provides production persistence, owner/member authentication, Microsoft Entra login, Gitea integration, remote MCP and the durable portfolio control plane. ProjectBrain intentionally delegates model reasoning to ChatGPT through MCP and therefore has no embedded AI provider by default. + +## Run + +```bash +cd backend +pnpm install +pnpm run dev +``` + +The default server listens on `0.0.0.0:4000`. + +## Scripts + +- `pnpm run dev` starts the backend with `tsx watch`. +- `pnpm run build` compiles TypeScript to `dist/`. +- `pnpm run start` runs the compiled server. +- `pnpm run typecheck` runs TypeScript without emitting files. +- `pnpm run lint` currently aliases typecheck. +- `pnpm run prisma:validate` validates the Prisma schema. +- `pnpm run prisma:generate` generates Prisma Client. +- `pnpm run db:migrate:dev` runs local development migrations when `DATABASE_URL` is available. +- `pnpm run db:seed` runs the local seed when `DATABASE_URL` is available. +- `pnpm run smoke:p2` verifies Project/Workspace/DNA database-mode behavior when the backend is running in database mode. + +## Health + +`GET /api/health` + +```json +{ + "data": { + "status": "ok", + "service": "projectbrain-api", + "version": "0.1.0", + "environment": "development" + }, + "meta": { + "requestId": "req-...", + "timestamp": "2026-06-06T00:00:00.000Z" + } +} +``` + +Incoming `x-request-id` headers are respected and echoed in both the response header and envelope metadata. + +## Response Envelope + +Success: + +```json +{ + "data": {}, + "meta": { + "requestId": "req-...", + "timestamp": "..." + } +} +``` + +Error: + +```json +{ + "error": { + "code": "PROJECT_NOT_FOUND", + "message": "Project not found", + "validation": [] + }, + "meta": { + "requestId": "req-...", + "timestamp": "..." + } +} +``` + +## Data Source Mode + +```text +PROJECTBRAIN_DATA_SOURCE=fixture | database +``` + +Default is `fixture`. + +When `database` is used, `DATABASE_URL` is required. The database-backed path is currently prepared for Project/Workspace/DNA parity only. Other endpoint families remain fixture-backed. + +## Local Postgres + +See: + +- `../docs/PERSISTENCE_LOCAL_POSTGRES_SETUP.md` +- `../docs/P2_LOCAL_RUNTIME_VERIFICATION.md` + +## Endpoint Index + +See `../docs/REAL_BACKEND_ENDPOINT_INDEX.md` for the current R1-R8 endpoint overview. + +## Not Included Yet + +- Authenticated private-network deployment is supported; public multi-tenant SaaS operation is not. +- No real AI calls. +- No upload/import pipeline. +- No Redis/BullMQ worker. +- No full database-backed replacement for R3-R8. +- No frontend client switch by default. diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c2b0d91 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,69 @@ +{ + "name": "projectbrain-api", + "version": "0.7.1", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "pnpm run prisma:generate && tsc -p tsconfig.json", + "start": "node dist/server.js", + "typecheck": "pnpm run prisma:generate && tsc -p tsconfig.json --noEmit", + "lint": "pnpm run prisma:generate && tsc -p tsconfig.json --noEmit", + "test": "pnpm run prisma:generate && vitest run", + "test:coverage": "pnpm run prisma:generate && vitest run --coverage", + "test:database": "vitest run test/database-authentication.test.ts test/database-repository-sync.test.ts test/database-autonomy.test.ts", + "prisma:generate": "node scripts/prisma-generate.mjs", + "prisma:validate": "node scripts/prisma-validate.mjs", + "prisma:format": "prisma format --schema prisma/schema.prisma", + "db:migrate:dev": "prisma migrate dev --schema prisma/schema.prisma", + "db:seed": "tsx prisma/seed.ts", + "smoke:p2": "node scripts/smoke-p2.mjs", + "smoke:p3": "node scripts/smoke-p3.mjs", + "smoke:fixtures": "node scripts/smoke-fixture-api.mjs", + "smoke:real-read-pilot": "node scripts/smoke-real-read-pilot.mjs", + "smoke:vertical-slice": "node scripts/smoke-vertical-slice.mjs", + "smoke:runtime-suite": "node scripts/smoke-runtime-suite.mjs", + "smoke:release-evidence": "node scripts/smoke-release-evidence.mjs", + "smoke:database-suite": "node scripts/smoke-database-suite.mjs", + "smoke:p4": "node scripts/smoke-p4.mjs", + "smoke:p5": "node scripts/smoke-p5.mjs", + "smoke:p6": "node scripts/smoke-p6.mjs", + "smoke:p7": "node scripts/smoke-p7.mjs", + "smoke:project-loop": "node scripts/smoke-project-loop.mjs", + "smoke:project-create": "node scripts/smoke-project-create.mjs", + "smoke:p8": "node scripts/smoke-p8.mjs", + "smoke:blueprints": "node scripts/smoke-blueprints-v52.mjs", + "db:deploy": "prisma migrate deploy --schema prisma/schema.prisma", + "db:status": "prisma migrate status --schema prisma/schema.prisma", + "db:verify-restore": "node scripts/verify-restored-database.mjs", + "db:validate": "node scripts/prisma-validate.mjs", + "audit:contract": "node scripts/audit-backend-contract.mjs", + "smoke:system-readiness": "node scripts/smoke-system-readiness.mjs", + "smoke:brainstorms": "node scripts/smoke-brainstorms.mjs", + "smoke:initiatives": "node scripts/smoke-initiatives.mjs" + }, + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/formbody": "^8.0.2", + "@fastify/helmet": "^13.1.0", + "@fastify/rate-limit": "^11.1.0", + "@modelcontextprotocol/sdk": "^1.30.0", + "@prisma/client": "^5.22.0", + "@projectbrain/contracts": "workspace:*", + "fastify": "^5.12.4", + "jose": "^6.2.9", + "prisma": "5.22.0", + "tsx": "^4.23.12", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@vitest/coverage-v8": "^4.1.10", + "typescript": "^5.7.3", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=24 <25", + "pnpm": "11.5.2" + } +} diff --git a/backend/prisma/migrations/20260606000000_init_project_workspace/migration.sql b/backend/prisma/migrations/20260606000000_init_project_workspace/migration.sql new file mode 100644 index 0000000..e4e5927 --- /dev/null +++ b/backend/prisma/migrations/20260606000000_init_project_workspace/migration.sql @@ -0,0 +1,617 @@ +-- CreateEnum +CREATE TYPE "ProjectStatus" AS ENUM ('active', 'archived', 'paused'); + +-- CreateEnum +CREATE TYPE "FeatureStatus" AS ENUM ('todo', 'in_progress', 'done'); + +-- CreateEnum +CREATE TYPE "FeatureLifecycle" AS ENUM ('feature_candidate', 'planned', 'active', 'implemented', 'verified', 'production_proven', 'deprecated', 'archived'); + +-- CreateEnum +CREATE TYPE "ApprovalState" AS ENUM ('pending', 'approved', 'rejected', 'blocked'); + +-- CreateEnum +CREATE TYPE "EvidenceStrength" AS ENUM ('weak', 'medium', 'strong', 'verified'); + +-- CreateEnum +CREATE TYPE "VerificationState" AS ENUM ('unverified', 'needs_evidence', 'verified', 'blocked'); + +-- CreateEnum +CREATE TYPE "ReviewStatus" AS ENUM ('pending', 'accepted', 'rejected', 'merged', 'converted'); + +-- CreateEnum +CREATE TYPE "EntityType" AS ENUM ('project', 'project_dna', 'workspace_state', 'feature_group', 'feature', 'evidence', 'context_pack', 'context_pack_section', 'memory_rule', 'knowledge_item', 'review_queue_item', 'idempotency_record', 'entity_relation', 'activity_event'); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "readableId" TEXT, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "domain" TEXT NOT NULL, + "status" "ProjectStatus" NOT NULL DEFAULT 'active', + "health" INTEGER NOT NULL DEFAULT 0, + "healthStatus" TEXT NOT NULL, + "activeContextPackId" TEXT, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProjectDNA" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "vision" TEXT NOT NULL, + "goalsJson" JSONB NOT NULL, + "nonGoalsJson" JSONB NOT NULL, + "targetAudience" TEXT NOT NULL, + "principlesJson" JSONB NOT NULL, + "metricsJson" JSONB NOT NULL, + "maturityStage" TEXT NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectDNA_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkspaceState" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "activeContextPackId" TEXT, + "primaryObjective" TEXT NOT NULL, + "activeSection" TEXT NOT NULL, + "notesJson" JSONB NOT NULL, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WorkspaceState_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FeatureGroup" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "name" TEXT NOT NULL, + "description" TEXT, + "focusArea" TEXT, + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FeatureGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Feature" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "featureGroupId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "status" "FeatureStatus" NOT NULL DEFAULT 'todo', + "lifecycle" "FeatureLifecycle" NOT NULL DEFAULT 'feature_candidate', + "intent" TEXT, + "confidence" INTEGER NOT NULL DEFAULT 0, + "health" INTEGER NOT NULL DEFAULT 0, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "evidenceStrength" "EvidenceStrength", + "verificationState" "VerificationState", + "verifiedAt" TIMESTAMP(3), + "verifiedBy" TEXT, + "trustJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Feature_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Evidence" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "type" TEXT NOT NULL, + "source" TEXT NOT NULL, + "confidence" INTEGER NOT NULL DEFAULT 0, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'weak', + "verificationState" "VerificationState", + "reason" TEXT NOT NULL, + "trustJson" JSONB, + "provenanceJson" JSONB, + "metadataJson" JSONB, + "lastSeen" TIMESTAMP(3) NOT NULL, + "verifiedAt" TIMESTAMP(3), + "verifiedBy" TEXT, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Evidence_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContextPack" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "title" TEXT NOT NULL, + "category" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "generatedBy" TEXT NOT NULL, + "confidence" INTEGER NOT NULL DEFAULT 0, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "qualityJson" JSONB, + "trustJson" JSONB, + "provenanceJson" JSONB, + "metadataJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContextPack_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContextPackSection" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "contextPackId" TEXT NOT NULL, + "slug" TEXT, + "title" TEXT NOT NULL, + "sectionType" TEXT NOT NULL, + "orderIndex" INTEGER NOT NULL, + "contentJson" JSONB NOT NULL, + "metadataJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContextPackSection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MemoryRule" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "title" TEXT NOT NULL, + "rule" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "category" TEXT NOT NULL, + "severity" TEXT NOT NULL, + "includeInContextPacks" BOOLEAN NOT NULL DEFAULT false, + "confidence" INTEGER NOT NULL DEFAULT 0, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "evidenceStrength" "EvidenceStrength", + "verificationState" "VerificationState", + "verifiedAt" TIMESTAMP(3), + "verifiedBy" TEXT, + "trustJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MemoryRule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "KnowledgeItem" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "title" TEXT NOT NULL, + "category" TEXT NOT NULL, + "content" TEXT NOT NULL, + "source" TEXT NOT NULL, + "confidence" INTEGER NOT NULL DEFAULT 0, + "includeInContextPacks" BOOLEAN NOT NULL DEFAULT false, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "evidenceStrength" "EvidenceStrength", + "verificationState" "VerificationState", + "verifiedAt" TIMESTAMP(3), + "verifiedBy" TEXT, + "trustJson" JSONB, + "provenanceJson" JSONB, + "metadataJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "KnowledgeItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ReviewQueueItem" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "priority" TEXT NOT NULL, + "status" "ReviewStatus" NOT NULL DEFAULT 'pending', + "source" TEXT NOT NULL, + "confidence" INTEGER NOT NULL DEFAULT 0, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'weak', + "verificationState" "VerificationState" NOT NULL DEFAULT 'unverified', + "suggestedAction" TEXT NOT NULL, + "notes" TEXT, + "payloadJson" JSONB, + "provenanceJson" JSONB, + "createdEntityType" "EntityType", + "createdEntityId" TEXT, + "targetEntityType" "EntityType", + "targetEntityId" TEXT, + "verifiedAt" TIMESTAMP(3), + "verifiedBy" TEXT, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReviewQueueItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "IdempotencyRecord" ( + "id" TEXT NOT NULL, + "projectId" TEXT, + "method" TEXT NOT NULL, + "path" TEXT NOT NULL, + "key" TEXT NOT NULL, + "requestHash" TEXT NOT NULL, + "statusCode" INTEGER NOT NULL, + "responseJson" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EntityRelation" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "sourceEntityType" "EntityType" NOT NULL, + "sourceEntityId" TEXT NOT NULL, + "targetEntityType" "EntityType" NOT NULL, + "targetEntityId" TEXT NOT NULL, + "relationshipType" TEXT NOT NULL, + "metadataJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EntityRelation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ActivityEvent" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "entityType" "EntityType" NOT NULL, + "entityId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "actor" TEXT NOT NULL, + "payloadJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ActivityEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); + +-- CreateIndex +CREATE INDEX "Project_status_idx" ON "Project"("status"); + +-- CreateIndex +CREATE INDEX "Project_activeContextPackId_idx" ON "Project"("activeContextPackId"); + +-- CreateIndex +CREATE INDEX "Project_deletedAt_idx" ON "Project"("deletedAt"); + +-- CreateIndex +CREATE INDEX "Project_updatedAt_idx" ON "Project"("updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectDNA_projectId_key" ON "ProjectDNA"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkspaceState_projectId_key" ON "WorkspaceState"("projectId"); + +-- CreateIndex +CREATE INDEX "WorkspaceState_projectId_activeContextPackId_idx" ON "WorkspaceState"("projectId", "activeContextPackId"); + +-- CreateIndex +CREATE INDEX "FeatureGroup_projectId_idx" ON "FeatureGroup"("projectId"); + +-- CreateIndex +CREATE INDEX "FeatureGroup_projectId_deletedAt_idx" ON "FeatureGroup"("projectId", "deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "FeatureGroup_projectId_slug_key" ON "FeatureGroup"("projectId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "FeatureGroup_projectId_readableId_key" ON "FeatureGroup"("projectId", "readableId"); + +-- CreateIndex +CREATE INDEX "Feature_projectId_idx" ON "Feature"("projectId"); + +-- CreateIndex +CREATE INDEX "Feature_projectId_status_idx" ON "Feature"("projectId", "status"); + +-- CreateIndex +CREATE INDEX "Feature_projectId_lifecycle_idx" ON "Feature"("projectId", "lifecycle"); + +-- CreateIndex +CREATE INDEX "Feature_projectId_featureGroupId_idx" ON "Feature"("projectId", "featureGroupId"); + +-- CreateIndex +CREATE INDEX "Feature_projectId_name_idx" ON "Feature"("projectId", "name"); + +-- CreateIndex +CREATE INDEX "Feature_projectId_deletedAt_idx" ON "Feature"("projectId", "deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Feature_projectId_slug_key" ON "Feature"("projectId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Feature_projectId_readableId_key" ON "Feature"("projectId", "readableId"); + +-- CreateIndex +CREATE INDEX "Evidence_projectId_idx" ON "Evidence"("projectId"); + +-- CreateIndex +CREATE INDEX "Evidence_projectId_type_idx" ON "Evidence"("projectId", "type"); + +-- CreateIndex +CREATE INDEX "Evidence_projectId_approvalState_idx" ON "Evidence"("projectId", "approvalState"); + +-- CreateIndex +CREATE INDEX "Evidence_projectId_evidenceStrength_idx" ON "Evidence"("projectId", "evidenceStrength"); + +-- CreateIndex +CREATE INDEX "Evidence_projectId_lastSeen_idx" ON "Evidence"("projectId", "lastSeen"); + +-- CreateIndex +CREATE INDEX "Evidence_projectId_deletedAt_idx" ON "Evidence"("projectId", "deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Evidence_projectId_slug_key" ON "Evidence"("projectId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Evidence_projectId_readableId_key" ON "Evidence"("projectId", "readableId"); + +-- CreateIndex +CREATE INDEX "ContextPack_projectId_idx" ON "ContextPack"("projectId"); + +-- CreateIndex +CREATE INDEX "ContextPack_projectId_category_idx" ON "ContextPack"("projectId", "category"); + +-- CreateIndex +CREATE INDEX "ContextPack_projectId_generatedBy_idx" ON "ContextPack"("projectId", "generatedBy"); + +-- CreateIndex +CREATE INDEX "ContextPack_projectId_createdAt_idx" ON "ContextPack"("projectId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ContextPack_projectId_deletedAt_idx" ON "ContextPack"("projectId", "deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContextPack_projectId_slug_key" ON "ContextPack"("projectId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContextPack_projectId_readableId_key" ON "ContextPack"("projectId", "readableId"); + +-- CreateIndex +CREATE INDEX "ContextPackSection_projectId_idx" ON "ContextPackSection"("projectId"); + +-- CreateIndex +CREATE INDEX "ContextPackSection_contextPackId_orderIndex_idx" ON "ContextPackSection"("contextPackId", "orderIndex"); + +-- CreateIndex +CREATE INDEX "ContextPackSection_projectId_sectionType_idx" ON "ContextPackSection"("projectId", "sectionType"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContextPackSection_contextPackId_slug_key" ON "ContextPackSection"("contextPackId", "slug"); + +-- CreateIndex +CREATE INDEX "MemoryRule_projectId_idx" ON "MemoryRule"("projectId"); + +-- CreateIndex +CREATE INDEX "MemoryRule_projectId_scope_idx" ON "MemoryRule"("projectId", "scope"); + +-- CreateIndex +CREATE INDEX "MemoryRule_projectId_category_idx" ON "MemoryRule"("projectId", "category"); + +-- CreateIndex +CREATE INDEX "MemoryRule_projectId_severity_idx" ON "MemoryRule"("projectId", "severity"); + +-- CreateIndex +CREATE INDEX "MemoryRule_projectId_includeInContextPacks_idx" ON "MemoryRule"("projectId", "includeInContextPacks"); + +-- CreateIndex +CREATE INDEX "MemoryRule_projectId_deletedAt_idx" ON "MemoryRule"("projectId", "deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "MemoryRule_projectId_slug_key" ON "MemoryRule"("projectId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "MemoryRule_projectId_readableId_key" ON "MemoryRule"("projectId", "readableId"); + +-- CreateIndex +CREATE INDEX "KnowledgeItem_projectId_idx" ON "KnowledgeItem"("projectId"); + +-- CreateIndex +CREATE INDEX "KnowledgeItem_projectId_category_idx" ON "KnowledgeItem"("projectId", "category"); + +-- CreateIndex +CREATE INDEX "KnowledgeItem_projectId_source_idx" ON "KnowledgeItem"("projectId", "source"); + +-- CreateIndex +CREATE INDEX "KnowledgeItem_projectId_createdAt_idx" ON "KnowledgeItem"("projectId", "createdAt"); + +-- CreateIndex +CREATE INDEX "KnowledgeItem_projectId_deletedAt_idx" ON "KnowledgeItem"("projectId", "deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "KnowledgeItem_projectId_slug_key" ON "KnowledgeItem"("projectId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "KnowledgeItem_projectId_readableId_key" ON "KnowledgeItem"("projectId", "readableId"); + +-- CreateIndex +CREATE INDEX "ReviewQueueItem_projectId_idx" ON "ReviewQueueItem"("projectId"); + +-- CreateIndex +CREATE INDEX "ReviewQueueItem_projectId_status_idx" ON "ReviewQueueItem"("projectId", "status"); + +-- CreateIndex +CREATE INDEX "ReviewQueueItem_projectId_type_idx" ON "ReviewQueueItem"("projectId", "type"); + +-- CreateIndex +CREATE INDEX "ReviewQueueItem_projectId_priority_idx" ON "ReviewQueueItem"("projectId", "priority"); + +-- CreateIndex +CREATE INDEX "ReviewQueueItem_projectId_createdAt_idx" ON "ReviewQueueItem"("projectId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ReviewQueueItem_projectId_deletedAt_idx" ON "ReviewQueueItem"("projectId", "deletedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ReviewQueueItem_projectId_slug_key" ON "ReviewQueueItem"("projectId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "ReviewQueueItem_projectId_readableId_key" ON "ReviewQueueItem"("projectId", "readableId"); + +-- CreateIndex +CREATE INDEX "IdempotencyRecord_projectId_idx" ON "IdempotencyRecord"("projectId"); + +-- CreateIndex +CREATE INDEX "IdempotencyRecord_expiresAt_idx" ON "IdempotencyRecord"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "IdempotencyRecord_method_path_key_key" ON "IdempotencyRecord"("method", "path", "key"); + +-- CreateIndex +CREATE INDEX "EntityRelation_projectId_idx" ON "EntityRelation"("projectId"); + +-- CreateIndex +CREATE INDEX "EntityRelation_projectId_sourceEntityType_sourceEntityId_idx" ON "EntityRelation"("projectId", "sourceEntityType", "sourceEntityId"); + +-- CreateIndex +CREATE INDEX "EntityRelation_projectId_targetEntityType_targetEntityId_idx" ON "EntityRelation"("projectId", "targetEntityType", "targetEntityId"); + +-- CreateIndex +CREATE INDEX "EntityRelation_projectId_relationshipType_idx" ON "EntityRelation"("projectId", "relationshipType"); + +-- CreateIndex +CREATE INDEX "EntityRelation_projectId_deletedAt_idx" ON "EntityRelation"("projectId", "deletedAt"); + +-- CreateIndex +CREATE INDEX "ActivityEvent_projectId_idx" ON "ActivityEvent"("projectId"); + +-- CreateIndex +CREATE INDEX "ActivityEvent_projectId_entityType_entityId_idx" ON "ActivityEvent"("projectId", "entityType", "entityId"); + +-- CreateIndex +CREATE INDEX "ActivityEvent_projectId_eventType_idx" ON "ActivityEvent"("projectId", "eventType"); + +-- CreateIndex +CREATE INDEX "ActivityEvent_projectId_createdAt_idx" ON "ActivityEvent"("projectId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "ProjectDNA" ADD CONSTRAINT "ProjectDNA_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkspaceState" ADD CONSTRAINT "WorkspaceState_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FeatureGroup" ADD CONSTRAINT "FeatureGroup_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Feature" ADD CONSTRAINT "Feature_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Feature" ADD CONSTRAINT "Feature_featureGroupId_fkey" FOREIGN KEY ("featureGroupId") REFERENCES "FeatureGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Evidence" ADD CONSTRAINT "Evidence_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContextPack" ADD CONSTRAINT "ContextPack_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContextPackSection" ADD CONSTRAINT "ContextPackSection_contextPackId_fkey" FOREIGN KEY ("contextPackId") REFERENCES "ContextPack"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemoryRule" ADD CONSTRAINT "MemoryRule_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "KnowledgeItem" ADD CONSTRAINT "KnowledgeItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ReviewQueueItem" ADD CONSTRAINT "ReviewQueueItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EntityRelation" ADD CONSTRAINT "EntityRelation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260606010000_p5_capture_review_parity/migration.sql b/backend/prisma/migrations/20260606010000_p5_capture_review_parity/migration.sql new file mode 100644 index 0000000..d7e4a5a --- /dev/null +++ b/backend/prisma/migrations/20260606010000_p5_capture_review_parity/migration.sql @@ -0,0 +1,40 @@ +-- P5 adds persistent capture items. ReviewQueueItem already exists in the initial schema. +CREATE TABLE "CaptureItem" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "source" TEXT NOT NULL, + "status" TEXT NOT NULL, + "classificationType" TEXT, + "confidence" INTEGER, + "evidenceStrength" "EvidenceStrength", + "suggestedAction" TEXT, + "linkedReviewItemId" TEXT, + "metadataJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CaptureItem_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "CaptureItem_projectId_slug_key" ON "CaptureItem"("projectId", "slug"); +CREATE UNIQUE INDEX "CaptureItem_projectId_readableId_key" ON "CaptureItem"("projectId", "readableId"); +CREATE INDEX "CaptureItem_projectId_idx" ON "CaptureItem"("projectId"); +CREATE INDEX "CaptureItem_projectId_status_idx" ON "CaptureItem"("projectId", "status"); +CREATE INDEX "CaptureItem_projectId_type_idx" ON "CaptureItem"("projectId", "type"); +CREATE INDEX "CaptureItem_projectId_classificationType_idx" ON "CaptureItem"("projectId", "classificationType"); +CREATE INDEX "CaptureItem_projectId_linkedReviewItemId_idx" ON "CaptureItem"("projectId", "linkedReviewItemId"); +CREATE INDEX "CaptureItem_projectId_createdAt_idx" ON "CaptureItem"("projectId", "createdAt"); +CREATE INDEX "CaptureItem_projectId_deletedAt_idx" ON "CaptureItem"("projectId", "deletedAt"); + +ALTER TABLE "CaptureItem" ADD CONSTRAINT "CaptureItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260606020000_p6_truth_updates_operating_log/migration.sql b/backend/prisma/migrations/20260606020000_p6_truth_updates_operating_log/migration.sql new file mode 100644 index 0000000..6f4c963 --- /dev/null +++ b/backend/prisma/migrations/20260606020000_p6_truth_updates_operating_log/migration.sql @@ -0,0 +1,113 @@ +-- P6 accepted truth, project updates, and operating log persistence. +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'accepted_truth_item'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'project_update'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'operating_log_entry'; + +CREATE TABLE IF NOT EXISTS "AcceptedTruthItem" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "sourceReviewItemId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'accepted', + "source" TEXT NOT NULL, + "confidence" INTEGER NOT NULL DEFAULT 0, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'approved', + "evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'weak', + "verificationState" "VerificationState" NOT NULL DEFAULT 'verified', + "payloadJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'review_queue', + "verifiedAt" TIMESTAMP(3), + "verifiedBy" TEXT, + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AcceptedTruthItem_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "ProjectUpdate" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "sourceTruthItemId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "targetArea" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ready', + "confidence" INTEGER NOT NULL DEFAULT 0, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'weak', + "payloadJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'accepted_truth', + "appliedAt" TIMESTAMP(3), + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProjectUpdate_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "OperatingLogEntry" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "linkedEntityIdsJson" JSONB NOT NULL, + "source" TEXT NOT NULL, + "importance" TEXT NOT NULL DEFAULT 'normal', + "payloadJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'runtime', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "OperatingLogEntry_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_slug_key" ON "AcceptedTruthItem"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_readableId_key" ON "AcceptedTruthItem"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_idx" ON "AcceptedTruthItem"("projectId"); +CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_sourceReviewItemId_idx" ON "AcceptedTruthItem"("projectId", "sourceReviewItemId"); +CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_kind_idx" ON "AcceptedTruthItem"("projectId", "kind"); +CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_status_idx" ON "AcceptedTruthItem"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_createdAt_idx" ON "AcceptedTruthItem"("projectId", "createdAt"); +CREATE INDEX IF NOT EXISTS "AcceptedTruthItem_projectId_deletedAt_idx" ON "AcceptedTruthItem"("projectId", "deletedAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "ProjectUpdate_projectId_slug_key" ON "ProjectUpdate"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "ProjectUpdate_projectId_readableId_key" ON "ProjectUpdate"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_idx" ON "ProjectUpdate"("projectId"); +CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_sourceTruthItemId_idx" ON "ProjectUpdate"("projectId", "sourceTruthItemId"); +CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_targetArea_idx" ON "ProjectUpdate"("projectId", "targetArea"); +CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_status_idx" ON "ProjectUpdate"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_createdAt_idx" ON "ProjectUpdate"("projectId", "createdAt"); +CREATE INDEX IF NOT EXISTS "ProjectUpdate_projectId_deletedAt_idx" ON "ProjectUpdate"("projectId", "deletedAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_slug_key" ON "OperatingLogEntry"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_readableId_key" ON "OperatingLogEntry"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_idx" ON "OperatingLogEntry"("projectId"); +CREATE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_type_idx" ON "OperatingLogEntry"("projectId", "type"); +CREATE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_timestamp_idx" ON "OperatingLogEntry"("projectId", "timestamp"); +CREATE INDEX IF NOT EXISTS "OperatingLogEntry_projectId_deletedAt_idx" ON "OperatingLogEntry"("projectId", "deletedAt"); + +ALTER TABLE "AcceptedTruthItem" ADD CONSTRAINT "AcceptedTruthItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectUpdate" ADD CONSTRAINT "ProjectUpdate_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OperatingLogEntry" ADD CONSTRAINT "OperatingLogEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260606030000_p7_current_state_handoff_codex/migration.sql b/backend/prisma/migrations/20260606030000_p7_current_state_handoff_codex/migration.sql new file mode 100644 index 0000000..3848147 --- /dev/null +++ b/backend/prisma/migrations/20260606030000_p7_current_state_handoff_codex/migration.sql @@ -0,0 +1,147 @@ +-- P7 current state, handoff bundle, Codex task, and Codex import persistence. +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'current_state_report'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'handoff_bundle'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'codex_task'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'codex_run_import'; + +CREATE TABLE IF NOT EXISTS "CurrentStateReport" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "existsJson" JSONB NOT NULL, + "worksJson" JSONB NOT NULL, + "uncertainJson" JSONB NOT NULL, + "brokenJson" JSONB NOT NULL, + "currentRisksJson" JSONB NOT NULL, + "pendingReviewItemIdsJson" JSONB NOT NULL, + "activeContextPackId" TEXT, + "recommendedNextActionsJson" JSONB NOT NULL, + "source" TEXT NOT NULL DEFAULT 'runtime', + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "payloadJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'runtime', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "CurrentStateReport_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "HandoffBundle" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "target" TEXT NOT NULL, + "title" TEXT NOT NULL, + "markdown" TEXT NOT NULL, + "qualityScore" INTEGER NOT NULL DEFAULT 0, + "sourceTruthItemIdsJson" JSONB NOT NULL, + "sourceReviewItemIdsJson" JSONB NOT NULL, + "sourceCaptureItemIdsJson" JSONB NOT NULL, + "sourceProjectUpdateIdsJson" JSONB, + "sourceOperatingLogEntryIdsJson" JSONB, + "status" TEXT NOT NULL DEFAULT 'ready', + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "payloadJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'runtime', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "HandoffBundle_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "CodexTask" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "handoffBundleId" TEXT, + "goal" TEXT NOT NULL, + "contextPackId" TEXT, + "constraintsJson" JSONB NOT NULL, + "relevantFilesJson" JSONB NOT NULL, + "doNotBreakRulesJson" JSONB NOT NULL, + "validationCommandsJson" JSONB NOT NULL, + "expectedReportFormat" TEXT NOT NULL, + "outputImportInstructions" TEXT NOT NULL, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "status" TEXT NOT NULL DEFAULT 'draft', + "payloadJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'handoff', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "CodexTask_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "CodexRunImport" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "codexTaskId" TEXT, + "sourceRun" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "extractedReviewItemIdsJson" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'imported', + "payloadJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'codex_output', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "CodexRunImport_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "CurrentStateReport_projectId_slug_key" ON "CurrentStateReport"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "CurrentStateReport_projectId_readableId_key" ON "CurrentStateReport"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "CurrentStateReport_projectId_idx" ON "CurrentStateReport"("projectId"); +CREATE INDEX IF NOT EXISTS "CurrentStateReport_projectId_generatedAt_idx" ON "CurrentStateReport"("projectId", "generatedAt"); +CREATE INDEX IF NOT EXISTS "CurrentStateReport_projectId_approvalState_idx" ON "CurrentStateReport"("projectId", "approvalState"); +CREATE INDEX IF NOT EXISTS "CurrentStateReport_projectId_deletedAt_idx" ON "CurrentStateReport"("projectId", "deletedAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "HandoffBundle_projectId_slug_key" ON "HandoffBundle"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "HandoffBundle_projectId_readableId_key" ON "HandoffBundle"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_idx" ON "HandoffBundle"("projectId"); +CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_target_idx" ON "HandoffBundle"("projectId", "target"); +CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_status_idx" ON "HandoffBundle"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_createdAt_idx" ON "HandoffBundle"("projectId", "createdAt"); +CREATE INDEX IF NOT EXISTS "HandoffBundle_projectId_deletedAt_idx" ON "HandoffBundle"("projectId", "deletedAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "CodexTask_projectId_slug_key" ON "CodexTask"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "CodexTask_projectId_readableId_key" ON "CodexTask"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "CodexTask_projectId_idx" ON "CodexTask"("projectId"); +CREATE INDEX IF NOT EXISTS "CodexTask_projectId_handoffBundleId_idx" ON "CodexTask"("projectId", "handoffBundleId"); +CREATE INDEX IF NOT EXISTS "CodexTask_projectId_status_idx" ON "CodexTask"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "CodexTask_projectId_approvalState_idx" ON "CodexTask"("projectId", "approvalState"); +CREATE INDEX IF NOT EXISTS "CodexTask_projectId_deletedAt_idx" ON "CodexTask"("projectId", "deletedAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "CodexRunImport_projectId_slug_key" ON "CodexRunImport"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "CodexRunImport_projectId_readableId_key" ON "CodexRunImport"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "CodexRunImport_projectId_idx" ON "CodexRunImport"("projectId"); +CREATE INDEX IF NOT EXISTS "CodexRunImport_projectId_codexTaskId_idx" ON "CodexRunImport"("projectId", "codexTaskId"); +CREATE INDEX IF NOT EXISTS "CodexRunImport_projectId_status_idx" ON "CodexRunImport"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "CodexRunImport_projectId_deletedAt_idx" ON "CodexRunImport"("projectId", "deletedAt"); + +ALTER TABLE "CurrentStateReport" ADD CONSTRAINT "CurrentStateReport_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "HandoffBundle" ADD CONSTRAINT "HandoffBundle_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CodexTask" ADD CONSTRAINT "CodexTask_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CodexTask" ADD CONSTRAINT "CodexTask_handoffBundleId_fkey" FOREIGN KEY ("handoffBundleId") REFERENCES "HandoffBundle"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CodexRunImport" ADD CONSTRAINT "CodexRunImport_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260606040000_p8_docs_knowledge_memory/migration.sql b/backend/prisma/migrations/20260606040000_p8_docs_knowledge_memory/migration.sql new file mode 100644 index 0000000..6f7ccb4 --- /dev/null +++ b/backend/prisma/migrations/20260606040000_p8_docs_knowledge_memory/migration.sql @@ -0,0 +1,36 @@ +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'documentation_item'; + +-- P8 Documentation / Knowledge / Memory persistence + +CREATE TABLE IF NOT EXISTS "DocumentationItem" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "title" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "status" TEXT NOT NULL, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "content" TEXT, + "metadataJson" JSONB, + "trustJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'fixture_seed', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DocumentationItem_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "DocumentationItem_projectId_slug_key" ON "DocumentationItem"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "DocumentationItem_projectId_readableId_key" ON "DocumentationItem"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_idx" ON "DocumentationItem"("projectId"); +CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_kind_idx" ON "DocumentationItem"("projectId", "kind"); +CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_status_idx" ON "DocumentationItem"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_approvalState_idx" ON "DocumentationItem"("projectId", "approvalState"); +CREATE INDEX IF NOT EXISTS "DocumentationItem_projectId_deletedAt_idx" ON "DocumentationItem"("projectId", "deletedAt"); + +ALTER TABLE "DocumentationItem" ADD CONSTRAINT "DocumentationItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260609070000_v52_blueprint_persistence/migration.sql b/backend/prisma/migrations/20260609070000_v52_blueprint_persistence/migration.sql new file mode 100644 index 0000000..fe58f5e --- /dev/null +++ b/backend/prisma/migrations/20260609070000_v52_blueprint_persistence/migration.sql @@ -0,0 +1,117 @@ +-- V52 Blueprint Engine persistence foundation +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'blueprint_graph'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'blueprint_node'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'blueprint_node_run'; + +CREATE TABLE IF NOT EXISTS "BlueprintGraph" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "archetypeId" TEXT NOT NULL, + "archetypeLabel" TEXT NOT NULL, + "depth" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'planned', + "documentRoot" TEXT NOT NULL DEFAULT 'docs/', + "intakeSummary" TEXT NOT NULL, + "assumptionsJson" JSONB NOT NULL, + "exportManifestJson" JSONB NOT NULL, + "recommendedNextActionsJson" JSONB NOT NULL, + "metadataJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'blueprint_engine', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "BlueprintGraph_pkey" PRIMARY KEY ("id"), + CONSTRAINT "BlueprintGraph_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS "BlueprintNode" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "graphId" TEXT NOT NULL, + "templateId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "title" TEXT NOT NULL, + "cluster" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "generationOrder" INTEGER NOT NULL, + "requiredDepth" TEXT NOT NULL, + "dependsOnJson" JSONB NOT NULL, + "dependencyTitlesJson" JSONB NOT NULL, + "blockedByJson" JSONB NOT NULL, + "outputPath" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "requiredContextJson" JSONB NOT NULL, + "qualityChecksJson" JSONB NOT NULL, + "producesJson" JSONB NOT NULL, + "promptPreview" TEXT NOT NULL, + "contextSelectionJson" JSONB NOT NULL, + "documentationItemId" TEXT, + "reviewItemId" TEXT, + "latestRunId" TEXT, + "staleReason" TEXT, + "metadataJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'blueprint_engine', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "BlueprintNode_pkey" PRIMARY KEY ("id"), + CONSTRAINT "BlueprintNode_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "BlueprintNode_graphId_fkey" FOREIGN KEY ("graphId") REFERENCES "BlueprintGraph"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS "BlueprintNodeRun" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "graphId" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'needs_review', + "generatedBy" TEXT NOT NULL DEFAULT 'deterministic', + "inputSnapshotJson" JSONB NOT NULL, + "prompt" TEXT NOT NULL, + "draftMarkdown" TEXT NOT NULL, + "qualityReportJson" JSONB NOT NULL, + "documentationItemId" TEXT, + "reviewItemId" TEXT, + "metadataJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'blueprint_engine', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "BlueprintNodeRun_pkey" PRIMARY KEY ("id"), + CONSTRAINT "BlueprintNodeRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "BlueprintNodeRun_graphId_fkey" FOREIGN KEY ("graphId") REFERENCES "BlueprintGraph"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "BlueprintNodeRun_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "BlueprintNode"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS "BlueprintGraph_projectId_slug_key" ON "BlueprintGraph"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "BlueprintGraph_projectId_readableId_key" ON "BlueprintGraph"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "BlueprintGraph_projectId_idx" ON "BlueprintGraph"("projectId"); +CREATE INDEX IF NOT EXISTS "BlueprintGraph_projectId_archetypeId_idx" ON "BlueprintGraph"("projectId", "archetypeId"); +CREATE INDEX IF NOT EXISTS "BlueprintGraph_projectId_status_idx" ON "BlueprintGraph"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "BlueprintGraph_projectId_deletedAt_idx" ON "BlueprintGraph"("projectId", "deletedAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "BlueprintNode_graphId_templateId_key" ON "BlueprintNode"("graphId", "templateId"); +CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_idx" ON "BlueprintNode"("projectId"); +CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_graphId_idx" ON "BlueprintNode"("projectId", "graphId"); +CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_status_idx" ON "BlueprintNode"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_cluster_idx" ON "BlueprintNode"("projectId", "cluster"); +CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_outputPath_idx" ON "BlueprintNode"("projectId", "outputPath"); +CREATE INDEX IF NOT EXISTS "BlueprintNode_projectId_deletedAt_idx" ON "BlueprintNode"("projectId", "deletedAt"); + +CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_idx" ON "BlueprintNodeRun"("projectId"); +CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_graphId_idx" ON "BlueprintNodeRun"("projectId", "graphId"); +CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_nodeId_idx" ON "BlueprintNodeRun"("projectId", "nodeId"); +CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_status_idx" ON "BlueprintNodeRun"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_createdAt_idx" ON "BlueprintNodeRun"("projectId", "createdAt"); +CREATE INDEX IF NOT EXISTS "BlueprintNodeRun_projectId_deletedAt_idx" ON "BlueprintNodeRun"("projectId", "deletedAt"); diff --git a/backend/prisma/migrations/20260609120000_v68_brainstorm_cpu_persistence/migration.sql b/backend/prisma/migrations/20260609120000_v68_brainstorm_cpu_persistence/migration.sql new file mode 100644 index 0000000..a5c847a --- /dev/null +++ b/backend/prisma/migrations/20260609120000_v68_brainstorm_cpu_persistence/migration.sql @@ -0,0 +1,40 @@ +CREATE TABLE "BrainstormSession" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "title" TEXT NOT NULL, + "prompt" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "mode" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'exploring', + "targetEntityId" TEXT, + "targetEntityType" TEXT, + "generatedBy" TEXT NOT NULL DEFAULT 'manual', + "contextSummary" TEXT NOT NULL, + "ideasJson" JSONB NOT NULL, + "clustersJson" JSONB NOT NULL, + "outcomesJson" JSONB NOT NULL, + "selectedIdeaIdsJson" JSONB NOT NULL, + "nextActionsJson" JSONB NOT NULL, + "metadataJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'brainstorm_cpu', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "BrainstormSession_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "BrainstormSession_projectId_slug_key" ON "BrainstormSession"("projectId", "slug"); +CREATE UNIQUE INDEX "BrainstormSession_projectId_readableId_key" ON "BrainstormSession"("projectId", "readableId"); +CREATE INDEX "BrainstormSession_projectId_idx" ON "BrainstormSession"("projectId"); +CREATE INDEX "BrainstormSession_projectId_scope_idx" ON "BrainstormSession"("projectId", "scope"); +CREATE INDEX "BrainstormSession_projectId_mode_idx" ON "BrainstormSession"("projectId", "mode"); +CREATE INDEX "BrainstormSession_projectId_status_idx" ON "BrainstormSession"("projectId", "status"); +CREATE INDEX "BrainstormSession_projectId_targetEntityType_targetEntityId_idx" ON "BrainstormSession"("projectId", "targetEntityType", "targetEntityId"); +CREATE INDEX "BrainstormSession_projectId_deletedAt_idx" ON "BrainstormSession"("projectId", "deletedAt"); + +ALTER TABLE "BrainstormSession" ADD CONSTRAINT "BrainstormSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260609130000_v72_initiatives_persistence/migration.sql b/backend/prisma/migrations/20260609130000_v72_initiatives_persistence/migration.sql new file mode 100644 index 0000000..d1fd38d --- /dev/null +++ b/backend/prisma/migrations/20260609130000_v72_initiatives_persistence/migration.sql @@ -0,0 +1,38 @@ +-- V72: persistent initiatives created from Brainstorm CPU roadmap outcomes. +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'initiative'; + +CREATE TABLE IF NOT EXISTS "Initiative" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "title" TEXT NOT NULL, + "intent" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'proposed', + "priority" TEXT NOT NULL DEFAULT 'normal', + "featureIdsJson" JSONB NOT NULL DEFAULT '[]', + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "confidence" INTEGER NOT NULL DEFAULT 0, + "sourceBrainstormId" TEXT, + "reviewItemId" TEXT, + "metadataJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'brainstorm_cpu', + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Initiative_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "Initiative_projectId_slug_key" ON "Initiative"("projectId", "slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "Initiative_projectId_readableId_key" ON "Initiative"("projectId", "readableId"); +CREATE INDEX IF NOT EXISTS "Initiative_projectId_idx" ON "Initiative"("projectId"); +CREATE INDEX IF NOT EXISTS "Initiative_projectId_status_idx" ON "Initiative"("projectId", "status"); +CREATE INDEX IF NOT EXISTS "Initiative_projectId_approvalState_idx" ON "Initiative"("projectId", "approvalState"); +CREATE INDEX IF NOT EXISTS "Initiative_projectId_sourceBrainstormId_idx" ON "Initiative"("projectId", "sourceBrainstormId"); +CREATE INDEX IF NOT EXISTS "Initiative_projectId_deletedAt_idx" ON "Initiative"("projectId", "deletedAt"); + +ALTER TABLE "Initiative" ADD CONSTRAINT "Initiative_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260624150000_rc39_release_evidence_records/migration.sql b/backend/prisma/migrations/20260624150000_rc39_release_evidence_records/migration.sql new file mode 100644 index 0000000..f17f4b3 --- /dev/null +++ b/backend/prisma/migrations/20260624150000_rc39_release_evidence_records/migration.sql @@ -0,0 +1,63 @@ +-- AlterEnum +ALTER TYPE "EntityType" ADD VALUE 'release_evidence_record'; + +-- CreateTable +CREATE TABLE "ReleaseEvidenceRecord" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "slug" TEXT, + "readableId" TEXT, + "capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "kind" TEXT NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'captured', + "command" TEXT, + "outputText" TEXT, + "route" TEXT, + "source" TEXT NOT NULL DEFAULT 'runtime_readiness', + "confidence" INTEGER NOT NULL DEFAULT 100, + "approvalState" "ApprovalState" NOT NULL DEFAULT 'approved', + "evidenceStrength" "EvidenceStrength" NOT NULL DEFAULT 'verified', + "verificationState" "VerificationState" NOT NULL DEFAULT 'verified', + "metadataJson" JSONB, + "provenanceJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'api', + "updatedBy" TEXT, + "sourceType" TEXT NOT NULL DEFAULT 'release_evidence', + "verifiedAt" TIMESTAMP(3), + "verifiedBy" TEXT, + "archivedAt" TIMESTAMP(3), + "deletedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReleaseEvidenceRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ReleaseEvidenceRecord_projectId_slug_key" ON "ReleaseEvidenceRecord"("projectId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "ReleaseEvidenceRecord_projectId_readableId_key" ON "ReleaseEvidenceRecord"("projectId", "readableId"); + +-- CreateIndex +CREATE INDEX "ReleaseEvidenceRecord_projectId_idx" ON "ReleaseEvidenceRecord"("projectId"); + +-- CreateIndex +CREATE INDEX "ReleaseEvidenceRecord_projectId_kind_idx" ON "ReleaseEvidenceRecord"("projectId", "kind"); + +-- CreateIndex +CREATE INDEX "ReleaseEvidenceRecord_projectId_status_idx" ON "ReleaseEvidenceRecord"("projectId", "status"); + +-- CreateIndex +CREATE INDEX "ReleaseEvidenceRecord_projectId_capturedAt_idx" ON "ReleaseEvidenceRecord"("projectId", "capturedAt"); + +-- CreateIndex +CREATE INDEX "ReleaseEvidenceRecord_projectId_verificationState_idx" ON "ReleaseEvidenceRecord"("projectId", "verificationState"); + +-- CreateIndex +CREATE INDEX "ReleaseEvidenceRecord_projectId_deletedAt_idx" ON "ReleaseEvidenceRecord"("projectId", "deletedAt"); + +-- AddForeignKey +ALTER TABLE "ReleaseEvidenceRecord" ADD CONSTRAINT "ReleaseEvidenceRecord_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260729000000_v1_authentication/migration.sql b/backend/prisma/migrations/20260729000000_v1_authentication/migration.sql new file mode 100644 index 0000000..9cdb5cb --- /dev/null +++ b/backend/prisma/migrations/20260729000000_v1_authentication/migration.sql @@ -0,0 +1,49 @@ +CREATE TYPE "UserRole" AS ENUM ('owner', 'member'); +CREATE TYPE "ProjectAccessRole" AS ENUM ('owner', 'editor', 'reviewer', 'reader'); + +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'owner', + "credentialVersion" INTEGER NOT NULL DEFAULT 1, + "disabledAt" TIMESTAMP(3), + "lastLoginAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "AuthSession" ( + "id" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "csrfToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "credentialVersion" INTEGER NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuthSession_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ProjectMembership" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "ProjectAccessRole" NOT NULL DEFAULT 'owner', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProjectMembership_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +CREATE UNIQUE INDEX "AuthSession_tokenHash_key" ON "AuthSession"("tokenHash"); +CREATE INDEX "AuthSession_userId_expiresAt_idx" ON "AuthSession"("userId", "expiresAt"); +CREATE INDEX "AuthSession_expiresAt_idx" ON "AuthSession"("expiresAt"); +CREATE UNIQUE INDEX "ProjectMembership_projectId_userId_key" ON "ProjectMembership"("projectId", "userId"); +CREATE INDEX "ProjectMembership_userId_idx" ON "ProjectMembership"("userId"); + +ALTER TABLE "AuthSession" ADD CONSTRAINT "AuthSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectMembership" ADD CONSTRAINT "ProjectMembership_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectMembership" ADD CONSTRAINT "ProjectMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260821000000_gitea_mcp_ecosystem/migration.sql b/backend/prisma/migrations/20260821000000_gitea_mcp_ecosystem/migration.sql new file mode 100644 index 0000000..7a99d79 --- /dev/null +++ b/backend/prisma/migrations/20260821000000_gitea_mcp_ecosystem/migration.sql @@ -0,0 +1,42 @@ +CREATE TYPE "GiteaAccessMode" AS ENUM ('read_only', 'read_write'); + +CREATE TABLE "GiteaRepositoryLink" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "repository" TEXT NOT NULL, + "defaultBranch" TEXT NOT NULL, + "accessMode" "GiteaAccessMode" NOT NULL DEFAULT 'read_write', + "linkedBy" TEXT NOT NULL, + "lastVerifiedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "GiteaRepositoryLink_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "McpAuditEvent" ( + "id" TEXT NOT NULL, + "projectId" TEXT, + "operationId" TEXT NOT NULL, + "actor" TEXT NOT NULL, + "toolName" TEXT NOT NULL, + "repository" TEXT, + "branch" TEXT, + "outcome" TEXT NOT NULL, + "errorCode" TEXT, + "durationMs" INTEGER NOT NULL, + "metadataJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "McpAuditEvent_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "GiteaRepositoryLink_projectId_key" ON "GiteaRepositoryLink"("projectId"); +CREATE UNIQUE INDEX "GiteaRepositoryLink_owner_repository_key" ON "GiteaRepositoryLink"("owner", "repository"); +CREATE INDEX "GiteaRepositoryLink_owner_repository_idx" ON "GiteaRepositoryLink"("owner", "repository"); +CREATE UNIQUE INDEX "McpAuditEvent_operationId_key" ON "McpAuditEvent"("operationId"); +CREATE INDEX "McpAuditEvent_projectId_createdAt_idx" ON "McpAuditEvent"("projectId", "createdAt"); +CREATE INDEX "McpAuditEvent_toolName_createdAt_idx" ON "McpAuditEvent"("toolName", "createdAt"); +CREATE INDEX "McpAuditEvent_outcome_createdAt_idx" ON "McpAuditEvent"("outcome", "createdAt"); + +ALTER TABLE "GiteaRepositoryLink" ADD CONSTRAINT "GiteaRepositoryLink_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "McpAuditEvent" ADD CONSTRAINT "McpAuditEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260821010000_entra_oidc_login/migration.sql b/backend/prisma/migrations/20260821010000_entra_oidc_login/migration.sql new file mode 100644 index 0000000..4223cb3 --- /dev/null +++ b/backend/prisma/migrations/20260821010000_entra_oidc_login/migration.sql @@ -0,0 +1,36 @@ +CREATE TABLE "ExternalIdentity" ( + "id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "displayName" TEXT, + "preferredUsername" TEXT, + "lastLoginAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ExternalIdentity_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "OidcLoginAttempt" ( + "id" TEXT NOT NULL, + "stateHash" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "userId" TEXT, + "codeVerifier" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "returnTo" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "OidcLoginAttempt_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ExternalIdentity_provider_tenantId_subject_key" ON "ExternalIdentity"("provider", "tenantId", "subject"); +CREATE UNIQUE INDEX "ExternalIdentity_provider_userId_key" ON "ExternalIdentity"("provider", "userId"); +CREATE INDEX "ExternalIdentity_userId_idx" ON "ExternalIdentity"("userId"); +CREATE UNIQUE INDEX "OidcLoginAttempt_stateHash_key" ON "OidcLoginAttempt"("stateHash"); +CREATE INDEX "OidcLoginAttempt_expiresAt_idx" ON "OidcLoginAttempt"("expiresAt"); +CREATE INDEX "OidcLoginAttempt_userId_idx" ON "OidcLoginAttempt"("userId"); +ALTER TABLE "ExternalIdentity" ADD CONSTRAINT "ExternalIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "OidcLoginAttempt" ADD CONSTRAINT "OidcLoginAttempt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260825000000_unified_delivery_core/migration.sql b/backend/prisma/migrations/20260825000000_unified_delivery_core/migration.sql new file mode 100644 index 0000000..80f1807 --- /dev/null +++ b/backend/prisma/migrations/20260825000000_unified_delivery_core/migration.sql @@ -0,0 +1,220 @@ +CREATE TYPE "RepositoryRole" AS ENUM ('primary', 'frontend', 'backend', 'infra', 'docs', 'shared', 'other'); +CREATE TYPE "RepositorySyncStatus" AS ENUM ('unverified', 'ready', 'degraded', 'unavailable'); +CREATE TYPE "DeliveryStatus" AS ENUM ('draft', 'awaiting_approval', 'approved', 'executing', 'validating', 'pull_request_open', 'merged', 'verified', 'released', 'blocked', 'failed', 'cancelled', 'superseded'); +CREATE TYPE "DeliveryChangeOperation" AS ENUM ('create', 'update', 'delete', 'rename'); +CREATE TYPE "DeliveryValidationStatus" AS ENUM ('pending', 'passed', 'failed', 'skipped'); +CREATE TYPE "ExternalOperationStatus" AS ENUM ('prepared', 'executing', 'succeeded', 'failed', 'unknown'); + +DROP INDEX IF EXISTS "GiteaRepositoryLink_projectId_key"; +ALTER TABLE "GiteaRepositoryLink" + ADD COLUMN "role" "RepositoryRole" NOT NULL DEFAULT 'primary', + ADD COLUMN "isPrimary" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "syncStatus" "RepositorySyncStatus" NOT NULL DEFAULT 'unverified', + ADD COLUMN "capabilitiesJson" JSONB, + ADD COLUMN "lastSyncAt" TIMESTAMP(3); + +CREATE INDEX "GiteaRepositoryLink_projectId_isPrimary_idx" ON "GiteaRepositoryLink"("projectId", "isPrimary"); +CREATE INDEX "GiteaRepositoryLink_projectId_role_idx" ON "GiteaRepositoryLink"("projectId", "role"); + +CREATE TABLE "DeliveryPlan" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "initiativeId" TEXT, + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "intent" TEXT NOT NULL, + "priority" TEXT NOT NULL DEFAULT 'normal', + "problem" TEXT NOT NULL, + "desiredOutcome" TEXT NOT NULL, + "usersJson" JSONB NOT NULL DEFAULT '[]', + "goalsJson" JSONB NOT NULL, + "nonGoalsJson" JSONB NOT NULL DEFAULT '[]', + "acceptanceCriteriaJson" JSONB NOT NULL, + "implementationPlanJson" JSONB NOT NULL, + "risksJson" JSONB NOT NULL DEFAULT '[]', + "validationCommandsJson" JSONB NOT NULL, + "status" "DeliveryStatus" NOT NULL DEFAULT 'awaiting_approval', + "approvalState" "ApprovalState" NOT NULL DEFAULT 'pending', + "approvedAt" TIMESTAMP(3), + "approvedBy" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DeliveryPlan_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "DeliveryRun" ( + "id" TEXT NOT NULL, + "deliveryPlanId" TEXT NOT NULL, + "repositoryLinkId" TEXT NOT NULL, + "branch" TEXT NOT NULL, + "baseBranch" TEXT NOT NULL, + "baseCommitSha" TEXT, + "headSha" TEXT, + "status" "DeliveryStatus" NOT NULL DEFAULT 'executing', + "pullRequestNumber" INTEGER, + "pullRequestUrl" TEXT, + "pullRequestTitle" TEXT, + "pullRequestState" TEXT, + "mergeCommitSha" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "pullRequestOpenedAt" TIMESTAMP(3), + "mergedAt" TIMESTAMP(3), + "verifiedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DeliveryRun_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "DeliveryFileChange" ( + "id" TEXT NOT NULL, + "deliveryRunId" TEXT NOT NULL, + "path" TEXT NOT NULL, + "previousPath" TEXT, + "operation" "DeliveryChangeOperation" NOT NULL, + "beforeSha" TEXT, + "afterSha" TEXT, + "commitSha" TEXT, + "actor" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "DeliveryFileChange_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "DeliveryValidation" ( + "id" TEXT NOT NULL, + "deliveryRunId" TEXT NOT NULL, + "command" TEXT NOT NULL, + "checkName" TEXT, + "status" "DeliveryValidationStatus" NOT NULL DEFAULT 'pending', + "provider" TEXT, + "externalRunId" TEXT, + "externalUrl" TEXT, + "headSha" TEXT, + "summary" TEXT, + "verifiedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DeliveryValidation_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ExternalOperation" ( + "id" TEXT NOT NULL, + "operationId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "deliveryRunId" TEXT, + "actor" TEXT NOT NULL, + "action" TEXT NOT NULL, + "targetJson" JSONB NOT NULL, + "status" "ExternalOperationStatus" NOT NULL DEFAULT 'prepared', + "resultJson" JSONB, + "errorCode" TEXT, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ExternalOperation_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "OutboxEvent" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "aggregateType" TEXT NOT NULL, + "aggregateId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "payloadJson" JSONB NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "publishedAt" TIMESTAMP(3), + "lastError" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "OutboxEvent_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "DeliveryPlan_initiativeId_key" ON "DeliveryPlan"("initiativeId"); +CREATE UNIQUE INDEX "DeliveryPlan_projectId_slug_key" ON "DeliveryPlan"("projectId", "slug"); +CREATE INDEX "DeliveryPlan_projectId_status_idx" ON "DeliveryPlan"("projectId", "status"); +CREATE INDEX "DeliveryPlan_projectId_updatedAt_idx" ON "DeliveryPlan"("projectId", "updatedAt"); +CREATE UNIQUE INDEX "DeliveryRun_repositoryLinkId_branch_key" ON "DeliveryRun"("repositoryLinkId", "branch"); +CREATE INDEX "DeliveryRun_deliveryPlanId_status_idx" ON "DeliveryRun"("deliveryPlanId", "status"); +CREATE INDEX "DeliveryRun_pullRequestNumber_idx" ON "DeliveryRun"("pullRequestNumber"); +CREATE INDEX "DeliveryFileChange_deliveryRunId_createdAt_idx" ON "DeliveryFileChange"("deliveryRunId", "createdAt"); +CREATE INDEX "DeliveryFileChange_deliveryRunId_path_idx" ON "DeliveryFileChange"("deliveryRunId", "path"); +CREATE INDEX "DeliveryValidation_deliveryRunId_status_idx" ON "DeliveryValidation"("deliveryRunId", "status"); +CREATE INDEX "DeliveryValidation_deliveryRunId_headSha_idx" ON "DeliveryValidation"("deliveryRunId", "headSha"); +CREATE UNIQUE INDEX "ExternalOperation_operationId_key" ON "ExternalOperation"("operationId"); +CREATE INDEX "ExternalOperation_projectId_status_createdAt_idx" ON "ExternalOperation"("projectId", "status", "createdAt"); +CREATE INDEX "ExternalOperation_deliveryRunId_createdAt_idx" ON "ExternalOperation"("deliveryRunId", "createdAt"); +CREATE UNIQUE INDEX "OutboxEvent_eventId_key" ON "OutboxEvent"("eventId"); +CREATE INDEX "OutboxEvent_projectId_createdAt_idx" ON "OutboxEvent"("projectId", "createdAt"); +CREATE INDEX "OutboxEvent_publishedAt_createdAt_idx" ON "OutboxEvent"("publishedAt", "createdAt"); + +ALTER TABLE "DeliveryPlan" ADD CONSTRAINT "DeliveryPlan_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DeliveryPlan" ADD CONSTRAINT "DeliveryPlan_initiativeId_fkey" FOREIGN KEY ("initiativeId") REFERENCES "Initiative"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "DeliveryRun" ADD CONSTRAINT "DeliveryRun_deliveryPlanId_fkey" FOREIGN KEY ("deliveryPlanId") REFERENCES "DeliveryPlan"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DeliveryRun" ADD CONSTRAINT "DeliveryRun_repositoryLinkId_fkey" FOREIGN KEY ("repositoryLinkId") REFERENCES "GiteaRepositoryLink"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "DeliveryFileChange" ADD CONSTRAINT "DeliveryFileChange_deliveryRunId_fkey" FOREIGN KEY ("deliveryRunId") REFERENCES "DeliveryRun"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DeliveryValidation" ADD CONSTRAINT "DeliveryValidation_deliveryRunId_fkey" FOREIGN KEY ("deliveryRunId") REFERENCES "DeliveryRun"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ExternalOperation" ADD CONSTRAINT "ExternalOperation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ExternalOperation" ADD CONSTRAINT "ExternalOperation_deliveryRunId_fkey" FOREIGN KEY ("deliveryRunId") REFERENCES "DeliveryRun"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "OutboxEvent" ADD CONSTRAINT "OutboxEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "DeliveryPlan" ( + "id", "projectId", "initiativeId", "slug", "title", "intent", "priority", "problem", "desiredOutcome", + "usersJson", "goalsJson", "nonGoalsJson", "acceptanceCriteriaJson", "implementationPlanJson", "risksJson", + "validationCommandsJson", "status", "approvalState", "approvedAt", "approvedBy", "createdBy", "updatedBy", "createdAt", "updatedAt" +) +SELECT + 'delivery-plan-' || substr(md5(i."id"), 1, 20), i."projectId", i."id", COALESCE(i."slug", i."id"), i."title", i."intent", i."priority", + COALESCE(i."metadataJson"->>'problem', i."intent"), COALESCE(i."metadataJson"->>'desiredOutcome', i."intent"), + COALESCE(i."metadataJson"->'users', '[]'::jsonb), COALESCE(i."metadataJson"->'goals', '[]'::jsonb), + COALESCE(i."metadataJson"->'nonGoals', '[]'::jsonb), COALESCE(i."metadataJson"->'acceptanceCriteria', '[]'::jsonb), + COALESCE(i."metadataJson"->'implementationPlan', '[]'::jsonb), COALESCE(i."metadataJson"->'risks', '[]'::jsonb), + COALESCE(i."metadataJson"->'validationCommands', '[]'::jsonb), + CASE COALESCE(i."metadataJson"->>'status', 'draft') + WHEN 'ready_for_approval' THEN 'awaiting_approval'::"DeliveryStatus" + WHEN 'approved' THEN 'approved'::"DeliveryStatus" + WHEN 'in_progress' THEN 'executing'::"DeliveryStatus" + WHEN 'ready_for_review' THEN 'validating'::"DeliveryStatus" + WHEN 'completed' THEN 'pull_request_open'::"DeliveryStatus" + WHEN 'blocked' THEN 'blocked'::"DeliveryStatus" + ELSE 'draft'::"DeliveryStatus" + END, + i."approvalState", CASE WHEN i."approvalState" = 'approved' THEN i."updatedAt" ELSE NULL END, + CASE WHEN i."approvalState" = 'approved' THEN COALESCE(i."updatedBy", 'owner') ELSE NULL END, + i."createdBy", i."updatedBy", i."createdAt", i."updatedAt" +FROM "Initiative" i +WHERE i."sourceType" = 'chatgpt_feature_delivery' AND i."deletedAt" IS NULL +ON CONFLICT ("initiativeId") DO NOTHING; + +INSERT INTO "DeliveryRun" ( + "id", "deliveryPlanId", "repositoryLinkId", "branch", "baseBranch", "headSha", "status", + "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState", "startedAt", "pullRequestOpenedAt", + "createdBy", "updatedBy", "createdAt", "updatedAt" +) +SELECT + 'delivery-run-' || substr(md5(dp."id" || l."id"), 1, 20), dp."id", l."id", i."metadataJson"->>'branch', l."defaultBranch", NULL, + CASE COALESCE(i."metadataJson"->>'status', 'in_progress') + WHEN 'ready_for_review' THEN 'validating'::"DeliveryStatus" + WHEN 'completed' THEN 'pull_request_open'::"DeliveryStatus" + ELSE 'executing'::"DeliveryStatus" + END, + CASE WHEN (i."metadataJson"->'pullRequest'->>'number') ~ '^[0-9]+$' THEN (i."metadataJson"->'pullRequest'->>'number')::integer ELSE NULL END, + i."metadataJson"->'pullRequest'->>'url', i."metadataJson"->'pullRequest'->>'title', + CASE WHEN i."metadataJson" ? 'pullRequest' THEN 'open' ELSE NULL END, + i."updatedAt", CASE WHEN i."metadataJson" ? 'pullRequest' THEN i."updatedAt" ELSE NULL END, + COALESCE(i."updatedBy", i."createdBy"), i."updatedBy", i."createdAt", i."updatedAt" +FROM "DeliveryPlan" dp +JOIN "Initiative" i ON i."id" = dp."initiativeId" +JOIN LATERAL ( + SELECT gl.* FROM "GiteaRepositoryLink" gl + WHERE gl."projectId" = dp."projectId" + ORDER BY gl."isPrimary" DESC, gl."createdAt" ASC + LIMIT 1 +) l ON true +WHERE COALESCE(i."metadataJson"->>'branch', '') <> '' +ON CONFLICT ("repositoryLinkId", "branch") DO NOTHING; diff --git a/backend/prisma/migrations/20260826020000_delivery_review_feedback/migration.sql b/backend/prisma/migrations/20260826020000_delivery_review_feedback/migration.sql new file mode 100644 index 0000000..b13371d --- /dev/null +++ b/backend/prisma/migrations/20260826020000_delivery_review_feedback/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "DeliveryPlan" ADD COLUMN "reviewFeedback" TEXT; diff --git a/backend/prisma/migrations/20260826030000_async_repository_analysis/migration.sql b/backend/prisma/migrations/20260826030000_async_repository_analysis/migration.sql new file mode 100644 index 0000000..04e3478 --- /dev/null +++ b/backend/prisma/migrations/20260826030000_async_repository_analysis/migration.sql @@ -0,0 +1,76 @@ +CREATE TYPE "AnalysisJobKind" AS ENUM ('portfolio_health'); +CREATE TYPE "AnalysisJobStatus" AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled'); +CREATE TYPE "AnalysisJobItemStatus" AS ENUM ('pending', 'running', 'succeeded', 'failed', 'cancelled'); + +CREATE TABLE "AnalysisJob" ( + "id" TEXT NOT NULL, + "kind" "AnalysisJobKind" NOT NULL DEFAULT 'portfolio_health', + "status" "AnalysisJobStatus" NOT NULL DEFAULT 'queued', + "actor" TEXT NOT NULL, + "requestJson" JSONB NOT NULL, + "requestHash" TEXT NOT NULL, + "activeDedupeKey" TEXT, + "progressCurrent" INTEGER NOT NULL DEFAULT 0, + "progressTotal" INTEGER NOT NULL DEFAULT 0, + "summaryText" TEXT, + "summaryJson" JSONB, + "errorCode" TEXT, + "cancelRequestedAt" TIMESTAMP(3), + "queuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "startedAt" TIMESTAMP(3), + "heartbeatAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AnalysisJob_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "AnalysisJobItem" ( + "id" TEXT NOT NULL, + "jobId" TEXT NOT NULL, + "repositoryLinkId" TEXT, + "projectId" TEXT, + "projectSlug" TEXT, + "projectName" TEXT, + "owner" TEXT NOT NULL, + "repository" TEXT NOT NULL, + "defaultBranch" TEXT NOT NULL, + "accessMode" "GiteaAccessMode" NOT NULL, + "sequence" INTEGER NOT NULL, + "status" "AnalysisJobItemStatus" NOT NULL DEFAULT 'pending', + "summary" TEXT, + "detailJson" JSONB, + "findingCount" INTEGER NOT NULL DEFAULT 0, + "errorCode" TEXT, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AnalysisJobItem_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AnalysisJob_activeDedupeKey_key" ON "AnalysisJob"("activeDedupeKey"); +CREATE INDEX "AnalysisJob_status_queuedAt_idx" ON "AnalysisJob"("status", "queuedAt"); +CREATE INDEX "AnalysisJob_actor_createdAt_idx" ON "AnalysisJob"("actor", "createdAt"); +CREATE INDEX "AnalysisJob_expiresAt_idx" ON "AnalysisJob"("expiresAt"); +CREATE UNIQUE INDEX "AnalysisJobItem_jobId_owner_repository_key" ON "AnalysisJobItem"("jobId", "owner", "repository"); +CREATE INDEX "AnalysisJobItem_jobId_sequence_idx" ON "AnalysisJobItem"("jobId", "sequence"); +CREATE INDEX "AnalysisJobItem_projectId_createdAt_idx" ON "AnalysisJobItem"("projectId", "createdAt"); +CREATE INDEX "AnalysisJobItem_repositoryLinkId_createdAt_idx" ON "AnalysisJobItem"("repositoryLinkId", "createdAt"); + +ALTER TABLE "AnalysisJobItem" ADD CONSTRAINT "AnalysisJobItem_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "AnalysisJob"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AnalysisJobItem" ADD CONSTRAINT "AnalysisJobItem_repositoryLinkId_fkey" FOREIGN KEY ("repositoryLinkId") REFERENCES "GiteaRepositoryLink"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE TABLE "AnalysisWorkerHeartbeat" ( + "id" TEXT NOT NULL, + "instanceId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "activeJobId" TEXT, + "detail" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL, + "lastSeenAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AnalysisWorkerHeartbeat_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "AnalysisWorkerHeartbeat_lastSeenAt_idx" ON "AnalysisWorkerHeartbeat"("lastSeenAt"); diff --git a/backend/prisma/migrations/20260826040000_closed_loop_findings/migration.sql b/backend/prisma/migrations/20260826040000_closed_loop_findings/migration.sql new file mode 100644 index 0000000..a18e399 --- /dev/null +++ b/backend/prisma/migrations/20260826040000_closed_loop_findings/migration.sql @@ -0,0 +1,129 @@ +-- Closed-loop repository findings, Gitea issue synchronization and investigations. +CREATE TYPE "FindingSeverity" AS ENUM ('info', 'warning', 'critical'); +CREATE TYPE "FindingStatus" AS ENUM ('new', 'triaged', 'issue_open', 'investigating', 'planned', 'fixing', 'verifying', 'resolved', 'regressed', 'suppressed'); +CREATE TYPE "InvestigationStatus" AS ENUM ('collecting', 'ready', 'concluded', 'planned', 'fixing', 'verifying', 'resolved', 'failed', 'cancelled'); +CREATE TYPE "IssueSyncMode" AS ENUM ('manual', 'review', 'automatic'); + +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'repository_finding'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'finding_investigation'; +ALTER TYPE "EntityType" ADD VALUE IF NOT EXISTS 'gitea_issue'; + +CREATE TABLE "RepositoryFinding" ( + "id" TEXT NOT NULL, + "fingerprint" TEXT NOT NULL, + "projectId" TEXT, + "repositoryLinkId" TEXT, + "owner" TEXT NOT NULL, + "repository" TEXT NOT NULL, + "ruleCode" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "severity" "FindingSeverity" NOT NULL, + "confidence" INTEGER NOT NULL DEFAULT 90, + "riskScore" INTEGER NOT NULL DEFAULT 0, + "status" "FindingStatus" NOT NULL DEFAULT 'new', + "occurrenceCount" INTEGER NOT NULL DEFAULT 1, + "clearCount" INTEGER NOT NULL DEFAULT 0, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolvedAt" TIMESTAMP(3), + "suppressedAt" TIMESTAMP(3), + "suppressedUntil" TIMESTAMP(3), + "suppressionReason" TEXT, + "lastAnalysisJobId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "RepositoryFinding_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "FindingOccurrence" ( + "id" TEXT NOT NULL, + "findingId" TEXT NOT NULL, + "analysisJobItemId" TEXT NOT NULL, + "observed" BOOLEAN NOT NULL DEFAULT true, + "detailJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "FindingOccurrence_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "GiteaIssueLink" ( + "id" TEXT NOT NULL, + "findingId" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "repository" TEXT NOT NULL, + "issueNumber" INTEGER NOT NULL, + "issueUrl" TEXT NOT NULL, + "state" TEXT NOT NULL DEFAULT 'open', + "title" TEXT NOT NULL, + "bodyFingerprint" TEXT NOT NULL, + "lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "closedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "GiteaIssueLink_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "FindingInvestigation" ( + "id" TEXT NOT NULL, + "findingId" TEXT NOT NULL, + "projectId" TEXT, + "deliveryPlanId" TEXT, + "status" "InvestigationStatus" NOT NULL DEFAULT 'collecting', + "actor" TEXT NOT NULL, + "contextJson" JSONB NOT NULL, + "diagnosis" TEXT, + "rootCause" TEXT, + "proposedChangesJson" JSONB NOT NULL DEFAULT '[]', + "confidence" INTEGER, + "conclusion" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "concludedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "FindingInvestigation_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "FindingAutomationPolicy" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "issueSyncMode" "IssueSyncMode" NOT NULL DEFAULT 'review', + "minimumSeverity" "FindingSeverity" NOT NULL DEFAULT 'warning', + "minimumOccurrences" INTEGER NOT NULL DEFAULT 2, + "maxIssuesPerRun" INTEGER NOT NULL DEFAULT 5, + "autoCloseResolved" BOOLEAN NOT NULL DEFAULT true, + "autoReopenRegressed" BOOLEAN NOT NULL DEFAULT true, + "scheduledAnalysis" BOOLEAN NOT NULL DEFAULT true, + "scheduleMinutes" INTEGER NOT NULL DEFAULT 1440, + "updatedBy" TEXT NOT NULL DEFAULT 'system', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "FindingAutomationPolicy_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "RepositoryFinding_fingerprint_key" ON "RepositoryFinding"("fingerprint"); +CREATE INDEX "RepositoryFinding_projectId_status_severity_idx" ON "RepositoryFinding"("projectId", "status", "severity"); +CREATE INDEX "RepositoryFinding_repositoryLinkId_status_idx" ON "RepositoryFinding"("repositoryLinkId", "status"); +CREATE INDEX "RepositoryFinding_owner_repository_status_idx" ON "RepositoryFinding"("owner", "repository", "status"); +CREATE INDEX "RepositoryFinding_lastSeenAt_idx" ON "RepositoryFinding"("lastSeenAt"); +CREATE UNIQUE INDEX "FindingOccurrence_findingId_analysisJobItemId_key" ON "FindingOccurrence"("findingId", "analysisJobItemId"); +CREATE INDEX "FindingOccurrence_analysisJobItemId_idx" ON "FindingOccurrence"("analysisJobItemId"); +CREATE UNIQUE INDEX "GiteaIssueLink_findingId_key" ON "GiteaIssueLink"("findingId"); +CREATE UNIQUE INDEX "GiteaIssueLink_owner_repository_issueNumber_key" ON "GiteaIssueLink"("owner", "repository", "issueNumber"); +CREATE INDEX "GiteaIssueLink_state_updatedAt_idx" ON "GiteaIssueLink"("state", "updatedAt"); +CREATE UNIQUE INDEX "FindingInvestigation_deliveryPlanId_key" ON "FindingInvestigation"("deliveryPlanId"); +CREATE INDEX "FindingInvestigation_findingId_createdAt_idx" ON "FindingInvestigation"("findingId", "createdAt"); +CREATE INDEX "FindingInvestigation_projectId_status_idx" ON "FindingInvestigation"("projectId", "status"); +CREATE UNIQUE INDEX "FindingAutomationPolicy_projectId_key" ON "FindingAutomationPolicy"("projectId"); + +ALTER TABLE "RepositoryFinding" ADD CONSTRAINT "RepositoryFinding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "RepositoryFinding" ADD CONSTRAINT "RepositoryFinding_repositoryLinkId_fkey" FOREIGN KEY ("repositoryLinkId") REFERENCES "GiteaRepositoryLink"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "FindingOccurrence" ADD CONSTRAINT "FindingOccurrence_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "RepositoryFinding"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FindingOccurrence" ADD CONSTRAINT "FindingOccurrence_analysisJobItemId_fkey" FOREIGN KEY ("analysisJobItemId") REFERENCES "AnalysisJobItem"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "GiteaIssueLink" ADD CONSTRAINT "GiteaIssueLink_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "RepositoryFinding"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FindingInvestigation" ADD CONSTRAINT "FindingInvestigation_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "RepositoryFinding"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FindingInvestigation" ADD CONSTRAINT "FindingInvestigation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "FindingInvestigation" ADD CONSTRAINT "FindingInvestigation_deliveryPlanId_fkey" FOREIGN KEY ("deliveryPlanId") REFERENCES "DeliveryPlan"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "FindingAutomationPolicy" ADD CONSTRAINT "FindingAutomationPolicy_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260826050000_portfolio_control_plane/migration.sql b/backend/prisma/migrations/20260826050000_portfolio_control_plane/migration.sql new file mode 100644 index 0000000..5bf2981 --- /dev/null +++ b/backend/prisma/migrations/20260826050000_portfolio_control_plane/migration.sql @@ -0,0 +1,30 @@ +-- Durable Gitea portfolio discovery and onboarding state. +CREATE TYPE "PortfolioRepositoryStatus" AS ENUM ('discovered', 'onboarded', 'excluded', 'unavailable'); + +CREATE TABLE "PortfolioRepository" ( + "id" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "repository" TEXT NOT NULL, + "projectId" TEXT, + "description" TEXT, + "defaultBranch" TEXT NOT NULL, + "htmlUrl" TEXT, + "private" BOOLEAN NOT NULL DEFAULT true, + "archived" BOOLEAN NOT NULL DEFAULT false, + "canPush" BOOLEAN NOT NULL DEFAULT false, + "status" "PortfolioRepositoryStatus" NOT NULL DEFAULT 'discovered', + "exclusionReason" TEXT, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "onboardedAt" TIMESTAMP(3), + "excludedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "PortfolioRepository_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PortfolioRepository_owner_repository_key" ON "PortfolioRepository"("owner", "repository"); +CREATE INDEX "PortfolioRepository_status_lastSeenAt_idx" ON "PortfolioRepository"("status", "lastSeenAt"); +CREATE INDEX "PortfolioRepository_projectId_idx" ON "PortfolioRepository"("projectId"); + +ALTER TABLE "PortfolioRepository" ADD CONSTRAINT "PortfolioRepository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260829211000_partial_analysis_status/migration.sql b/backend/prisma/migrations/20260829211000_partial_analysis_status/migration.sql new file mode 100644 index 0000000..79871d4 --- /dev/null +++ b/backend/prisma/migrations/20260829211000_partial_analysis_status/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "AnalysisJobStatus" ADD VALUE IF NOT EXISTS 'partial'; diff --git a/backend/prisma/migrations/20260830000000_git_source_library/migration.sql b/backend/prisma/migrations/20260830000000_git_source_library/migration.sql new file mode 100644 index 0000000..169ba12 --- /dev/null +++ b/backend/prisma/migrations/20260830000000_git_source_library/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "ProjectSource" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "repositoryLinkId" TEXT, + "title" TEXT NOT NULL, + "kind" TEXT NOT NULL DEFAULT 'document', + "inputMode" TEXT NOT NULL DEFAULT 'git', + "path" TEXT, + "url" TEXT, + "content" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'processed', + "branch" TEXT, + "contentSha" TEXT, + "commitSha" TEXT, + "lastSyncedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProjectSource_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "ProjectSource_repositoryLinkId_path_key" ON "ProjectSource"("repositoryLinkId", "path"); +CREATE INDEX "ProjectSource_projectId_status_idx" ON "ProjectSource"("projectId", "status"); +CREATE INDEX "ProjectSource_repositoryLinkId_lastSyncedAt_idx" ON "ProjectSource"("repositoryLinkId", "lastSyncedAt"); +ALTER TABLE "ProjectSource" ADD CONSTRAINT "ProjectSource_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectSource" ADD CONSTRAINT "ProjectSource_repositoryLinkId_fkey" FOREIGN KEY ("repositoryLinkId") REFERENCES "GiteaRepositoryLink"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260902000000_attention_provenance_cleanup/migration.sql b/backend/prisma/migrations/20260902000000_attention_provenance_cleanup/migration.sql new file mode 100644 index 0000000..e94c0cf --- /dev/null +++ b/backend/prisma/migrations/20260902000000_attention_provenance_cleanup/migration.sql @@ -0,0 +1,19 @@ +-- Historical fixture and smoke records are useful evidence, but were never +-- real owner decisions. Close only those narrowly identified pending items. +UPDATE "ReviewQueueItem" +SET + "status" = 'rejected', + "approvalState" = 'rejected', + "notes" = CASE + WHEN "notes" IS NULL OR "notes" = '' THEN 'Automatically closed: synthetic seed or smoke evidence is not a human attention item.' + ELSE "notes" || E'\nAutomatically closed: synthetic seed or smoke evidence is not a human attention item.' + END, + "updatedBy" = 'attention_policy_migration', + "updatedAt" = CURRENT_TIMESTAMP +WHERE + "status" = 'pending' + AND ( + "createdBy" = 'seed' + OR LOWER("source") LIKE '%smoke%' + OR LOWER("sourceType") LIKE '%smoke%' + ); diff --git a/backend/prisma/migrations/20260902130000_autonomy_executor_heartbeat/migration.sql b/backend/prisma/migrations/20260902130000_autonomy_executor_heartbeat/migration.sql new file mode 100644 index 0000000..408c3c0 --- /dev/null +++ b/backend/prisma/migrations/20260902130000_autonomy_executor_heartbeat/migration.sql @@ -0,0 +1,15 @@ +CREATE TABLE "AutonomyExecutorHeartbeat" ( + "id" TEXT NOT NULL, + "instanceId" TEXT NOT NULL, + "runId" TEXT, + "status" TEXT NOT NULL, + "activeProjectId" TEXT, + "detail" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL, + "lastSeenAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutonomyExecutorHeartbeat_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "AutonomyExecutorHeartbeat_lastSeenAt_idx" ON "AutonomyExecutorHeartbeat"("lastSeenAt"); diff --git a/backend/prisma/migrations/20260912190000_autonomous_operating_system_foundation/migration.sql b/backend/prisma/migrations/20260912190000_autonomous_operating_system_foundation/migration.sql new file mode 100644 index 0000000..78190a5 --- /dev/null +++ b/backend/prisma/migrations/20260912190000_autonomous_operating_system_foundation/migration.sql @@ -0,0 +1,164 @@ +-- Durable cross-domain work graph, completion contract, saved views and diagnostics. +-- Existing domain records remain authoritative for their domain; these tables provide +-- the shared operating-system projection and its durable dependency/audit edges. + +CREATE TABLE "WorkGraphNode" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "stableKey" TEXT NOT NULL, + "source" TEXT NOT NULL, + "sourceId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "canonicalLifecycle" TEXT NOT NULL DEFAULT 'ready', + "domainLifecycle" TEXT NOT NULL DEFAULT 'pending', + "scope" TEXT NOT NULL DEFAULT 'committed', + "priority" INTEGER NOT NULL DEFAULT 50, + "phase" TEXT, + "risk" TEXT NOT NULL DEFAULT 'normal', + "blockingStatus" TEXT NOT NULL DEFAULT 'open', + "executionEligibility" TEXT NOT NULL DEFAULT 'blocked', + "blockReason" TEXT, + "owner" TEXT, + "leaseToken" TEXT, + "leaseExpiresAt" TIMESTAMP(3), + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "consecutiveFailureCount" INTEGER NOT NULL DEFAULT 0, + "recoveryStartedAt" TIMESTAMP(3), + "lastMeaningfulProgressAt" TIMESTAMP(3), + "completionEvidenceJson" JSONB, + "metadataJson" JSONB, + "supersededById" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "WorkGraphNode_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "WorkGraphDependency" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "dependsOnNodeId" TEXT NOT NULL, + "dependencyType" TEXT NOT NULL DEFAULT 'blocks', + "createdBy" TEXT NOT NULL DEFAULT 'system', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "WorkGraphDependency_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "WorkGraphAuditEvent" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "nodeId" TEXT, + "eventType" TEXT NOT NULL, + "actor" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "payloadJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "WorkGraphAuditEvent_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "WorkGraphEvidence" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "referenceType" TEXT NOT NULL, + "referenceId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'observed', + "summary" TEXT NOT NULL, + "metadataJson" JSONB, + "verifiedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "WorkGraphEvidence_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ProjectDefinitionOfDone" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "requirementsJson" JSONB NOT NULL, + "scopePolicyJson" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'incomplete', + "satisfiedCount" INTEGER NOT NULL DEFAULT 0, + "requirementCount" INTEGER NOT NULL DEFAULT 0, + "lastEvaluatedAt" TIMESTAMP(3), + "lastEvidenceAt" TIMESTAMP(3), + "evaluationJson" JSONB, + "createdBy" TEXT NOT NULL DEFAULT 'system', + "updatedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProjectDefinitionOfDone_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ProjectSavedView" ( + "id" TEXT NOT NULL, + "ownerKey" TEXT NOT NULL, + "projectId" TEXT, + "name" TEXT NOT NULL, + "scope" TEXT NOT NULL DEFAULT 'portfolio', + "filtersJson" JSONB NOT NULL, + "isPinned" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ProjectSavedView_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "DiagnosticRun" ( + "id" TEXT NOT NULL, + "projectId" TEXT, + "scope" TEXT NOT NULL DEFAULT 'platform', + "status" TEXT NOT NULL DEFAULT 'running', + "summary" TEXT, + "checksJson" JSONB NOT NULL, + "repairPlanJson" JSONB, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + "createdBy" TEXT NOT NULL DEFAULT 'system', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "DiagnosticRun_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "WorkGraphNode_projectId_stableKey_key" ON "WorkGraphNode"("projectId", "stableKey"); +CREATE UNIQUE INDEX "WorkGraphNode_projectId_source_sourceId_key" ON "WorkGraphNode"("projectId", "source", "sourceId"); +CREATE INDEX "WorkGraphNode_projectId_executionEligibility_priority_idx" ON "WorkGraphNode"("projectId", "executionEligibility", "priority"); +CREATE INDEX "WorkGraphNode_projectId_canonicalLifecycle_idx" ON "WorkGraphNode"("projectId", "canonicalLifecycle"); +CREATE INDEX "WorkGraphNode_projectId_scope_idx" ON "WorkGraphNode"("projectId", "scope"); +CREATE INDEX "WorkGraphNode_projectId_leaseExpiresAt_idx" ON "WorkGraphNode"("projectId", "leaseExpiresAt"); +CREATE INDEX "WorkGraphNode_projectId_updatedAt_idx" ON "WorkGraphNode"("projectId", "updatedAt"); +CREATE INDEX "WorkGraphNode_projectId_supersededById_idx" ON "WorkGraphNode"("projectId", "supersededById"); + +CREATE UNIQUE INDEX "WorkGraphDependency_nodeId_dependsOnNodeId_key" ON "WorkGraphDependency"("nodeId", "dependsOnNodeId"); +CREATE INDEX "WorkGraphDependency_projectId_nodeId_idx" ON "WorkGraphDependency"("projectId", "nodeId"); +CREATE INDEX "WorkGraphDependency_projectId_dependsOnNodeId_idx" ON "WorkGraphDependency"("projectId", "dependsOnNodeId"); + +CREATE INDEX "WorkGraphAuditEvent_projectId_createdAt_idx" ON "WorkGraphAuditEvent"("projectId", "createdAt"); +CREATE INDEX "WorkGraphAuditEvent_projectId_nodeId_createdAt_idx" ON "WorkGraphAuditEvent"("projectId", "nodeId", "createdAt"); +CREATE INDEX "WorkGraphAuditEvent_projectId_eventType_idx" ON "WorkGraphAuditEvent"("projectId", "eventType"); + +CREATE UNIQUE INDEX "WorkGraphEvidence_nodeId_kind_referenceType_referenceId_key" ON "WorkGraphEvidence"("nodeId", "kind", "referenceType", "referenceId"); +CREATE INDEX "WorkGraphEvidence_projectId_nodeId_idx" ON "WorkGraphEvidence"("projectId", "nodeId"); +CREATE INDEX "WorkGraphEvidence_projectId_status_idx" ON "WorkGraphEvidence"("projectId", "status"); + +CREATE UNIQUE INDEX "ProjectDefinitionOfDone_projectId_key" ON "ProjectDefinitionOfDone"("projectId"); +CREATE INDEX "ProjectDefinitionOfDone_status_lastEvaluatedAt_idx" ON "ProjectDefinitionOfDone"("status", "lastEvaluatedAt"); + +CREATE UNIQUE INDEX "ProjectSavedView_ownerKey_scope_name_key" ON "ProjectSavedView"("ownerKey", "scope", "name"); +CREATE INDEX "ProjectSavedView_ownerKey_scope_updatedAt_idx" ON "ProjectSavedView"("ownerKey", "scope", "updatedAt"); +CREATE INDEX "ProjectSavedView_projectId_updatedAt_idx" ON "ProjectSavedView"("projectId", "updatedAt"); + +CREATE INDEX "DiagnosticRun_projectId_createdAt_idx" ON "DiagnosticRun"("projectId", "createdAt"); +CREATE INDEX "DiagnosticRun_scope_status_createdAt_idx" ON "DiagnosticRun"("scope", "status", "createdAt"); + +ALTER TABLE "WorkGraphNode" ADD CONSTRAINT "WorkGraphNode_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkGraphNode" ADD CONSTRAINT "WorkGraphNode_supersededById_fkey" FOREIGN KEY ("supersededById") REFERENCES "WorkGraphNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "WorkGraphDependency" ADD CONSTRAINT "WorkGraphDependency_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkGraphDependency" ADD CONSTRAINT "WorkGraphDependency_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "WorkGraphNode"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkGraphDependency" ADD CONSTRAINT "WorkGraphDependency_dependsOnNodeId_fkey" FOREIGN KEY ("dependsOnNodeId") REFERENCES "WorkGraphNode"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkGraphAuditEvent" ADD CONSTRAINT "WorkGraphAuditEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkGraphAuditEvent" ADD CONSTRAINT "WorkGraphAuditEvent_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "WorkGraphNode"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkGraphEvidence" ADD CONSTRAINT "WorkGraphEvidence_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "WorkGraphEvidence" ADD CONSTRAINT "WorkGraphEvidence_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "WorkGraphNode"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectDefinitionOfDone" ADD CONSTRAINT "ProjectDefinitionOfDone_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ProjectSavedView" ADD CONSTRAINT "ProjectSavedView_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DiagnosticRun" ADD CONSTRAINT "DiagnosticRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260913090000_portfolio_project_pins/migration.sql b/backend/prisma/migrations/20260913090000_portfolio_project_pins/migration.sql new file mode 100644 index 0000000..7d73b7b --- /dev/null +++ b/backend/prisma/migrations/20260913090000_portfolio_project_pins/migration.sql @@ -0,0 +1,16 @@ +-- Durable per-user project pins. Saved views reference these pins rather than +-- pretending that a view-level bookmark can identify projects on its own. + +CREATE TABLE "ProjectPortfolioPin" ( + "id" TEXT NOT NULL, + "ownerKey" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ProjectPortfolioPin_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProjectPortfolioPin_ownerKey_projectId_key" ON "ProjectPortfolioPin"("ownerKey", "projectId"); +CREATE INDEX "ProjectPortfolioPin_ownerKey_createdAt_idx" ON "ProjectPortfolioPin"("ownerKey", "createdAt"); +CREATE INDEX "ProjectPortfolioPin_projectId_idx" ON "ProjectPortfolioPin"("projectId"); + +ALTER TABLE "ProjectPortfolioPin" ADD CONSTRAINT "ProjectPortfolioPin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..ebb071d --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g. Git) +provider = "postgresql" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..2624e79 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,1967 @@ +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum ProjectStatus { + active + archived + paused +} + +enum FeatureStatus { + todo + in_progress + done +} + +enum FeatureLifecycle { + feature_candidate + planned + active + implemented + verified + production_proven + deprecated + archived +} + +enum ApprovalState { + pending + approved + rejected + blocked +} + +enum EvidenceStrength { + weak + medium + strong + verified +} + +enum VerificationState { + unverified + needs_evidence + verified + blocked +} + +enum ReviewStatus { + pending + accepted + rejected + merged + converted +} + +enum UserRole { + owner + member +} + +enum ProjectAccessRole { + owner + editor + reviewer + reader +} + +enum GiteaAccessMode { + read_only + read_write +} + +enum RepositoryRole { + primary + frontend + backend + infra + docs + shared + other +} + +enum RepositorySyncStatus { + unverified + ready + degraded + unavailable +} + +enum PortfolioRepositoryStatus { + discovered + onboarded + excluded + unavailable +} + +enum DeliveryStatus { + draft + awaiting_approval + approved + executing + validating + pull_request_open + merged + verified + released + blocked + failed + cancelled + superseded +} + +enum DeliveryChangeOperation { + create + update + delete + rename +} + +enum DeliveryValidationStatus { + pending + passed + failed + skipped +} + +enum ExternalOperationStatus { + prepared + executing + succeeded + failed + unknown +} + +enum AnalysisJobKind { + portfolio_health +} + +enum AnalysisJobStatus { + queued + running + succeeded + partial + failed + cancelled +} + +enum AnalysisJobItemStatus { + pending + running + succeeded + failed + cancelled +} + +enum FindingSeverity { + info + warning + critical +} + +enum FindingStatus { + new + triaged + issue_open + investigating + planned + fixing + verifying + resolved + regressed + suppressed +} + +enum InvestigationStatus { + collecting + ready + concluded + planned + fixing + verifying + resolved + failed + cancelled +} + +enum IssueSyncMode { + manual + review + automatic +} + +enum EntityType { + project + project_dna + workspace_state + feature_group + feature + evidence + context_pack + context_pack_section + memory_rule + knowledge_item + documentation_item + review_queue_item + capture_item + accepted_truth_item + project_update + operating_log_entry + current_state_report + release_evidence_record + handoff_bundle + codex_task + codex_run_import + blueprint_graph + blueprint_node + blueprint_node_run + brainstorm_session + initiative + repository_finding + finding_investigation + gitea_issue + idempotency_record + entity_relation + activity_event +} + +model Project { + id String @id @default(cuid()) + slug String @unique + readableId String? + name String + description String + domain String + status ProjectStatus @default(active) + health Int @default(0) + healthStatus String + activeContextPackId String? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + dna ProjectDNA? + workspaceState WorkspaceState? + featureGroups FeatureGroup[] + features Feature[] + evidence Evidence[] + contextPacks ContextPack[] + memoryRules MemoryRule[] + knowledgeItems KnowledgeItem[] + documentationItems DocumentationItem[] + reviewQueueItems ReviewQueueItem[] + captureItems CaptureItem[] + acceptedTruthItems AcceptedTruthItem[] + projectUpdates ProjectUpdate[] + operatingLogEntries OperatingLogEntry[] + currentStateReports CurrentStateReport[] + releaseEvidenceRecords ReleaseEvidenceRecord[] + handoffBundles HandoffBundle[] + codexTasks CodexTask[] + codexRunImports CodexRunImport[] + entityRelations EntityRelation[] + activityEvents ActivityEvent[] + blueprintGraphs BlueprintGraph[] + blueprintNodes BlueprintNode[] + blueprintNodeRuns BlueprintNodeRun[] + brainstormSessions BrainstormSession[] + initiatives Initiative[] + memberships ProjectMembership[] + giteaRepositories GiteaRepositoryLink[] + sourceItems ProjectSource[] + portfolioRepositories PortfolioRepository[] + deliveryPlans DeliveryPlan[] + repositoryFindings RepositoryFinding[] + findingInvestigations FindingInvestigation[] + findingAutomationPolicy FindingAutomationPolicy? + externalOperations ExternalOperation[] + outboxEvents OutboxEvent[] + mcpAuditEvents McpAuditEvent[] + workGraphNodes WorkGraphNode[] + workGraphDependencies WorkGraphDependency[] + workGraphAuditEvents WorkGraphAuditEvent[] + workGraphEvidence WorkGraphEvidence[] + savedViews ProjectSavedView[] + portfolioPins ProjectPortfolioPin[] + diagnosticRuns DiagnosticRun[] + definitionOfDone ProjectDefinitionOfDone? + + @@index([status]) + @@index([activeContextPackId]) + @@index([deletedAt]) + @@index([updatedAt]) +} + +model PortfolioRepository { + id String @id @default(cuid()) + owner String + repository String + projectId String? + description String? + defaultBranch String + htmlUrl String? + private Boolean @default(true) + archived Boolean @default(false) + canPush Boolean @default(false) + status PortfolioRepositoryStatus @default(discovered) + exclusionReason String? + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + onboardedAt DateTime? + excludedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + + @@unique([owner, repository]) + @@index([status, lastSeenAt]) + @@index([projectId]) +} + +model GiteaRepositoryLink { + id String @id @default(cuid()) + projectId String + owner String + repository String + defaultBranch String + accessMode GiteaAccessMode @default(read_write) + role RepositoryRole @default(primary) + isPrimary Boolean @default(true) + syncStatus RepositorySyncStatus @default(unverified) + capabilitiesJson Json? + linkedBy String + lastVerifiedAt DateTime? + lastSyncAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deliveryRuns DeliveryRun[] + analysisJobItems AnalysisJobItem[] + repositoryFindings RepositoryFinding[] + sourceItems ProjectSource[] + + @@unique([owner, repository]) + @@index([projectId, isPrimary]) + @@index([projectId, role]) + @@index([owner, repository]) +} + +model ProjectSource { + id String @id @default(cuid()) + projectId String + repositoryLinkId String? + title String + kind String @default("document") + inputMode String @default("git") + path String? + url String? + content String + summary String + status String @default("processed") + branch String? + contentSha String? + commitSha String? + lastSyncedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + repositoryLink GiteaRepositoryLink? @relation(fields: [repositoryLinkId], references: [id], onDelete: Cascade) + + @@unique([repositoryLinkId, path]) + @@index([projectId, status]) + @@index([repositoryLinkId, lastSyncedAt]) +} + +model AnalysisJob { + id String @id @default(cuid()) + kind AnalysisJobKind @default(portfolio_health) + status AnalysisJobStatus @default(queued) + actor String + requestJson Json + requestHash String + activeDedupeKey String? @unique + progressCurrent Int @default(0) + progressTotal Int @default(0) + summaryText String? + summaryJson Json? + errorCode String? + cancelRequestedAt DateTime? + queuedAt DateTime @default(now()) + startedAt DateTime? + heartbeatAt DateTime? + completedAt DateTime? + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + items AnalysisJobItem[] + + @@index([status, queuedAt]) + @@index([actor, createdAt]) + @@index([expiresAt]) +} + +model AnalysisJobItem { + id String @id @default(cuid()) + jobId String + repositoryLinkId String? + projectId String? + projectSlug String? + projectName String? + owner String + repository String + defaultBranch String + accessMode GiteaAccessMode + sequence Int + status AnalysisJobItemStatus @default(pending) + summary String? + detailJson Json? + findingCount Int @default(0) + errorCode String? + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + job AnalysisJob @relation(fields: [jobId], references: [id], onDelete: Cascade) + repositoryLink GiteaRepositoryLink? @relation(fields: [repositoryLinkId], references: [id], onDelete: SetNull) + findingOccurrences FindingOccurrence[] + + @@unique([jobId, owner, repository]) + @@index([jobId, sequence]) + @@index([projectId, createdAt]) + @@index([repositoryLinkId, createdAt]) +} + +model RepositoryFinding { + id String @id @default(cuid()) + fingerprint String @unique + projectId String? + repositoryLinkId String? + owner String + repository String + ruleCode String + subject String + title String + message String + severity FindingSeverity + confidence Int @default(90) + riskScore Int @default(0) + status FindingStatus @default(new) + occurrenceCount Int @default(1) + clearCount Int @default(0) + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + resolvedAt DateTime? + suppressedAt DateTime? + suppressedUntil DateTime? + suppressionReason String? + lastAnalysisJobId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + repositoryLink GiteaRepositoryLink? @relation(fields: [repositoryLinkId], references: [id], onDelete: SetNull) + occurrences FindingOccurrence[] + issueLink GiteaIssueLink? + investigations FindingInvestigation[] + + @@index([projectId, status, severity]) + @@index([repositoryLinkId, status]) + @@index([owner, repository, status]) + @@index([lastSeenAt]) +} + +model FindingOccurrence { + id String @id @default(cuid()) + findingId String + analysisJobItemId String + observed Boolean @default(true) + detailJson Json? + createdAt DateTime @default(now()) + + finding RepositoryFinding @relation(fields: [findingId], references: [id], onDelete: Cascade) + analysisJobItem AnalysisJobItem @relation(fields: [analysisJobItemId], references: [id], onDelete: Cascade) + + @@unique([findingId, analysisJobItemId]) + @@index([analysisJobItemId]) +} + +model GiteaIssueLink { + id String @id @default(cuid()) + findingId String @unique + owner String + repository String + issueNumber Int + issueUrl String + state String @default("open") + title String + bodyFingerprint String + lastSyncedAt DateTime @default(now()) + closedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + finding RepositoryFinding @relation(fields: [findingId], references: [id], onDelete: Cascade) + + @@unique([owner, repository, issueNumber]) + @@index([state, updatedAt]) +} + +model FindingInvestigation { + id String @id @default(cuid()) + findingId String + projectId String? + deliveryPlanId String? @unique + status InvestigationStatus @default(collecting) + actor String + contextJson Json + diagnosis String? + rootCause String? + proposedChangesJson Json @default("[]") + confidence Int? + conclusion String? + startedAt DateTime @default(now()) + concludedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + finding RepositoryFinding @relation(fields: [findingId], references: [id], onDelete: Cascade) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + deliveryPlan DeliveryPlan? @relation(fields: [deliveryPlanId], references: [id], onDelete: SetNull) + + @@index([findingId, createdAt]) + @@index([projectId, status]) +} + +model FindingAutomationPolicy { + id String @id @default(cuid()) + projectId String @unique + enabled Boolean @default(true) + issueSyncMode IssueSyncMode @default(review) + minimumSeverity FindingSeverity @default(warning) + minimumOccurrences Int @default(2) + maxIssuesPerRun Int @default(5) + autoCloseResolved Boolean @default(true) + autoReopenRegressed Boolean @default(true) + scheduledAnalysis Boolean @default(true) + scheduleMinutes Int @default(1440) + updatedBy String @default("system") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) +} + +model AnalysisWorkerHeartbeat { + id String @id + instanceId String + status String + activeJobId String? + detail String? + startedAt DateTime + lastSeenAt DateTime + updatedAt DateTime @updatedAt + + @@index([lastSeenAt]) +} + +model AutonomyExecutorHeartbeat { + id String @id + instanceId String + runId String? + status String + activeProjectId String? + detail String? + startedAt DateTime + lastSeenAt DateTime + updatedAt DateTime @updatedAt + + @@index([lastSeenAt]) +} + +model DeliveryPlan { + id String @id @default(cuid()) + projectId String + initiativeId String? @unique + slug String + title String + intent String + priority String @default("normal") + problem String + desiredOutcome String + usersJson Json @default("[]") + goalsJson Json + nonGoalsJson Json @default("[]") + acceptanceCriteriaJson Json + implementationPlanJson Json + risksJson Json @default("[]") + validationCommandsJson Json + status DeliveryStatus @default(awaiting_approval) + approvalState ApprovalState @default(pending) + approvedAt DateTime? + approvedBy String? + reviewFeedback String? + version Int @default(1) + createdBy String @default("system") + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + initiative Initiative? @relation(fields: [initiativeId], references: [id], onDelete: SetNull) + runs DeliveryRun[] + findingInvestigation FindingInvestigation? + + @@unique([projectId, slug]) + @@index([projectId, status]) + @@index([projectId, updatedAt]) +} + +model DeliveryRun { + id String @id @default(cuid()) + deliveryPlanId String + repositoryLinkId String + branch String + baseBranch String + baseCommitSha String? + headSha String? + status DeliveryStatus @default(executing) + pullRequestNumber Int? + pullRequestUrl String? + pullRequestTitle String? + pullRequestState String? + mergeCommitSha String? + startedAt DateTime @default(now()) + pullRequestOpenedAt DateTime? + mergedAt DateTime? + verifiedAt DateTime? + completedAt DateTime? + createdBy String @default("system") + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + deliveryPlan DeliveryPlan @relation(fields: [deliveryPlanId], references: [id], onDelete: Cascade) + repositoryLink GiteaRepositoryLink @relation(fields: [repositoryLinkId], references: [id], onDelete: Restrict) + changes DeliveryFileChange[] + validations DeliveryValidation[] + operations ExternalOperation[] + + @@unique([repositoryLinkId, branch]) + @@index([deliveryPlanId, status]) + @@index([pullRequestNumber]) +} + +model DeliveryFileChange { + id String @id @default(cuid()) + deliveryRunId String + path String + previousPath String? + operation DeliveryChangeOperation + beforeSha String? + afterSha String? + commitSha String? + actor String + createdAt DateTime @default(now()) + + deliveryRun DeliveryRun @relation(fields: [deliveryRunId], references: [id], onDelete: Cascade) + + @@index([deliveryRunId, createdAt]) + @@index([deliveryRunId, path]) +} + +model DeliveryValidation { + id String @id @default(cuid()) + deliveryRunId String + command String + checkName String? + status DeliveryValidationStatus @default(pending) + provider String? + externalRunId String? + externalUrl String? + headSha String? + summary String? + verifiedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + deliveryRun DeliveryRun @relation(fields: [deliveryRunId], references: [id], onDelete: Cascade) + + @@index([deliveryRunId, status]) + @@index([deliveryRunId, headSha]) +} + +model ExternalOperation { + id String @id @default(cuid()) + operationId String @unique + projectId String + deliveryRunId String? + actor String + action String + targetJson Json + status ExternalOperationStatus @default(prepared) + resultJson Json? + errorCode String? + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deliveryRun DeliveryRun? @relation(fields: [deliveryRunId], references: [id], onDelete: SetNull) + + @@index([projectId, status, createdAt]) + @@index([deliveryRunId, createdAt]) +} + +model OutboxEvent { + id String @id @default(cuid()) + eventId String @unique + projectId String + aggregateType String + aggregateId String + eventType String + payloadJson Json + attempts Int @default(0) + publishedAt DateTime? + lastError String? + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId, createdAt]) + @@index([publishedAt, createdAt]) +} + +model McpAuditEvent { + id String @id @default(cuid()) + projectId String? + operationId String @unique + actor String + toolName String + repository String? + branch String? + outcome String + errorCode String? + durationMs Int + metadataJson Json? + createdAt DateTime @default(now()) + + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + + @@index([projectId, createdAt]) + @@index([toolName, createdAt]) + @@index([outcome, createdAt]) +} + +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String + role UserRole @default(owner) + credentialVersion Int @default(1) + disabledAt DateTime? + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sessions AuthSession[] + memberships ProjectMembership[] + externalIdentities ExternalIdentity[] + oidcLoginAttempts OidcLoginAttempt[] +} + +model ExternalIdentity { + id String @id @default(cuid()) + provider String + tenantId String + subject String + userId String + displayName String? + preferredUsername String? + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, tenantId, subject]) + @@unique([provider, userId]) + @@index([userId]) +} + +model OidcLoginAttempt { + id String @id @default(cuid()) + stateHash String @unique + provider String + purpose String + userId String? + codeVerifier String + nonce String + returnTo String + expiresAt DateTime + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([expiresAt]) + @@index([userId]) +} + +model AuthSession { + id String @id @default(cuid()) + tokenHash String @unique + csrfToken String + userId String + credentialVersion Int + expiresAt DateTime + lastSeenAt DateTime @default(now()) + revokedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, expiresAt]) + @@index([expiresAt]) +} + +model ProjectMembership { + id String @id @default(cuid()) + projectId String + userId String + role ProjectAccessRole @default(owner) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([projectId, userId]) + @@index([userId]) +} + +model ProjectDNA { + id String @id @default(cuid()) + projectId String @unique + vision String + goalsJson Json + nonGoalsJson Json + targetAudience String + principlesJson Json + metricsJson Json + maturityStage String + revision Int @default(1) + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) +} + +model WorkspaceState { + id String @id @default(cuid()) + projectId String @unique + activeContextPackId String? + primaryObjective String + activeSection String + notesJson Json + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId, activeContextPackId]) +} + +model FeatureGroup { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + name String + description String? + focusArea String? + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + features Feature[] + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, deletedAt]) +} + +model Feature { + id String @id @default(cuid()) + projectId String + featureGroupId String + slug String? + readableId String? + name String + description String + status FeatureStatus @default(todo) + lifecycle FeatureLifecycle @default(feature_candidate) + intent String? + confidence Int @default(0) + health Int @default(0) + approvalState ApprovalState @default(pending) + evidenceStrength EvidenceStrength? + verificationState VerificationState? + verifiedAt DateTime? + verifiedBy String? + trustJson Json? + provenanceJson Json? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + featureGroup FeatureGroup @relation(fields: [featureGroupId], references: [id]) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, status]) + @@index([projectId, lifecycle]) + @@index([projectId, featureGroupId]) + @@index([projectId, name]) + @@index([projectId, deletedAt]) +} + +model Evidence { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + type String + source String + confidence Int @default(0) + approvalState ApprovalState @default(pending) + evidenceStrength EvidenceStrength @default(weak) + verificationState VerificationState? + reason String + trustJson Json? + provenanceJson Json? + metadataJson Json? + lastSeen DateTime + verifiedAt DateTime? + verifiedBy String? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, type]) + @@index([projectId, approvalState]) + @@index([projectId, evidenceStrength]) + @@index([projectId, lastSeen]) + @@index([projectId, deletedAt]) +} + +model ContextPack { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + title String + category String + summary String + generatedBy String + confidence Int @default(0) + approvalState ApprovalState @default(pending) + qualityJson Json? + trustJson Json? + provenanceJson Json? + metadataJson Json? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + sections ContextPackSection[] + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, category]) + @@index([projectId, generatedBy]) + @@index([projectId, createdAt]) + @@index([projectId, deletedAt]) +} + +model ContextPackSection { + id String @id @default(cuid()) + projectId String + contextPackId String + slug String? + title String + sectionType String + orderIndex Int + contentJson Json + metadataJson Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + contextPack ContextPack @relation(fields: [contextPackId], references: [id], onDelete: Cascade) + + @@unique([contextPackId, slug]) + @@index([projectId]) + @@index([contextPackId, orderIndex]) + @@index([projectId, sectionType]) +} + +model MemoryRule { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + title String + rule String + scope String + category String + severity String + includeInContextPacks Boolean @default(false) + confidence Int @default(0) + approvalState ApprovalState @default(pending) + evidenceStrength EvidenceStrength? + verificationState VerificationState? + verifiedAt DateTime? + verifiedBy String? + trustJson Json? + provenanceJson Json? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, scope]) + @@index([projectId, category]) + @@index([projectId, severity]) + @@index([projectId, includeInContextPacks]) + @@index([projectId, deletedAt]) +} + +model KnowledgeItem { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + title String + category String + content String + source String + confidence Int @default(0) + includeInContextPacks Boolean @default(false) + approvalState ApprovalState @default(pending) + evidenceStrength EvidenceStrength? + verificationState VerificationState? + verifiedAt DateTime? + verifiedBy String? + trustJson Json? + provenanceJson Json? + metadataJson Json? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, category]) + @@index([projectId, source]) + @@index([projectId, createdAt]) + @@index([projectId, deletedAt]) +} + +model DocumentationItem { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + title String + kind String + status String + approvalState ApprovalState @default(pending) + content String? + metadataJson Json? + trustJson Json? + provenanceJson Json? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, kind]) + @@index([projectId, status]) + @@index([projectId, approvalState]) + @@index([projectId, deletedAt]) +} + +model CaptureItem { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + type String + title String + content String + source String + status String + classificationType String? + confidence Int? + evidenceStrength EvidenceStrength? + suggestedAction String? + linkedReviewItemId String? + metadataJson Json? + provenanceJson Json? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, status]) + @@index([projectId, type]) + @@index([projectId, classificationType]) + @@index([projectId, linkedReviewItemId]) + @@index([projectId, createdAt]) + @@index([projectId, deletedAt]) +} + +model ReviewQueueItem { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + type String + title String + description String + priority String + status ReviewStatus @default(pending) + source String + confidence Int @default(0) + approvalState ApprovalState @default(pending) + evidenceStrength EvidenceStrength @default(weak) + verificationState VerificationState @default(unverified) + suggestedAction String + notes String? + payloadJson Json? + provenanceJson Json? + createdEntityType EntityType? + createdEntityId String? + targetEntityType EntityType? + targetEntityId String? + verifiedAt DateTime? + verifiedBy String? + createdBy String @default("system") + updatedBy String? + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, status]) + @@index([projectId, type]) + @@index([projectId, priority]) + @@index([projectId, createdAt]) + @@index([projectId, deletedAt]) +} + +model Initiative { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + title String + intent String + status String @default("proposed") + priority String @default("normal") + featureIdsJson Json @default("[]") + approvalState ApprovalState @default(pending) + confidence Int @default(0) + sourceBrainstormId String? + reviewItemId String? + metadataJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("brainstorm_cpu") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deliveryPlan DeliveryPlan? + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, status]) + @@index([projectId, approvalState]) + @@index([projectId, sourceBrainstormId]) + @@index([projectId, deletedAt]) +} + +model BrainstormSession { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + title String + prompt String + scope String + mode String + status String @default("exploring") + targetEntityId String? + targetEntityType String? + generatedBy String @default("manual") + contextSummary String + ideasJson Json + clustersJson Json + outcomesJson Json + selectedIdeaIdsJson Json + nextActionsJson Json + metadataJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("brainstorm_cpu") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, scope]) + @@index([projectId, mode]) + @@index([projectId, status]) + @@index([projectId, targetEntityType, targetEntityId]) + @@index([projectId, deletedAt]) +} + +model IdempotencyRecord { + id String @id @default(cuid()) + projectId String? + method String + path String + key String + requestHash String + statusCode Int + responseJson Json + createdAt DateTime @default(now()) + expiresAt DateTime + + @@unique([method, path, key]) + @@index([projectId]) + @@index([expiresAt]) +} + +model EntityRelation { + id String @id @default(cuid()) + projectId String + sourceEntityType EntityType + sourceEntityId String + targetEntityType EntityType + targetEntityId String + relationshipType String + metadataJson Json? + createdBy String @default("system") + sourceType String @default("fixture_seed") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId]) + @@index([projectId, sourceEntityType, sourceEntityId]) + @@index([projectId, targetEntityType, targetEntityId]) + @@index([projectId, relationshipType]) + @@index([projectId, deletedAt]) +} + +model AcceptedTruthItem { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + sourceReviewItemId String + kind String + title String + summary String + status String @default("accepted") + source String + confidence Int @default(0) + approvalState ApprovalState @default(approved) + evidenceStrength EvidenceStrength @default(weak) + verificationState VerificationState @default(verified) + payloadJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("review_queue") + verifiedAt DateTime? + verifiedBy String? + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, sourceReviewItemId]) + @@index([projectId, kind]) + @@index([projectId, status]) + @@index([projectId, createdAt]) + @@index([projectId, deletedAt]) +} + +model ProjectUpdate { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + sourceTruthItemId String + kind String + title String + summary String + targetArea String + status String @default("ready") + confidence Int @default(0) + approvalState ApprovalState @default(pending) + evidenceStrength EvidenceStrength @default(weak) + payloadJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("accepted_truth") + appliedAt DateTime? + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, sourceTruthItemId]) + @@index([projectId, targetArea]) + @@index([projectId, status]) + @@index([projectId, createdAt]) + @@index([projectId, deletedAt]) +} + +model OperatingLogEntry { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + timestamp DateTime @default(now()) + type String + title String + summary String + linkedEntityIdsJson Json + source String + importance String @default("normal") + payloadJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("runtime") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, type]) + @@index([projectId, timestamp]) + @@index([projectId, deletedAt]) +} + +model CurrentStateReport { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + generatedAt DateTime @default(now()) + existsJson Json + worksJson Json + uncertainJson Json + brokenJson Json + currentRisksJson Json + pendingReviewItemIdsJson Json + activeContextPackId String? + recommendedNextActionsJson Json + source String @default("runtime") + approvalState ApprovalState @default(pending) + payloadJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("runtime") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, generatedAt]) + @@index([projectId, approvalState]) + @@index([projectId, deletedAt]) +} + +model ReleaseEvidenceRecord { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + capturedAt DateTime @default(now()) + kind String + title String + summary String + status String @default("captured") + command String? + outputText String? + route String? + source String @default("runtime_readiness") + confidence Int @default(100) + approvalState ApprovalState @default(approved) + evidenceStrength EvidenceStrength @default(verified) + verificationState VerificationState @default(verified) + metadataJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("release_evidence") + verifiedAt DateTime? + verifiedBy String? + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, kind]) + @@index([projectId, status]) + @@index([projectId, capturedAt]) + @@index([projectId, verificationState]) + @@index([projectId, deletedAt]) +} + +model HandoffBundle { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + target String + title String + markdown String + qualityScore Int @default(0) + sourceTruthItemIdsJson Json + sourceReviewItemIdsJson Json + sourceCaptureItemIdsJson Json + sourceProjectUpdateIdsJson Json? + sourceOperatingLogEntryIdsJson Json? + status String @default("ready") + approvalState ApprovalState @default(pending) + payloadJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("runtime") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + codexTasks CodexTask[] + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, target]) + @@index([projectId, status]) + @@index([projectId, createdAt]) + @@index([projectId, deletedAt]) +} + +model CodexTask { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + handoffBundleId String? + goal String + contextPackId String? + constraintsJson Json + relevantFilesJson Json + doNotBreakRulesJson Json + validationCommandsJson Json + expectedReportFormat String + outputImportInstructions String + approvalState ApprovalState @default(pending) + status String @default("draft") + payloadJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("handoff") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + handoffBundle HandoffBundle? @relation(fields: [handoffBundleId], references: [id], onDelete: SetNull) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, handoffBundleId]) + @@index([projectId, status]) + @@index([projectId, approvalState]) + @@index([projectId, deletedAt]) +} + +model CodexRunImport { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + codexTaskId String? + sourceRun String + summary String + extractedReviewItemIdsJson Json + status String @default("imported") + payloadJson Json? + provenanceJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("codex_output") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, codexTaskId]) + @@index([projectId, status]) + @@index([projectId, deletedAt]) +} + +model ActivityEvent { + id String @id @default(cuid()) + projectId String + entityType EntityType + entityId String + eventType String + actor String + payloadJson Json? + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId]) + @@index([projectId, entityType, entityId]) + @@index([projectId, eventType]) + @@index([projectId, createdAt]) +} + +model WorkGraphNode { + id String @id @default(cuid()) + projectId String + stableKey String + source String + sourceId String + type String + title String + canonicalLifecycle String @default("ready") + domainLifecycle String @default("pending") + scope String @default("committed") + priority Int @default(50) + phase String? + risk String @default("normal") + blockingStatus String @default("open") + executionEligibility String @default("blocked") + blockReason String? + owner String? + leaseToken String? + leaseExpiresAt DateTime? + attemptCount Int @default(0) + consecutiveFailureCount Int @default(0) + recoveryStartedAt DateTime? + lastMeaningfulProgressAt DateTime? + completionEvidenceJson Json? + metadataJson Json? + supersededById String? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + dependencies WorkGraphDependency[] @relation("WorkGraphNodeDependencies") + dependents WorkGraphDependency[] @relation("WorkGraphNodeDependents") + auditEvents WorkGraphAuditEvent[] + evidence WorkGraphEvidence[] + supersededBy WorkGraphNode? @relation("WorkGraphSupersession", fields: [supersededById], references: [id], onDelete: SetNull) + supersedes WorkGraphNode[] @relation("WorkGraphSupersession") + + @@unique([projectId, stableKey]) + @@unique([projectId, source, sourceId]) + @@index([projectId, executionEligibility, priority]) + @@index([projectId, canonicalLifecycle]) + @@index([projectId, scope]) + @@index([projectId, leaseExpiresAt]) + @@index([projectId, updatedAt]) + @@index([projectId, supersededById]) +} + +model WorkGraphDependency { + id String @id @default(cuid()) + projectId String + nodeId String + dependsOnNodeId String + dependencyType String @default("blocks") + createdBy String @default("system") + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + node WorkGraphNode @relation("WorkGraphNodeDependencies", fields: [nodeId], references: [id], onDelete: Cascade) + dependsOnNode WorkGraphNode @relation("WorkGraphNodeDependents", fields: [dependsOnNodeId], references: [id], onDelete: Cascade) + + @@unique([nodeId, dependsOnNodeId]) + @@index([projectId, nodeId]) + @@index([projectId, dependsOnNodeId]) +} + +model WorkGraphAuditEvent { + id String @id @default(cuid()) + projectId String + nodeId String? + eventType String + actor String + summary String + payloadJson Json? + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + node WorkGraphNode? @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@index([projectId, createdAt]) + @@index([projectId, nodeId, createdAt]) + @@index([projectId, eventType]) +} + +model WorkGraphEvidence { + id String @id @default(cuid()) + projectId String + nodeId String + kind String + referenceType String + referenceId String + status String @default("observed") + summary String + metadataJson Json? + verifiedAt DateTime? + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + node WorkGraphNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@unique([nodeId, kind, referenceType, referenceId]) + @@index([projectId, nodeId]) + @@index([projectId, status]) +} + +model ProjectDefinitionOfDone { + id String @id @default(cuid()) + projectId String @unique + requirementsJson Json + scopePolicyJson Json + status String @default("incomplete") + satisfiedCount Int @default(0) + requirementCount Int @default(0) + lastEvaluatedAt DateTime? + lastEvidenceAt DateTime? + evaluationJson Json? + createdBy String @default("system") + updatedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([status, lastEvaluatedAt]) +} + +model ProjectSavedView { + id String @id @default(cuid()) + ownerKey String + projectId String? + name String + scope String @default("portfolio") + filtersJson Json + isPinned Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([ownerKey, scope, name]) + @@index([ownerKey, scope, updatedAt]) + @@index([projectId, updatedAt]) +} + +// Per-user project pins are deliberately separate from pinned saved views: +// one is a reusable filter shortcut, the other controls what appears in it. +model ProjectPortfolioPin { + id String @id @default(cuid()) + ownerKey String + projectId String + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([ownerKey, projectId]) + @@index([ownerKey, createdAt]) + @@index([projectId]) +} + +model DiagnosticRun { + id String @id @default(cuid()) + projectId String? + scope String @default("platform") + status String @default("running") + summary String? + checksJson Json + repairPlanJson Json? + startedAt DateTime @default(now()) + completedAt DateTime? + createdBy String @default("system") + createdAt DateTime @default(now()) + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId, createdAt]) + @@index([scope, status, createdAt]) +} + +model BlueprintGraph { + id String @id @default(cuid()) + projectId String + slug String? + readableId String? + archetypeId String + archetypeLabel String + depth String + status String @default("planned") + documentRoot String @default("docs/") + intakeSummary String + assumptionsJson Json + exportManifestJson Json + recommendedNextActionsJson Json + metadataJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("blueprint_engine") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + nodes BlueprintNode[] + runs BlueprintNodeRun[] + + @@unique([projectId, slug]) + @@unique([projectId, readableId]) + @@index([projectId]) + @@index([projectId, archetypeId]) + @@index([projectId, status]) + @@index([projectId, deletedAt]) +} + +model BlueprintNode { + id String @id @default(cuid()) + projectId String + graphId String + templateId String + kind String + title String + cluster String + status String @default("pending") + generationOrder Int + requiredDepth String + dependsOnJson Json + dependencyTitlesJson Json + blockedByJson Json + outputPath String + purpose String + requiredContextJson Json + qualityChecksJson Json + producesJson Json + promptPreview String + contextSelectionJson Json + documentationItemId String? + reviewItemId String? + latestRunId String? + staleReason String? + metadataJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("blueprint_engine") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + graph BlueprintGraph @relation(fields: [graphId], references: [id], onDelete: Cascade) + runs BlueprintNodeRun[] + + @@unique([graphId, templateId]) + @@index([projectId]) + @@index([projectId, graphId]) + @@index([projectId, status]) + @@index([projectId, cluster]) + @@index([projectId, outputPath]) + @@index([projectId, deletedAt]) +} + +model BlueprintNodeRun { + id String @id @default(cuid()) + projectId String + graphId String + nodeId String + status String @default("needs_review") + generatedBy String @default("deterministic") + inputSnapshotJson Json + prompt String + draftMarkdown String + qualityReportJson Json + documentationItemId String? + reviewItemId String? + metadataJson Json? + createdBy String @default("api") + updatedBy String? + sourceType String @default("blueprint_engine") + archivedAt DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + graph BlueprintGraph @relation(fields: [graphId], references: [id], onDelete: Cascade) + node BlueprintNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@index([projectId]) + @@index([projectId, graphId]) + @@index([projectId, nodeId]) + @@index([projectId, status]) + @@index([projectId, createdAt]) + @@index([projectId, deletedAt]) +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..30f1203 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,828 @@ +import { + ApprovalState, + EvidenceStrength, + FeatureLifecycle, + FeatureStatus, + ReviewStatus, + PrismaClient, + VerificationState, + type Prisma, +} from "@prisma/client"; +import { + findProject, + getWorkspaceRead, +} from "../src/fixtures/projectFixtures.js"; +import { contextPackFixtures } from "../src/fixtures/contextPackFixtures.js"; +import { knowledgeFixtures } from "../src/fixtures/knowledgeFixtures.js"; +import { memoryRuleFixtures } from "../src/fixtures/memoryRuleFixtures.js"; +import { captureFixtures } from "../src/fixtures/captureFixtures.js"; +import { reviewQueueFixtures } from "../src/fixtures/reviewQueueFixtures.js"; +import { + evidenceFixtures, + featureFixtures, + featureGroupFixtures, +} from "../src/fixtures/featureFixtures.js"; + +const prisma = new PrismaClient(); + +const widefrog = findProject("widefrog"); + +const toJson = (value: unknown): Prisma.InputJsonValue => + JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; + +const main = async () => { + if (!widefrog) { + throw new Error("WideFrog fixture is missing; cannot seed P2 database."); + } + + const project = await prisma.project.upsert({ + where: { slug: widefrog.id }, + create: { + slug: widefrog.id, + readableId: widefrog.id, + name: widefrog.name, + description: widefrog.description, + domain: widefrog.domain, + status: widefrog.status, + health: widefrog.health, + healthStatus: widefrog.healthStatus, + activeContextPackId: widefrog.activeContextPackId, + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + createdAt: new Date(widefrog.createdAt), + }, + update: { + readableId: widefrog.id, + name: widefrog.name, + description: widefrog.description, + domain: widefrog.domain, + status: widefrog.status, + health: widefrog.health, + healthStatus: widefrog.healthStatus, + activeContextPackId: widefrog.activeContextPackId, + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + + await prisma.projectDNA.upsert({ + where: { projectId: project.id }, + create: { + projectId: project.id, + vision: widefrog.dna.vision, + goalsJson: toJson(widefrog.dna.goals), + nonGoalsJson: toJson(widefrog.dna.nonGoals), + targetAudience: widefrog.dna.targetAudience, + principlesJson: toJson(widefrog.dna.principles), + metricsJson: toJson(widefrog.dna.successMetrics), + maturityStage: widefrog.dna.maturityStage, + revision: widefrog.dna.revision, + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + }, + update: { + vision: widefrog.dna.vision, + goalsJson: toJson(widefrog.dna.goals), + nonGoalsJson: toJson(widefrog.dna.nonGoals), + targetAudience: widefrog.dna.targetAudience, + principlesJson: toJson(widefrog.dna.principles), + metricsJson: toJson(widefrog.dna.successMetrics), + maturityStage: widefrog.dna.maturityStage, + revision: widefrog.dna.revision, + updatedBy: "seed", + sourceType: "fixture_seed", + }, + }); + + const workspaceRead = getWorkspaceRead(widefrog); + const workspaceState = widefrog.workspaceState; + + if (workspaceState) { + await prisma.workspaceState.upsert({ + where: { projectId: project.id }, + create: { + projectId: project.id, + activeContextPackId: workspaceState.activeContextPackId, + primaryObjective: workspaceState.primaryObjective, + activeSection: workspaceState.activeSection, + notesJson: toJson({ + workspaceState, + focusAreas: workspaceRead.focusAreas, + activeFocusAreaId: workspaceRead.activeFocusAreaId, + currentStateSummary: workspaceRead.currentStateSummary, + healthSummary: workspaceRead.healthSummary, + }), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + }, + update: { + activeContextPackId: workspaceState.activeContextPackId, + primaryObjective: workspaceState.primaryObjective, + activeSection: workspaceState.activeSection, + notesJson: toJson({ + workspaceState, + focusAreas: workspaceRead.focusAreas, + activeFocusAreaId: workspaceRead.activeFocusAreaId, + currentStateSummary: workspaceRead.currentStateSummary, + healthSummary: workspaceRead.healthSummary, + }), + updatedBy: "seed", + sourceType: "fixture_seed", + }, + }); + } + + + const seededProjects = new Map([[widefrog.id, project.id]]); + + for (const group of featureGroupFixtures.filter((item) => seededProjects.has(item.projectId))) { + const projectId = seededProjects.get(group.projectId)!; + await prisma.featureGroup.upsert({ + where: { + projectId_slug: { + projectId, + slug: group.id, + }, + }, + create: { + projectId, + slug: group.id, + readableId: group.id, + name: group.name, + description: group.description, + focusArea: group.focusArea, + }, + update: { + readableId: group.id, + name: group.name, + description: group.description, + focusArea: group.focusArea, + deletedAt: null, + archivedAt: null, + }, + }); + } + + const mapFeatureStatus = (status: string): FeatureStatus => { + if (status === "Done") return FeatureStatus.done; + if (status === "In Progress") return FeatureStatus.in_progress; + return FeatureStatus.todo; + }; + + const mapFeatureLifecycle = (feature: { lifecycle?: string; status: string }): FeatureLifecycle => { + if (feature.lifecycle && feature.lifecycle in FeatureLifecycle) { + return FeatureLifecycle[feature.lifecycle as keyof typeof FeatureLifecycle]; + } + if (feature.status === "Done") return FeatureLifecycle.verified; + if (feature.status === "In Progress") return FeatureLifecycle.active; + return FeatureLifecycle.planned; + }; + + const mapVerificationState = (status: string): VerificationState => { + if (status === "Done") return VerificationState.verified; + if (status === "In Progress") return VerificationState.needs_evidence; + return VerificationState.unverified; + }; + + const mapEvidenceStrength = (confidence: number): EvidenceStrength => { + if (confidence >= 95) return EvidenceStrength.verified; + if (confidence >= 85) return EvidenceStrength.strong; + if (confidence >= 70) return EvidenceStrength.medium; + return EvidenceStrength.weak; + }; + + const mapApprovalState = (approvalState: string): ApprovalState => { + if (approvalState === "approved") return ApprovalState.approved; + if (approvalState === "rejected") return ApprovalState.rejected; + if (approvalState === "blocked") return ApprovalState.blocked; + return ApprovalState.pending; + }; + + const mapFixtureEvidenceStrength = (strength: string | undefined, confidence: number): EvidenceStrength => { + if (strength && strength in EvidenceStrength) { + return EvidenceStrength[strength as keyof typeof EvidenceStrength]; + } + return mapEvidenceStrength(confidence); + }; + + for (const feature of featureFixtures.filter((item) => seededProjects.has(item.projectId))) { + const projectId = seededProjects.get(feature.projectId)!; + const group = await prisma.featureGroup.findFirst({ + where: { + projectId, + slug: feature.groupId, + deletedAt: null, + }, + }); + + if (!group) { + throw new Error(`Feature group '${feature.groupId}' is missing for feature '${feature.id}'.`); + } + + await prisma.feature.upsert({ + where: { + projectId_slug: { + projectId, + slug: feature.id, + }, + }, + create: { + projectId, + featureGroupId: group.id, + slug: feature.id, + readableId: feature.id, + name: feature.name, + description: feature.description, + status: mapFeatureStatus(feature.status), + lifecycle: mapFeatureLifecycle(feature), + intent: feature.intent, + confidence: feature.confidence, + health: feature.health, + approvalState: feature.status === "Done" ? ApprovalState.approved : ApprovalState.pending, + evidenceStrength: mapFixtureEvidenceStrength(feature.evidenceStrength, feature.confidence), + verificationState: mapVerificationState(feature.status), + trustJson: toJson({ + bugCount: feature.bugCount, + taskCount: feature.taskCount, + decisionCount: feature.decisionCount, + codeEvidence: feature.codeEvidence, + aiSuggestionCount: feature.aiSuggestionCount, + evidenceIds: feature.evidenceIds, + initiativeId: feature.initiativeId, + }), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/featureFixtures.ts", + fixtureId: feature.id, + }), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + }, + update: { + featureGroupId: group.id, + readableId: feature.id, + name: feature.name, + description: feature.description, + status: mapFeatureStatus(feature.status), + lifecycle: mapFeatureLifecycle(feature), + intent: feature.intent, + confidence: feature.confidence, + health: feature.health, + approvalState: feature.status === "Done" ? ApprovalState.approved : ApprovalState.pending, + evidenceStrength: mapFixtureEvidenceStrength(feature.evidenceStrength, feature.confidence), + verificationState: mapVerificationState(feature.status), + trustJson: toJson({ + bugCount: feature.bugCount, + taskCount: feature.taskCount, + decisionCount: feature.decisionCount, + codeEvidence: feature.codeEvidence, + aiSuggestionCount: feature.aiSuggestionCount, + evidenceIds: feature.evidenceIds, + initiativeId: feature.initiativeId, + }), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/featureFixtures.ts", + fixtureId: feature.id, + }), + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + } + + for (const evidence of evidenceFixtures.filter((item) => seededProjects.has(item.projectId))) { + const projectId = seededProjects.get(evidence.projectId)!; + await prisma.evidence.upsert({ + where: { + projectId_slug: { + projectId, + slug: evidence.id, + }, + }, + create: { + projectId, + slug: evidence.id, + readableId: evidence.id, + type: evidence.type, + source: evidence.source, + confidence: evidence.confidence, + approvalState: mapApprovalState(evidence.approvalState), + evidenceStrength: mapEvidenceStrength(evidence.confidence), + verificationState: evidence.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified, + reason: evidence.reason, + metadataJson: toJson({ + linkedEntity: evidence.linkedEntity, + featureIds: evidence.featureIds, + route: evidence.route, + }), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/featureFixtures.ts", + fixtureId: evidence.id, + }), + lastSeen: new Date(evidence.lastSeen), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + }, + update: { + readableId: evidence.id, + type: evidence.type, + source: evidence.source, + confidence: evidence.confidence, + approvalState: mapApprovalState(evidence.approvalState), + evidenceStrength: mapEvidenceStrength(evidence.confidence), + verificationState: evidence.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified, + reason: evidence.reason, + metadataJson: toJson({ + linkedEntity: evidence.linkedEntity, + featureIds: evidence.featureIds, + route: evidence.route, + }), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/featureFixtures.ts", + fixtureId: evidence.id, + }), + lastSeen: new Date(evidence.lastSeen), + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + } + + + + for (const contextPack of contextPackFixtures.filter((item) => seededProjects.has(item.projectId))) { + const projectId = seededProjects.get(contextPack.projectId)!; + await prisma.contextPack.upsert({ + where: { + projectId_slug: { + projectId, + slug: contextPack.id, + }, + }, + create: { + projectId, + slug: contextPack.id, + readableId: contextPack.id, + title: contextPack.title, + category: contextPack.category, + summary: contextPack.summary, + generatedBy: contextPack.generatedBy, + confidence: contextPack.trust.confidence, + approvalState: mapApprovalState(contextPack.trust.approvalState), + qualityJson: toJson(contextPack.quality ?? {}), + trustJson: toJson(contextPack.trust), + metadataJson: toJson({ + dna: contextPack.dna, + assumptions: contextPack.assumptions, + constraints: contextPack.constraints, + evidence: contextPack.evidence, + signals: contextPack.signals, + featureIds: contextPack.featureIds, + bugIds: contextPack.bugIds, + taskIds: contextPack.taskIds, + decisionIds: contextPack.decisionIds, + memoryRuleIds: contextPack.memoryRuleIds, + architectureNotes: contextPack.architectureNotes, + regressions: contextPack.regressions, + risks: contextPack.risks, + currentFocus: contextPack.currentFocus, + }), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/contextPackFixtures.ts", + fixtureId: contextPack.id, + }), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + createdAt: new Date(contextPack.createdAt), + sections: { + create: [ + { + projectId, + slug: "dna", + title: "Project DNA", + sectionType: "dna", + orderIndex: 1, + contentJson: toJson(contextPack.dna), + }, + { + projectId, + slug: "features", + title: "Relevant features", + sectionType: "features", + orderIndex: 2, + contentJson: toJson(contextPack.featureIds ?? []), + }, + { + projectId, + slug: "evidence", + title: "Evidence references", + sectionType: "evidence", + orderIndex: 3, + contentJson: toJson(contextPack.evidence), + }, + { + projectId, + slug: "risks", + title: "Risks", + sectionType: "risks", + orderIndex: 4, + contentJson: toJson(contextPack.risks ?? []), + }, + ], + }, + }, + update: { + readableId: contextPack.id, + title: contextPack.title, + category: contextPack.category, + summary: contextPack.summary, + generatedBy: contextPack.generatedBy, + confidence: contextPack.trust.confidence, + approvalState: mapApprovalState(contextPack.trust.approvalState), + qualityJson: toJson(contextPack.quality ?? {}), + trustJson: toJson(contextPack.trust), + metadataJson: toJson({ + dna: contextPack.dna, + assumptions: contextPack.assumptions, + constraints: contextPack.constraints, + evidence: contextPack.evidence, + signals: contextPack.signals, + featureIds: contextPack.featureIds, + bugIds: contextPack.bugIds, + taskIds: contextPack.taskIds, + decisionIds: contextPack.decisionIds, + memoryRuleIds: contextPack.memoryRuleIds, + architectureNotes: contextPack.architectureNotes, + regressions: contextPack.regressions, + risks: contextPack.risks, + currentFocus: contextPack.currentFocus, + }), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/contextPackFixtures.ts", + fixtureId: contextPack.id, + }), + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + + const seededContextPack = await prisma.contextPack.findFirstOrThrow({ + where: { projectId, slug: contextPack.id }, + }); + await prisma.contextPackSection.deleteMany({ where: { contextPackId: seededContextPack.id } }); + await prisma.contextPackSection.createMany({ + data: [ + { + projectId, + contextPackId: seededContextPack.id, + slug: "dna", + title: "Project DNA", + sectionType: "dna", + orderIndex: 1, + contentJson: toJson(contextPack.dna), + }, + { + projectId, + contextPackId: seededContextPack.id, + slug: "features", + title: "Relevant features", + sectionType: "features", + orderIndex: 2, + contentJson: toJson(contextPack.featureIds ?? []), + }, + { + projectId, + contextPackId: seededContextPack.id, + slug: "evidence", + title: "Evidence references", + sectionType: "evidence", + orderIndex: 3, + contentJson: toJson(contextPack.evidence), + }, + { + projectId, + contextPackId: seededContextPack.id, + slug: "risks", + title: "Risks", + sectionType: "risks", + orderIndex: 4, + contentJson: toJson(contextPack.risks ?? []), + }, + ], + }); + } + + + + for (const capture of captureFixtures.filter((item) => seededProjects.has(item.projectId))) { + const projectId = seededProjects.get(capture.projectId)!; + await prisma.captureItem.upsert({ + where: { + projectId_slug: { + projectId, + slug: capture.id, + }, + }, + create: { + projectId, + slug: capture.id, + readableId: capture.id, + type: capture.type, + title: capture.title, + content: capture.content, + source: capture.source, + status: capture.status, + classificationType: capture.classificationType, + confidence: capture.confidence, + evidenceStrength: capture.evidenceStrength + ? mapFixtureEvidenceStrength(capture.evidenceStrength, capture.confidence ?? 0) + : undefined, + suggestedAction: capture.suggestedAction, + linkedReviewItemId: capture.linkedReviewItemId, + metadataJson: toJson({}), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/captureFixtures.ts", + fixtureId: capture.id, + }), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + createdAt: new Date(capture.createdAt), + }, + update: { + readableId: capture.id, + type: capture.type, + title: capture.title, + content: capture.content, + source: capture.source, + status: capture.status, + classificationType: capture.classificationType, + confidence: capture.confidence, + evidenceStrength: capture.evidenceStrength + ? mapFixtureEvidenceStrength(capture.evidenceStrength, capture.confidence ?? 0) + : undefined, + suggestedAction: capture.suggestedAction, + linkedReviewItemId: capture.linkedReviewItemId, + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/captureFixtures.ts", + fixtureId: capture.id, + }), + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + } + + const mapReviewStatus = (status: string): ReviewStatus => { + if (status === "accepted") return ReviewStatus.accepted; + if (status === "rejected") return ReviewStatus.rejected; + if (status === "merged") return ReviewStatus.merged; + if (status === "converted") return ReviewStatus.converted; + return ReviewStatus.pending; + }; + + const mapReviewVerificationState = (state: string): VerificationState => { + if (state === "verified") return VerificationState.verified; + if (state === "blocked") return VerificationState.blocked; + if (state === "needs evidence") return VerificationState.needs_evidence; + return VerificationState.unverified; + }; + + for (const review of reviewQueueFixtures.filter((item) => seededProjects.has(item.projectId))) { + const projectId = seededProjects.get(review.projectId)!; + await prisma.reviewQueueItem.upsert({ + where: { + projectId_slug: { + projectId, + slug: review.id, + }, + }, + create: { + projectId, + slug: review.id, + readableId: review.id, + type: review.type, + title: review.title, + description: review.description, + priority: review.priority, + status: mapReviewStatus(review.status), + source: review.source, + confidence: review.confidence, + approvalState: review.status === "accepted" || review.status === "converted" || review.status === "merged" + ? ApprovalState.approved + : review.status === "rejected" + ? ApprovalState.rejected + : ApprovalState.pending, + evidenceStrength: mapFixtureEvidenceStrength(review.evidenceStrength, review.confidence), + verificationState: mapReviewVerificationState(review.verificationState), + suggestedAction: review.suggestedAction, + notes: review.notes, + targetEntityId: review.targetEntityId, + createdEntityId: review.createdEntityId, + payloadJson: toJson({ convertedTarget: review.convertedTarget }), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/reviewQueueFixtures.ts", + fixtureId: review.id, + }), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + createdAt: new Date(review.createdAt), + }, + update: { + readableId: review.id, + type: review.type, + title: review.title, + description: review.description, + priority: review.priority, + status: mapReviewStatus(review.status), + source: review.source, + confidence: review.confidence, + approvalState: review.status === "accepted" || review.status === "converted" || review.status === "merged" + ? ApprovalState.approved + : review.status === "rejected" + ? ApprovalState.rejected + : ApprovalState.pending, + evidenceStrength: mapFixtureEvidenceStrength(review.evidenceStrength, review.confidence), + verificationState: mapReviewVerificationState(review.verificationState), + suggestedAction: review.suggestedAction, + notes: review.notes, + targetEntityId: review.targetEntityId, + createdEntityId: review.createdEntityId, + payloadJson: toJson({ convertedTarget: review.convertedTarget }), + provenanceJson: toJson({ + seededFrom: "backend/src/fixtures/reviewQueueFixtures.ts", + fixtureId: review.id, + }), + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + } + + + + + for (const knowledge of knowledgeFixtures.filter((item) => seededProjects.has(item.projectId))) { + const projectId = seededProjects.get(knowledge.projectId)!; + await prisma.knowledgeItem.upsert({ + where: { projectId_slug: { projectId, slug: knowledge.id } }, + create: { + projectId, + slug: knowledge.id, + readableId: knowledge.id, + title: knowledge.title, + category: knowledge.category, + content: knowledge.content, + source: knowledge.source, + confidence: knowledge.confidence, + includeInContextPacks: knowledge.includeInContextPacks.length > 0, + approvalState: mapApprovalState(knowledge.trust.approvalState), + evidenceStrength: mapEvidenceStrength(knowledge.confidence), + verificationState: knowledge.trust.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified, + metadataJson: toJson({ linkedFeatureIds: knowledge.linkedFeatureIds, includeInContextPacks: knowledge.includeInContextPacks }), + trustJson: toJson(knowledge.trust), + provenanceJson: toJson({ seededFrom: "backend/src/fixtures/knowledgeFixtures.ts", fixtureId: knowledge.id }), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + createdAt: new Date(knowledge.createdAt), + }, + update: { + readableId: knowledge.id, + title: knowledge.title, + category: knowledge.category, + content: knowledge.content, + source: knowledge.source, + confidence: knowledge.confidence, + includeInContextPacks: knowledge.includeInContextPacks.length > 0, + approvalState: mapApprovalState(knowledge.trust.approvalState), + evidenceStrength: mapEvidenceStrength(knowledge.confidence), + verificationState: knowledge.trust.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified, + metadataJson: toJson({ linkedFeatureIds: knowledge.linkedFeatureIds, includeInContextPacks: knowledge.includeInContextPacks }), + trustJson: toJson(knowledge.trust), + provenanceJson: toJson({ seededFrom: "backend/src/fixtures/knowledgeFixtures.ts", fixtureId: knowledge.id }), + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + } + + for (const rule of memoryRuleFixtures.filter((item) => seededProjects.has(item.projectId))) { + const projectId = seededProjects.get(rule.projectId)!; + await prisma.memoryRule.upsert({ + where: { projectId_slug: { projectId, slug: rule.id } }, + create: { + projectId, + slug: rule.id, + readableId: rule.id, + title: rule.title, + rule: rule.rule, + scope: rule.scope, + category: rule.category, + severity: rule.severity, + includeInContextPacks: rule.includeInContextPacksDefault, + confidence: rule.trust.confidence, + approvalState: mapApprovalState(rule.trust.approvalState), + evidenceStrength: mapEvidenceStrength(rule.trust.confidence), + verificationState: rule.trust.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified, + trustJson: toJson(rule.trust), + provenanceJson: toJson({ linkedFeatureIds: rule.linkedFeatureIds, includeInContextPacks: rule.includeInContextPacks, seededFrom: "backend/src/fixtures/memoryRuleFixtures.ts", fixtureId: rule.id }), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + createdAt: new Date(rule.createdAt), + }, + update: { + readableId: rule.id, + title: rule.title, + rule: rule.rule, + scope: rule.scope, + category: rule.category, + severity: rule.severity, + includeInContextPacks: rule.includeInContextPacksDefault, + confidence: rule.trust.confidence, + approvalState: mapApprovalState(rule.trust.approvalState), + evidenceStrength: mapEvidenceStrength(rule.trust.confidence), + verificationState: rule.trust.approvalState === "approved" ? VerificationState.verified : VerificationState.unverified, + trustJson: toJson(rule.trust), + provenanceJson: toJson({ linkedFeatureIds: rule.linkedFeatureIds, includeInContextPacks: rule.includeInContextPacks, seededFrom: "backend/src/fixtures/memoryRuleFixtures.ts", fixtureId: rule.id }), + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + } + + const documentationSeed = [ + { id: "doc-wide-readme", title: "README summary", kind: "README summary", status: "outdated", linkedFeatureIds: [], approvalState: ApprovalState.pending, content: "Project README summary needs an approved refresh after runtime persistence work." }, + { id: "doc-wide-operating-model", title: "ProjectBrain Operating Model", kind: "architecture docs", status: "AI-generated", linkedFeatureIds: ["f-auth-mfa", "f-auth-social"], approvalState: ApprovalState.pending, content: "Operating model generated from existing project memory and runtime workflow." }, + { id: "doc-wide-codex", title: "Codex instructions", kind: "Codex instructions", status: "needs verification", linkedFeatureIds: [], approvalState: ApprovalState.pending, content: "Codex instructions must preserve review-before-truth boundaries." }, + { id: "doc-wide-decision-log", title: "Decision log", kind: "decision log", status: "approved", linkedFeatureIds: ["f-auth-mfa"], approvalState: ApprovalState.approved, content: "Decision log is approved and can feed context packs." }, + ]; + + for (const doc of documentationSeed) { + await prisma.documentationItem.upsert({ + where: { projectId_slug: { projectId: project.id, slug: doc.id } }, + create: { + projectId: project.id, + slug: doc.id, + readableId: doc.id, + title: doc.title, + kind: doc.kind, + status: doc.status, + approvalState: doc.approvalState, + content: doc.content, + metadataJson: toJson({ linkedFeatureIds: doc.linkedFeatureIds, lastUpdated: new Date().toISOString() }), + trustJson: toJson({ source: "documentation seed", confidence: doc.approvalState === ApprovalState.approved ? 90 : 70, evidence: [], lastChecked: new Date().toISOString(), approvalState: doc.approvalState }), + provenanceJson: toJson({ seededFrom: "backend/prisma/seed.ts", fixtureId: doc.id }), + createdBy: "seed", + updatedBy: "seed", + sourceType: "fixture_seed", + }, + update: { + readableId: doc.id, + title: doc.title, + kind: doc.kind, + status: doc.status, + approvalState: doc.approvalState, + content: doc.content, + metadataJson: toJson({ linkedFeatureIds: doc.linkedFeatureIds, lastUpdated: new Date().toISOString() }), + trustJson: toJson({ source: "documentation seed", confidence: doc.approvalState === ApprovalState.approved ? 90 : 70, evidence: [], lastChecked: new Date().toISOString(), approvalState: doc.approvalState }), + provenanceJson: toJson({ seededFrom: "backend/prisma/seed.ts", fixtureId: doc.id }), + updatedBy: "seed", + sourceType: "fixture_seed", + deletedAt: null, + archivedAt: null, + }, + }); + } + + console.log(`Seeded P2-P8 ProjectBrain persistence data for ${widefrog.id}.`); +}; + +try { + await main(); +} finally { + await prisma.$disconnect(); +} diff --git a/backend/scripts/audit-backend-contract.mjs b/backend/scripts/audit-backend-contract.mjs new file mode 100644 index 0000000..491560a --- /dev/null +++ b/backend/scripts/audit-backend-contract.mjs @@ -0,0 +1,283 @@ +#!/usr/bin/env node +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const backendRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const root = dirname(backendRoot); +const failures = []; +const notes = []; + +const read = (relativePath) => readFileSync(join(root, relativePath), "utf8"); +const exists = (relativePath) => existsSync(join(root, relativePath)); + +const requireFile = (relativePath) => { + if (!exists(relativePath)) failures.push(`Missing required backend contract file: ${relativePath}`); +}; + +const requireIncludes = (relativePath, markers) => { + if (!exists(relativePath)) { + failures.push(`Missing required file for marker audit: ${relativePath}`); + return; + } + + const text = read(relativePath); + for (const marker of markers) { + if (!text.includes(marker)) failures.push(`${relativePath} is missing marker: ${marker}`); + } +}; + +const rejectIncludes = (relativePath, markers) => { + if (!exists(relativePath)) return; + const text = read(relativePath); + for (const marker of markers) { + if (text.includes(marker)) failures.push(`${relativePath} still contains stale backend status marker: ${marker}`); + } +}; + +const routeRegistrations = [ + "registerHealthRoutes", + "registerAuthRoutes", + "registerEntraAuthRoutes", + "registerProjectRoutes", + "registerFeatureRoutes", + "registerEvidenceRoutes", + "registerContextPackRoutes", + "registerPromptRoutes", + "registerCheckRoutes", + "registerBrainstormRoutes", + "registerBuildRoutes", + "registerBlueprintRoutes", + "registerRegressionRoutes", + "registerSnapshotRoutes", + "registerInsightRoutes", + "registerRelationshipRoutes", + "registerDocumentationRoutes", + "registerKnowledgeRoutes", + "registerInitiativeRoutes", + "registerMemoryRuleRoutes", + "registerCaptureRoutes", + "registerReviewQueueRoutes", + "registerRuntimePersistenceRoutes", + "registerP7RuntimeRoutes", + "registerAIActionRoutes", + "registerSystemReadinessRoutes", +]; + +const routeFiles = [ + "backend/src/routes/health.ts", + "backend/src/routes/auth.ts", + "backend/src/routes/entraAuth.ts", + "backend/src/routes/projects.ts", + "backend/src/routes/features.ts", + "backend/src/routes/evidence.ts", + "backend/src/routes/contextPacks.ts", + "backend/src/routes/prompts.ts", + "backend/src/routes/checks.ts", + "backend/src/routes/brainstorms.ts", + "backend/src/routes/builds.ts", + "backend/src/routes/blueprints.ts", + "backend/src/routes/regressions.ts", + "backend/src/routes/snapshots.ts", + "backend/src/routes/insights.ts", + "backend/src/routes/relationships.ts", + "backend/src/routes/documentation.ts", + "backend/src/routes/knowledge.ts", + "backend/src/routes/initiatives.ts", + "backend/src/routes/memoryRules.ts", + "backend/src/routes/capture.ts", + "backend/src/routes/reviewQueue.ts", + "backend/src/routes/runtimePersistence.ts", + "backend/src/routes/p7Runtime.ts", + "backend/src/routes/aiActions.ts", + "backend/src/routes/systemReadiness.ts", +]; + +const endpointDocs = [ + "GET /api/health", + "GET /api/auth/entra/config", + "GET /api/auth/entra/start", + "GET /api/auth/entra/callback", + "GET /api/auth/entra/status", + "POST /api/auth/entra/link/start", + "DELETE /api/auth/entra/identity", + "GET /api/projects", + "POST /api/projects", + "GET /api/projects/:projectId", + "PATCH /api/projects/:projectId", + "GET /api/projects/:projectId/workspace", + "PATCH /api/projects/:projectId/workspace/active-context-pack", + "GET /api/projects/:projectId/dna", + "GET /api/projects/:projectId/feature-groups", + "GET /api/projects/:projectId/features", + "GET /api/projects/:projectId/evidence", + "GET /api/projects/:projectId/context-packs", + "POST /api/projects/:projectId/prompts/generate", + "GET /api/projects/:projectId/checks", + "GET /api/projects/:projectId/builds", + "GET /api/projects/:projectId/regressions", + "GET /api/projects/:projectId/snapshots", + "GET /api/projects/:projectId/insights", + "GET /api/projects/:projectId/relationships", + "GET /api/projects/:projectId/knowledge", + "GET /api/projects/:projectId/memory-rules", + "GET /api/projects/:projectId/capture-items", + "GET /api/projects/:projectId/review-queue", + "GET /api/projects/:projectId/ai-actions", + "GET /api/projects/:projectId/accepted-truth", + "GET /api/projects/:projectId/project-updates", + "GET /api/projects/:projectId/operating-log", + "GET /api/projects/:projectId/current-state", + "GET /api/projects/:projectId/handoff-bundles", + "GET /api/projects/:projectId/codex-tasks", + "GET /api/system/readiness", +]; + +const smokeScripts = [ + "smoke:p2", + "smoke:p3", + "smoke:p4", + "smoke:p5", + "smoke:p6", + "smoke:p7", + "smoke:p8", + "smoke:fixtures", + "smoke:runtime-suite", + "smoke:database-suite", + "smoke:system-readiness", + "smoke:blueprints", + "smoke:brainstorms", + "smoke:initiatives", +]; + +for (const file of [ + "backend/src/app.ts", + "backend/src/config/env.ts", + "backend/src/utils/envelope.ts", + "backend/src/plugins/errorHandler.ts", + "backend/src/plugins/requestId.ts", + "backend/src/db/prisma.ts", + "backend/prisma/schema.prisma", + "backend/prisma/seed.ts", + "backend/package.json", + "docs/REAL_BACKEND_ENDPOINT_INDEX.md", + "docs/PROJECTBRAIN_STATUS_AND_GATES.md", + "docs/BACKEND_RELEASE_COMPLETION_PASS.md", +]) { + requireFile(file); +} + +for (const routeFile of routeFiles) requireFile(routeFile); + +requireIncludes("backend/src/app.ts", routeRegistrations); +requireIncludes("backend/src/config/env.ts", [ + "PROJECTBRAIN_DATA_SOURCE", + "DATABASE_URL is required in production", + "DATABASE_URL is required when PROJECTBRAIN_DATA_SOURCE=database", +]); +requireIncludes("backend/src/utils/envelope.ts", ["successEnvelope", "errorEnvelope", "requestId", "timestamp"]); +requireIncludes("backend/src/db/prisma.ts", ["PrismaClient", "getPrismaClient", "prisma"]); +requireIncludes("backend/package.json", ["audit:contract", ...smokeScripts]); +requireIncludes("package.json", ["audit:backend-contract", "audit:all"]); +requireIncludes("docs/REAL_BACKEND_ENDPOINT_INDEX.md", endpointDocs); +requireIncludes("docs/PROJECTBRAIN_STATUS_AND_GATES.md", [ + "PostgreSQL production path", + "Gate 3 — Post-merge exact-SHA validation", + "Gate 4 — Runnerless deployment receipt", + "Gate 5 — Runtime verification", +]); +requireIncludes("docs/BACKEND_RELEASE_COMPLETION_PASS.md", [ + "Backend Release Completion Pass", + "Fixture mode", + "Database mode", + "Database-only Routes", + "Release Gates", +]); + +rejectIncludes("docs/REAL_BACKEND_ENDPOINT_INDEX.md", [ + "mostly fixture-backed", + "P2 database parity is prepared", + "must be locally smoke-tested before P3 begins", +]); +rejectIncludes("docs/PROJECTBRAIN_STATUS_AND_GATES.md", [ + "P2 DB parity prepared but not runtime-smoked locally yet", + "Required before P3", + "Can start only after Gate 2", + "Run P2 database smoke locally. If green, proceed to P3", +]); + +requireIncludes("backend/src/routes/brainstorms.ts", ["DATABASE_MODE_REQUIRED", "PROJECTBRAIN_DATA_SOURCE=database"]); +requireIncludes("backend/src/routes/initiatives.ts", ["DATABASE_MODE_REQUIRED", "PROJECTBRAIN_DATA_SOURCE=database"]); +requireIncludes("backend/src/routes/documentation.ts", ["DATABASE_MODE_REQUIRED", "PROJECTBRAIN_DATA_SOURCE=database"]); + +for (const file of [ + "backend/src/routes/projects.ts", + "backend/src/routes/features.ts", + "backend/src/routes/evidence.ts", + "backend/src/routes/contextPacks.ts", + "backend/src/routes/prompts.ts", + "backend/src/routes/capture.ts", + "backend/src/routes/reviewQueue.ts", + "backend/src/routes/runtimePersistence.ts", + "backend/src/routes/p7Runtime.ts", + "backend/src/routes/knowledge.ts", + "backend/src/routes/memoryRules.ts", + "backend/src/routes/blueprints.ts", +]) { + requireIncludes(file, ["dataSource", "database"]); +} + +requireIncludes("backend/scripts/smoke-fixture-api.mjs", [ + "R1 health", + "R2 projects/workspace/DNA", + "R3 features/evidence", + "R4 context packs/prompts", + "R5 checks/review", + "R6 builds/regressions/snapshots", + "R7 knowledge/memory/relationships/insights", + "R8 capture/review queue/AI actions", + "error envelopes", +]); + +const migrationsPath = join(root, "backend/prisma/migrations"); +if (!existsSync(migrationsPath)) { + failures.push("Missing backend/prisma/migrations"); +} else { + const migrationCount = readdirSync(migrationsPath, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length; + if (migrationCount < 8) failures.push(`Expected at least 8 backend migrations, found ${migrationCount}`); + else notes.push(`Migrations present: ${migrationCount}`); +} + +requireIncludes("backend/prisma/schema.prisma", [ + "model Project", + "model Feature", + "model Evidence", + "model ContextPack", + "model ReviewQueueItem", + "model CaptureItem", + "model AcceptedTruthItem", + "model ProjectUpdate", + "model OperatingLogEntry", + "model CurrentStateReport", + "model HandoffBundle", + "model CodexTask", + "model DocumentationItem", + "model KnowledgeItem", + "model MemoryRule", + "model BlueprintGraph", + "model BrainstormSession", + "model Initiative", + "model IdempotencyRecord", +]); + +console.log("ProjectBrain backend contract audit"); +console.log("==================================="); +for (const note of notes) console.log(`INFO ${note}`); + +if (failures.length) { + console.log("\nFailures:"); + for (const failure of failures) console.log(`- ${failure}`); + process.exit(1); +} + +console.log("\nPASS: backend contract surface, docs and release gates are aligned."); diff --git a/backend/scripts/docker-entrypoint.sh b/backend/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..b38c021 --- /dev/null +++ b/backend/scripts/docker-entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +LEGACY_INIT_MIGRATION="prisma/migrations/20260606200000_init_project_workspace" +if [ -d "$LEGACY_INIT_MIGRATION" ]; then + echo "[ProjectBrain] Removing obsolete duplicate init migration: $LEGACY_INIT_MIGRATION" + rm -rf "$LEGACY_INIT_MIGRATION" +fi + +FAILED_LEGACY_COUNT=$(./node_modules/.bin/prisma migrate status --schema prisma/schema.prisma 2>&1 | grep -c "20260606200000_init_project_workspace" || true) +if [ "$FAILED_LEGACY_COUNT" != "0" ]; then + echo "[ProjectBrain] Legacy migration 20260606200000_init_project_workspace is recorded in the database." + echo "[ProjectBrain] Marking it as rolled back so migrate deploy can continue with the corrected migration chain." + ./node_modules/.bin/prisma migrate resolve --rolled-back 20260606200000_init_project_workspace --schema prisma/schema.prisma || true +fi + +echo "[ProjectBrain] Running Prisma migrations..." +./node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma + +if [ "${PROJECTBRAIN_SEED_ON_START:-false}" = "true" ]; then + echo "[ProjectBrain] Seeding database..." + ./node_modules/.bin/tsx prisma/seed.ts +else + echo "[ProjectBrain] Skipping database seed. Set PROJECTBRAIN_SEED_ON_START=true to seed fixtures." +fi + +echo "[ProjectBrain] Starting API..." +exec node dist/server.js diff --git a/backend/scripts/prisma-generate.mjs b/backend/scripts/prisma-generate.mjs new file mode 100644 index 0000000..cdf425a --- /dev/null +++ b/backend/scripts/prisma-generate.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const prismaCli = require.resolve("prisma/build/index.js"); + +const child = spawn(process.execPath, [prismaCli, "generate", "--schema", "prisma/schema.prisma"], { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + PRISMA_HIDE_UPDATE_MESSAGE: "true", + }, +}); + +const writeFiltered = (stream, chunk) => { + const lines = chunk + .toString() + .split(/\r?\n/) + .filter((line) => { + const trimmed = line.trim(); + return ( + trimmed && + !trimmed.startsWith("Tip:") && + !trimmed.startsWith("Help us improve the Prisma ORM") && + !trimmed.includes("https://pris.ly/orm/survey/") + ); + }); + + if (lines.length) stream.write(`${lines.join("\n")}\n`); +}; + +child.stdout?.on("data", (chunk) => writeFiltered(process.stdout, chunk)); +child.stderr?.on("data", (chunk) => writeFiltered(process.stderr, chunk)); + +child.on("error", (error) => { + console.error(`Failed to start Prisma: ${error.message}`); + process.exit(1); +}); + +child.on("close", (code) => { + process.exit(code ?? 1); +}); diff --git a/backend/scripts/prisma-validate.mjs b/backend/scripts/prisma-validate.mjs new file mode 100644 index 0000000..618c3fd --- /dev/null +++ b/backend/scripts/prisma-validate.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const prismaCli = require.resolve("prisma/build/index.js"); + +const localValidationUrl = + "postgresql://projectbrain:projectbrain@localhost:5432/projectbrain?schema=public"; + +const child = spawn(process.execPath, [prismaCli, "validate", "--schema", "prisma/schema.prisma"], { + stdio: "inherit", + env: { + ...process.env, + DATABASE_URL: process.env.DATABASE_URL || localValidationUrl, + PRISMA_HIDE_UPDATE_MESSAGE: "true", + }, +}); + +child.on("error", (error) => { + console.error(`Failed to start Prisma: ${error.message}`); + process.exit(1); +}); + +child.on("close", (code) => { + process.exit(code ?? 1); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..36489d1 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,189 @@ +import Fastify, { LogController } from "fastify"; +import cookie from "@fastify/cookie"; +import helmet from "@fastify/helmet"; +import rateLimit from "@fastify/rate-limit"; +import formbody from "@fastify/formbody"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Writable } from "node:stream"; +import { loadEnv, type AppEnv } from "./config/env.js"; +import { errorHandlerPlugin } from "./plugins/errorHandler.js"; +import { authenticationPlugin } from "./plugins/authentication.js"; +import { createRequestId, requestIdPlugin } from "./plugins/requestId.js"; +import { registerAIActionRoutes } from "./routes/aiActions.js"; +import { registerActivityRoutes } from "./routes/activity.js"; +import { registerAuthRoutes } from "./routes/auth.js"; +import { registerEntraAuthRoutes } from "./routes/entraAuth.js"; +import { initializeOwner } from "./security/authStore.js"; +import { registerAutonomyRoutes } from "./routes/autonomy.js"; +import { registerBrainstormRoutes } from "./routes/brainstorms.js"; +import { registerBuildRoutes } from "./routes/builds.js"; +import { registerBlueprintRoutes } from "./routes/blueprints.js"; +import { registerCaptureRoutes } from "./routes/capture.js"; +import { registerCheckRoutes } from "./routes/checks.js"; +import { registerContextPackRoutes } from "./routes/contextPacks.js"; +import { registerEvidenceRoutes } from "./routes/evidence.js"; +import { registerDocumentationRoutes } from "./routes/documentation.js"; +import { registerFeatureRoutes } from "./routes/features.js"; +import { registerHealthRoutes } from "./routes/health.js"; +import { registerInsightRoutes } from "./routes/insights.js"; +import { registerInitiativeRoutes } from "./routes/initiatives.js"; +import { registerKnowledgeRoutes } from "./routes/knowledge.js"; +import { registerMemoryRuleRoutes } from "./routes/memoryRules.js"; +import { registerProjectRoutes } from "./routes/projects.js"; +import { registerPromptRoutes } from "./routes/prompts.js"; +import { registerRegressionRoutes } from "./routes/regressions.js"; +import { registerRelationshipRoutes } from "./routes/relationships.js"; +import { registerReleaseEvidenceRoutes } from "./routes/releaseEvidence.js"; +import { registerReviewQueueRoutes } from "./routes/reviewQueue.js"; +import { registerRuntimePersistenceRoutes } from "./routes/runtimePersistence.js"; +import { registerSystemReadinessRoutes } from "./routes/systemReadiness.js"; +import { registerP7RuntimeRoutes } from "./routes/p7Runtime.js"; +import { registerSnapshotRoutes } from "./routes/snapshots.js"; +import { observabilityPlugin } from "./plugins/observability.js"; +import { registerMetricsRoutes } from "./routes/metrics.js"; +import { registerGiteaIntegrationRoutes } from "./routes/giteaIntegration.js"; +import { registerMcpOAuthRoutes } from "./routes/mcpOAuth.js"; +import { registerMcpRoutes } from "./routes/mcp.js"; +import { registerAnalysisJobRoutes } from "./routes/analysisJobs.js"; +import { registerFindingRoutes } from "./routes/findings.js"; +import { registerPortfolioRoutes } from "./routes/portfolio.js"; +import { registerOperatingSystemRoutes } from "./routes/operatingSystem.js"; + +const filename = fileURLToPath(import.meta.url); +const currentDirectory = dirname(filename); + +const readPackageVersion = (): string => { + try { + const packageJsonPath = resolve(currentDirectory, "../package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + version?: string; + }; + + return packageJson.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +}; + +export type BuildAppOptions = { + env?: AppEnv; + logStream?: Writable; +}; + +export const buildApp = async (options: BuildAppOptions = {}) => { + const env = options.env ?? loadEnv(); + const app = Fastify({ + logController: new LogController({ disableRequestLogging: true }), + logger: { + level: env.LOG_LEVEL, + redact: { + paths: ["req.headers.authorization", "req.headers.cookie", "req.headers.x-csrf-token", "req.body", "res.headers.set-cookie", "username", "password", "currentPassword", "newPassword", "content", "summary", "outputText"], + censor: "[REDACTED]", + }, + ...(options.logStream ? { stream: options.logStream } : {}), + }, + genReqId: (request) => createRequestId(request.headers["x-request-id"]), + bodyLimit: 1_048_576, + trustProxy: env.PROJECTBRAIN_TRUST_PROXY, + }); + + app.removeContentTypeParser("application/json"); + app.addContentTypeParser("application/json", { parseAs: "buffer" }, (request, body, done) => { + const rawBody = Buffer.isBuffer(body) ? body : Buffer.from(body); + request.rawBody = rawBody; + try { done(null, JSON.parse(rawBody.toString("utf8"))); } + catch (error) { done(error as Error, undefined); } + }); + + await app.register(cookie, { secret: env.PROJECTBRAIN_AUTH_SECRET, hook: "onRequest" }); + await app.register(helmet, { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], baseUri: ["'self'"], frameAncestors: ["'none'"], objectSrc: ["'none'"] } } }); + await app.register(rateLimit, { global: true, max: 240, timeWindow: "1 minute" }); + await app.register(formbody); + await requestIdPlugin(app); + await observabilityPlugin(app); + await errorHandlerPlugin(app); + await initializeOwner(env); + await authenticationPlugin(app, env); + await app.register(registerHealthRoutes, { + env, + version: readPackageVersion(), + }); + await app.register(registerMetricsRoutes, { env }); + await app.register(registerAuthRoutes, { env }); + await app.register(registerEntraAuthRoutes, { env }); + await app.register(registerMcpOAuthRoutes, { env }); + await app.register(registerMcpRoutes, { env }); + await app.register(registerGiteaIntegrationRoutes, { env }); + await app.register(registerAnalysisJobRoutes, { env }); + await app.register(registerFindingRoutes, { env }); + await app.register(registerPortfolioRoutes, { env }); + await app.register(registerActivityRoutes, { env }); + await app.register(registerProjectRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + env, + }); + await app.register(registerAutonomyRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + env, + }); + await app.register(registerFeatureRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerEvidenceRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerContextPackRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerPromptRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerCheckRoutes); + await app.register(registerBrainstormRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerBuildRoutes); + await app.register(registerBlueprintRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerRegressionRoutes); + await app.register(registerSnapshotRoutes); + await app.register(registerInsightRoutes); + await app.register(registerRelationshipRoutes); + await app.register(registerDocumentationRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerKnowledgeRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerInitiativeRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerMemoryRuleRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerCaptureRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerReviewQueueRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerRuntimePersistenceRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerReleaseEvidenceRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerP7RuntimeRoutes, { + dataSource: env.PROJECTBRAIN_DATA_SOURCE, + }); + await app.register(registerAIActionRoutes); + await app.register(registerSystemReadinessRoutes, { + env, + }); + await app.register(registerOperatingSystemRoutes, { env }); + + return app; +}; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..78a7bc1 --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,154 @@ +import { z } from "zod"; + +const optionalEnvValue = (schema: T) => z.preprocess( + (value) => typeof value === "string" && value.trim() === "" ? undefined : value, + schema.optional(), +); + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + PORT: z.coerce.number().int().positive().default(4000), + HOST: z.string().min(1).default("0.0.0.0"), + LOG_LEVEL: z + .enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]) + .default("info"), + DATABASE_URL: z.string().min(1).optional(), + PROJECTBRAIN_DATA_SOURCE: z.enum(["fixture", "database"]).default("fixture"), + PROJECTBRAIN_OWNER_USERNAME: z.string().trim().min(3).max(80), + PROJECTBRAIN_OWNER_PASSWORD: z.string().min(12).max(256), + PROJECTBRAIN_AUTH_SECRET: z.string().min(32), + PROJECTBRAIN_SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(720).default(24), + PROJECTBRAIN_TRUST_PROXY: z.enum(["true", "false"]).default("false").transform((value) => value === "true"), + PROJECTBRAIN_GITEA_URL: z.string().url().optional(), + PROJECTBRAIN_GITEA_TOKEN: z.string().min(20).optional(), + PROJECTBRAIN_GITEA_WRITE_TOKEN: z.string().min(20).optional(), + PROJECTBRAIN_GITEA_WEBHOOK_SECRET: optionalEnvValue(z.string().min(24).max(512)), + PROJECTBRAIN_MCP_ENABLED: z.enum(["true", "false"]).default("false").transform((value) => value === "true"), + PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(60).max(5_000).default(1_200), + PROJECTBRAIN_PUBLIC_URL: z.string().url().optional(), + PROJECTBRAIN_HUB_SERVICE_TOKEN: optionalEnvValue(z.string().min(32).max(512)), + PROJECTBRAIN_ENTRA_TENANT_ID: optionalEnvValue(z.string().uuid()), + PROJECTBRAIN_ENTRA_CLIENT_ID: optionalEnvValue(z.string().uuid()), + PROJECTBRAIN_ENTRA_CLIENT_SECRET: optionalEnvValue(z.string().min(16)), + PROJECTBRAIN_ANALYSIS_WORKER_PORT: z.coerce.number().int().min(1024).max(65535).default(4001), + PROJECTBRAIN_ANALYSIS_POLL_MS: z.coerce.number().int().min(250).max(60_000).default(1_000), + PROJECTBRAIN_ANALYSIS_BATCH_SIZE: z.coerce.number().int().min(1).max(10).default(5), + PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY: z.coerce.number().int().min(1).max(2).default(2), + PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS: z.coerce.number().int().min(60).max(3_600).default(1_800), + PROJECTBRAIN_ANALYSIS_STALE_SECONDS: z.coerce.number().int().min(60).max(7_200).default(300), + PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES: z.coerce.number().int().min(0).max(43_200).default(1_440), + PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: z.enum(["true", "false"]).default("true").transform((value) => value === "true"), + PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: z.coerce.number().int().min(15).max(3_600).default(60), + // An ordinary ChatGPT Chat task runs the autonomous executor hourly through + // the ProjectBrain MCP plug-in. Keep a 30-minute grace window so ordinary + // scheduler jitter does not make a healthy executor appear disconnected. + PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: z.coerce.number().int().min(60).max(86_400).default(5_400), +}); + +export type AppEnv = z.infer; + +const productionPlaceholderValues = new Set([ + "replace-with-a-unique-password-of-at-least-12-characters", + "replace-with-at-least-32-random-characters", + "replace-with-a-unique-postgres-password", + "projectbrain", + "password", + "changeme", + "adminadminadmin", +]); + +const assertProductionSecret = (name: string, value: string) => { + const normalized = value.trim().toLowerCase(); + if (productionPlaceholderValues.has(normalized) || normalized.startsWith("replace-with-")) { + throw new Error(`${name} must not use a placeholder or known insecure default in production.`); + } +}; + +const assertProductionDatabaseUrl = (value: string) => { + const url = new URL(value); + const password = decodeURIComponent(url.password || ""); + // External PostgreSQL deployments may authenticate outside the URL. When an + // inline password is supplied, however, it must never be a shipped default. + if (password) assertProductionSecret("DATABASE_URL database password", password); +}; + +export const loadEnv = ( + input: NodeJS.ProcessEnv = process.env, +): AppEnv => { + const parsed = envSchema.parse({ + NODE_ENV: input.NODE_ENV, + PORT: input.PORT, + HOST: input.HOST, + LOG_LEVEL: input.LOG_LEVEL, + DATABASE_URL: input.DATABASE_URL, + PROJECTBRAIN_DATA_SOURCE: input.PROJECTBRAIN_DATA_SOURCE, + PROJECTBRAIN_OWNER_USERNAME: input.PROJECTBRAIN_OWNER_USERNAME, + PROJECTBRAIN_OWNER_PASSWORD: input.PROJECTBRAIN_OWNER_PASSWORD, + PROJECTBRAIN_AUTH_SECRET: input.PROJECTBRAIN_AUTH_SECRET, + PROJECTBRAIN_SESSION_TTL_HOURS: input.PROJECTBRAIN_SESSION_TTL_HOURS, + PROJECTBRAIN_TRUST_PROXY: input.PROJECTBRAIN_TRUST_PROXY, + PROJECTBRAIN_GITEA_URL: input.PROJECTBRAIN_GITEA_URL, + PROJECTBRAIN_GITEA_TOKEN: input.PROJECTBRAIN_GITEA_TOKEN, + PROJECTBRAIN_GITEA_WRITE_TOKEN: input.PROJECTBRAIN_GITEA_WRITE_TOKEN, + PROJECTBRAIN_GITEA_WEBHOOK_SECRET: input.PROJECTBRAIN_GITEA_WEBHOOK_SECRET, + PROJECTBRAIN_MCP_ENABLED: input.PROJECTBRAIN_MCP_ENABLED, + PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE: input.PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE, + PROJECTBRAIN_PUBLIC_URL: input.PROJECTBRAIN_PUBLIC_URL, + PROJECTBRAIN_HUB_SERVICE_TOKEN: input.PROJECTBRAIN_HUB_SERVICE_TOKEN, + PROJECTBRAIN_ENTRA_TENANT_ID: input.PROJECTBRAIN_ENTRA_TENANT_ID, + PROJECTBRAIN_ENTRA_CLIENT_ID: input.PROJECTBRAIN_ENTRA_CLIENT_ID, + PROJECTBRAIN_ENTRA_CLIENT_SECRET: input.PROJECTBRAIN_ENTRA_CLIENT_SECRET, + PROJECTBRAIN_ANALYSIS_WORKER_PORT: input.PROJECTBRAIN_ANALYSIS_WORKER_PORT, + PROJECTBRAIN_ANALYSIS_POLL_MS: input.PROJECTBRAIN_ANALYSIS_POLL_MS, + PROJECTBRAIN_ANALYSIS_BATCH_SIZE: input.PROJECTBRAIN_ANALYSIS_BATCH_SIZE, + PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY: input.PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY, + PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS: input.PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS, + PROJECTBRAIN_ANALYSIS_STALE_SECONDS: input.PROJECTBRAIN_ANALYSIS_STALE_SECONDS, + PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES: input.PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES, + PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: input.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES, + PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: input.PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS, + PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: input.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS, + }); + + if (parsed.NODE_ENV === "production" && !parsed.DATABASE_URL) { + throw new Error("DATABASE_URL is required in production."); + } + + if (parsed.PROJECTBRAIN_DATA_SOURCE === "database" && !parsed.DATABASE_URL) { + throw new Error( + "DATABASE_URL is required when PROJECTBRAIN_DATA_SOURCE=database.", + ); + } + + if (parsed.PROJECTBRAIN_MCP_ENABLED && (!parsed.PROJECTBRAIN_GITEA_URL || !parsed.PROJECTBRAIN_GITEA_TOKEN || !parsed.PROJECTBRAIN_PUBLIC_URL)) { + throw new Error("PROJECTBRAIN_GITEA_URL, PROJECTBRAIN_GITEA_TOKEN and PROJECTBRAIN_PUBLIC_URL are required when MCP is enabled."); + } + if (parsed.PROJECTBRAIN_MCP_ENABLED && parsed.PROJECTBRAIN_DATA_SOURCE !== "database") { + throw new Error("PROJECTBRAIN_DATA_SOURCE=database is required when MCP is enabled."); + } + if (parsed.NODE_ENV === "production" && parsed.PROJECTBRAIN_MCP_ENABLED && new URL(parsed.PROJECTBRAIN_PUBLIC_URL!).protocol !== "https:") { + throw new Error("PROJECTBRAIN_PUBLIC_URL must use HTTPS when MCP is enabled in production."); + } + + const entraValues = [parsed.PROJECTBRAIN_ENTRA_TENANT_ID, parsed.PROJECTBRAIN_ENTRA_CLIENT_ID, parsed.PROJECTBRAIN_ENTRA_CLIENT_SECRET]; + if (entraValues.some(Boolean) && !entraValues.every(Boolean)) { + throw new Error("PROJECTBRAIN_ENTRA_TENANT_ID, PROJECTBRAIN_ENTRA_CLIENT_ID and PROJECTBRAIN_ENTRA_CLIENT_SECRET must be configured together."); + } + if (entraValues.every(Boolean) && (!parsed.PROJECTBRAIN_PUBLIC_URL || parsed.PROJECTBRAIN_DATA_SOURCE !== "database")) { + throw new Error("PROJECTBRAIN_PUBLIC_URL and PROJECTBRAIN_DATA_SOURCE=database are required when Entra login is configured."); + } + if (parsed.NODE_ENV === "production" && entraValues.every(Boolean) && new URL(parsed.PROJECTBRAIN_PUBLIC_URL!).protocol !== "https:") { + throw new Error("PROJECTBRAIN_PUBLIC_URL must use HTTPS when Entra login is configured in production."); + } + + if (parsed.NODE_ENV === "production") { + assertProductionDatabaseUrl(parsed.DATABASE_URL!); + assertProductionSecret("PROJECTBRAIN_OWNER_PASSWORD", parsed.PROJECTBRAIN_OWNER_PASSWORD); + assertProductionSecret("PROJECTBRAIN_AUTH_SECRET", parsed.PROJECTBRAIN_AUTH_SECRET); + if (parsed.PROJECTBRAIN_GITEA_WEBHOOK_SECRET) assertProductionSecret("PROJECTBRAIN_GITEA_WEBHOOK_SECRET", parsed.PROJECTBRAIN_GITEA_WEBHOOK_SECRET); + if (parsed.PROJECTBRAIN_HUB_SERVICE_TOKEN) assertProductionSecret("PROJECTBRAIN_HUB_SERVICE_TOKEN", parsed.PROJECTBRAIN_HUB_SERVICE_TOKEN); + if (parsed.PROJECTBRAIN_ENTRA_CLIENT_SECRET) assertProductionSecret("PROJECTBRAIN_ENTRA_CLIENT_SECRET", parsed.PROJECTBRAIN_ENTRA_CLIENT_SECRET); + } + + return parsed; +}; diff --git a/backend/src/db/prisma.ts b/backend/src/db/prisma.ts new file mode 100644 index 0000000..4ab5fee --- /dev/null +++ b/backend/src/db/prisma.ts @@ -0,0 +1,17 @@ +import prismaPackage from "@prisma/client"; +import type { PrismaClient as PrismaClientType } from "@prisma/client"; + +const PrismaClient = prismaPackage.PrismaClient as new () => PrismaClientType; + +let prismaClient: PrismaClientType | undefined; + +export const getPrismaClient = (): PrismaClientType => { + prismaClient ??= new PrismaClient(); + return prismaClient; +}; + +export const prisma = new Proxy({} as PrismaClientType, { + get(_target, property, receiver) { + return Reflect.get(getPrismaClient(), property, receiver); + }, +}); diff --git a/backend/src/fixtures/aiActionFixtures.ts b/backend/src/fixtures/aiActionFixtures.ts new file mode 100644 index 0000000..4afa06c --- /dev/null +++ b/backend/src/fixtures/aiActionFixtures.ts @@ -0,0 +1,245 @@ +import { createReviewPreviewFromAIAction } from "./reviewQueueFixtures.js"; +import type { + AIActionDto, + AIActionListFilters, + AIActionRunDto, + AIActionRunListFilters, + RunAIActionRequestDto, +} from "../types/api.js"; + +let runCounter = 1; + +export const aiActionFixtures: AIActionDto[] = [ + { + id: "generate_features", + projectId: "widefrog", + label: "Generate Features", + category: "generation", + sourceType: ["constitution", "dna", "feature_map", "capture_items"], + outputTarget: ["review_queue", "roadmap"], + description: "Generate feature candidates from approved project context.", + requiresContextPack: true, + status: "available", + }, + { + id: "detect_bugs", + projectId: "widefrog", + label: "Detect Bugs", + category: "analysis", + sourceType: ["checks", "feature_map", "manual"], + outputTarget: ["review_queue"], + description: "Detect bug candidates from checks, notes and build summaries.", + requiresContextPack: false, + status: "available", + }, + { + id: "classify_notes", + projectId: "widefrog", + label: "Classify Notes", + category: "classification", + sourceType: ["capture_items", "manual"], + outputTarget: ["review_queue"], + description: "Classify captured notes into reviewable candidates.", + requiresContextPack: false, + status: "available", + }, + { + id: "extract_decisions", + projectId: "widefrog", + label: "Extract Decisions", + category: "extraction", + sourceType: ["capture_items", "manual"], + outputTarget: ["review_queue"], + description: "Extract decision candidates from captured material.", + requiresContextPack: false, + status: "available", + }, + { + id: "extract_memory_rules", + projectId: "widefrog", + label: "Extract Memory Rules", + category: "extraction", + sourceType: ["capture_items", "checks", "manual"], + outputTarget: ["review_queue", "context_pack"], + description: "Extract do-not-break and workflow memory rule candidates.", + requiresContextPack: false, + status: "available", + }, + { + id: "update_documentation", + projectId: "widefrog", + label: "Update Documentation", + category: "documentation", + sourceType: ["feature_map", "manual"], + outputTarget: ["documentation", "review_queue"], + description: "Generate deterministic documentation update suggestions.", + requiresContextPack: false, + status: "available", + }, + { + id: "generate_codex_task", + projectId: "widefrog", + label: "Generate Codex Task", + category: "generation", + sourceType: ["context_pack", "manual"], + outputTarget: ["codex_task"], + description: "Generate a structured Codex task from a context pack.", + requiresContextPack: true, + status: "available", + }, + { + id: "import_build_summary", + projectId: "widefrog", + label: "Import Build Summary", + category: "extraction", + sourceType: ["manual"], + outputTarget: ["review_queue"], + description: "Turn a build summary into reviewable project-memory candidates.", + requiresContextPack: false, + status: "available", + }, + { + id: "suggest_roadmap", + projectId: "widefrog", + label: "Suggest Roadmap", + category: "planning", + sourceType: ["feature_map", "context_pack"], + outputTarget: ["roadmap", "review_queue"], + description: "Suggest now/next/later planning candidates.", + requiresContextPack: true, + status: "available", + }, + { + id: "generate_current_state", + projectId: "widefrog", + label: "Generate Current State", + category: "analysis", + sourceType: ["constitution", "dna", "feature_map", "context_pack"], + outputTarget: ["current_state", "review_queue"], + description: "Summarize what exists, works, is uncertain and is risky.", + requiresContextPack: true, + status: "available", + }, + { + id: "detect_missing_docs_verification_tests_context_risks", + projectId: "widefrog", + label: "Detect Missing Docs, Verification, Tests, Context and Risks", + category: "analysis", + sourceType: ["checks", "feature_map", "context_pack"], + outputTarget: ["review_queue"], + description: "Detect missing documentation, verification, tests, context and risks.", + requiresContextPack: false, + status: "available", + }, +]; + +export const aiActionRunFixtures: AIActionRunDto[] = []; + +export const listAIActionsForProject = (projectId: string): AIActionDto[] => + aiActionFixtures.filter((action) => action.projectId === projectId); + +export const findAIAction = ( + projectId: string, + actionId: string, +): AIActionDto | undefined => + aiActionFixtures.find( + (action) => action.projectId === projectId && action.id === actionId, + ); + +export const applyAIActionFilters = ( + actions: AIActionDto[], + filters: AIActionListFilters, +): AIActionDto[] => + actions.filter((action) => { + if (filters.category?.length && !filters.category.includes(action.category)) { + return false; + } + if ( + filters.outputTarget?.length && + !filters.outputTarget.some((target) => action.outputTarget.includes(target)) + ) { + return false; + } + if ( + filters.sourceType?.length && + !filters.sourceType.some((source) => action.sourceType.includes(source)) + ) { + return false; + } + return true; + }); + +export const runAIAction = ( + projectId: string, + action: AIActionDto, + body: RunAIActionRequestDto, +): AIActionRunDto => { + const createdAt = new Date().toISOString(); + const inputSource = body.inputSource ?? action.sourceType[0] ?? "manual"; + const outputTarget = body.outputTarget ?? action.outputTarget[0] ?? "review_queue"; + const objective = body.objective?.trim() || `Run ${action.label}`; + const runId = `airun-${projectId}-${runCounter++}`; + const outputText = [ + `Deterministic mock run for ${action.id}.`, + `Objective: ${objective}.`, + `Input source: ${inputSource}${body.inputId ? ` (${body.inputId})` : ""}.`, + `Output target: ${outputTarget}.`, + "No real AI call was made.", + ].join("\n"); + const reviewQueuePreview = + outputTarget === "review_queue" + ? createReviewPreviewFromAIAction( + projectId, + action.id, + `${action.label} preview`, + outputText, + ) + : undefined; + const run: AIActionRunDto = { + id: runId, + projectId, + actionId: action.id, + status: "completed", + inputSource, + inputId: body.inputId, + outputTarget, + objective, + outputText, + createdFrom: action.id, + reviewQueuePreview, + createdAt, + }; + + aiActionRunFixtures.push(run); + return run; +}; + +export const listAIActionRunsForProject = (projectId: string): AIActionRunDto[] => + aiActionRunFixtures.filter((run) => run.projectId === projectId); + +export const findAIActionRun = ( + projectId: string, + runId: string, +): AIActionRunDto | undefined => + aiActionRunFixtures.find((run) => run.projectId === projectId && run.id === runId); + +export const applyAIActionRunFilters = ( + runs: AIActionRunDto[], + filters: AIActionRunListFilters, +): AIActionRunDto[] => + runs.filter((run) => { + if (filters.actionId?.length && !filters.actionId.includes(run.actionId)) { + return false; + } + if (filters.status?.length && !filters.status.includes(run.status)) return false; + if ( + filters.outputTarget?.length && + !filters.outputTarget.includes(run.outputTarget) + ) { + return false; + } + if (filters.createdFrom?.length && !filters.createdFrom.includes(run.createdFrom)) { + return false; + } + return true; + }); diff --git a/backend/src/fixtures/buildFixtures.ts b/backend/src/fixtures/buildFixtures.ts new file mode 100644 index 0000000..0411307 --- /dev/null +++ b/backend/src/fixtures/buildFixtures.ts @@ -0,0 +1,175 @@ +import type { + BuildHistoryDto, + BuildListFilters, + BuildValidationResult, + CreateBuildRequestDto, + TrustMetadataDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; + +const trust = (confidence: number): TrustMetadataDto => ({ + source: "deterministic build fixture", + confidence, + evidence: [], + lastChecked: trustNow, + approvalState: "approved", +}); + +let buildCounter = 103; + +export const buildFixtures: BuildHistoryDto[] = [ + { + id: "build-wide-01", + projectId: "widefrog", + buildNumber: "BLD-101", + agent: "Build Assistant", + goal: "Stabilize auth telemetry before stream release.", + targetTool: "agent-review", + persona: "delivery-engineer", + expectedOutput: "Updated telemetry checks and evidence matrix", + promptContextPackId: "cp-widefrog-core", + selectedContextPackIds: ["cp-widefrog-core"], + contextSelectionSummary: "Seed build uses the core context pack as its preserved prompt context.", + filesChanged: [ + "src/hooks/useMFA.ts", + "src/services/telemetry.ts", + "src/components/AuthGuard.tsx", + ], + validationResult: "pass", + regressionsIntroduced: [ + { + title: "Queue telemetry timing regression in low-bandwidth Safari", + severity: "medium", + linkedFeatureIds: ["f-auth-mfa"], + preventionRule: "R-SEC-02", + }, + ], + lessonsLearned: [ + "Route all streaming lifecycle signals through telemetry adapter.", + "Keep fallback queue handling aligned across auth modules.", + ], + linkedFeatureIds: ["f-auth-mfa"], + source: "import-analysis pipeline", + summary: "Build passed with pending accessibility review.", + status: "success", + createdAt: "2026-06-03T11:30:00Z", + trust: trust(94), + }, + { + id: "build-wide-02", + projectId: "widefrog", + buildNumber: "BLD-102", + agent: "Build Assistant", + goal: "Create regression guardrails for context-pack prompts.", + targetTool: "prompt-builder", + persona: "ai-trainer", + expectedOutput: "Prompt template + checks mapping + prevention tasks", + promptContextPackId: "cp-widefrog-core", + selectedContextPackIds: ["cp-widefrog-core"], + contextSelectionSummary: "Seed build uses the core context pack as its preserved prompt context.", + filesChanged: [ + "docs/analysis/context-pack-guidelines.md", + "src/shared/lib/mock-data.ts", + ], + validationResult: "warning", + regressionsIntroduced: [], + lessonsLearned: [ + "No new regressions introduced.", + "Memory pack includes Do-Not-Break constraints by default.", + ], + linkedFeatureIds: ["f-auth-mfa", "f-auth-social"], + source: "mock flow", + summary: "Context-prompt build finished with warning: one pending knowledge gap.", + status: "success", + createdAt: "2026-06-04T08:15:00Z", + trust: trust(89), + }, +]; + +export const listBuildsForProject = (projectId: string): BuildHistoryDto[] => + buildFixtures.filter((build) => build.projectId === projectId); + +export const findBuild = ( + projectId: string, + buildId: string, +): BuildHistoryDto | undefined => + buildFixtures.find((build) => build.projectId === projectId && build.id === buildId); + +export const applyBuildFilters = ( + builds: BuildHistoryDto[], + filters: BuildListFilters, +): BuildHistoryDto[] => + builds.filter((build) => { + if (filters.agent?.length && !filters.agent.includes(build.agent)) return false; + if (filters.result?.length && !filters.result.includes(build.validationResult)) { + return false; + } + if (filters.status?.length && !filters.status.includes(build.status)) return false; + if ( + filters.featureId && + !build.linkedFeatureIds.includes(filters.featureId) && + !build.regressionsIntroduced.some((regression) => + regression.linkedFeatureIds.includes(filters.featureId as string), + ) + ) { + return false; + } + if ( + filters.contextPackId && + build.promptContextPackId !== filters.contextPackId + ) { + return false; + } + + return true; + }); + +export const createBuild = ( + projectId: string, + body: CreateBuildRequestDto, +): BuildHistoryDto => { + const validationResult = toValidationResult(body.validationResult); + const createdAt = new Date().toISOString(); + const buildNumber = `BLD-${buildCounter++}`; + const build: BuildHistoryDto = { + id: `build-${projectId}-${buildNumber.toLowerCase()}`, + projectId, + buildNumber, + agent: body.agent ?? "manual", + goal: body.goal.trim(), + targetTool: body.agent ?? "manual", + persona: body.agent === "codex" ? "codex-agent" : "operator", + expectedOutput: "Deterministic backend build journal entry.", + promptContextPackId: body.contextPackId, + selectedContextPackIds: body.selectedContextPackIds, + contextSelectionSummary: body.contextSelectionSummary, + promptId: body.promptId, + filesChanged: body.filesChanged ?? [], + validationResult, + regressionsIntroduced: [], + lessonsLearned: body.lessonsLearned ?? [], + linkedFeatureIds: body.linkedFeatureIds ?? [], + source: "real-backend-r6-fixture", + summary: `${body.goal.trim()} (${validationResult})`, + status: validationResult === "fail" ? "failed" : "success", + createdAt, + trust: { + source: "deterministic build create", + confidence: validationResult === "pass" ? 88 : 70, + evidence: [], + lastChecked: createdAt, + approvalState: "pending", + }, + }; + + buildFixtures.push(build); + return build; +}; + +const toValidationResult = ( + value: string | undefined, +): BuildValidationResult => { + if (value === "fail" || value === "warning" || value === "pass") return value; + return "pass"; +}; diff --git a/backend/src/fixtures/captureFixtures.ts b/backend/src/fixtures/captureFixtures.ts new file mode 100644 index 0000000..422b8b1 --- /dev/null +++ b/backend/src/fixtures/captureFixtures.ts @@ -0,0 +1,184 @@ +import type { + CaptureClassificationDto, + CaptureItemDto, + CaptureItemListFilters, + CreateCaptureItemRequestDto, +} from "../types/api.js"; + +let captureCounter = 3; + +export const captureFixtures: CaptureItemDto[] = [ + { + id: "cap-wide-01", + projectId: "widefrog", + type: "codex_output", + title: "Codex scope note: AI operating system", + content: + "ProjectBrain should capture notes, route AI output through review, and export structured Codex tasks.", + source: "codex", + status: "in_review", + classificationType: "documentation_update", + confidence: 88, + evidenceStrength: "medium", + suggestedAction: "Send to review as documentation update.", + createdAt: "2026-06-05T21:10:00Z", + updatedAt: "2026-06-05T21:20:00Z", + linkedReviewItemId: "rq-wide-01", + }, + { + id: "cap-wide-02", + projectId: "widefrog", + type: "note", + title: "Unraid deployment constraint", + content: + "The product must remain self-hosted and not require cloud services or paid API calls.", + source: "manual", + status: "classified", + classificationType: "memory_rule", + confidence: 92, + evidenceStrength: "strong", + suggestedAction: "Review as do-not-break memory rule.", + createdAt: "2026-06-05T21:12:00Z", + updatedAt: "2026-06-05T21:18:00Z", + }, +]; + +export const listCaptureItemsForProject = (projectId: string): CaptureItemDto[] => + captureFixtures.filter((item) => item.projectId === projectId); + +export const findCaptureItem = ( + projectId: string, + captureItemId: string, +): CaptureItemDto | undefined => + captureFixtures.find( + (item) => item.projectId === projectId && item.id === captureItemId, + ); + +export const applyCaptureFilters = ( + items: CaptureItemDto[], + filters: CaptureItemListFilters, +): CaptureItemDto[] => + items.filter((item) => { + if (filters.status?.length && !filters.status.includes(item.status)) { + return false; + } + if (filters.type?.length && !filters.type.includes(item.type)) return false; + if (filters.source?.length && !filters.source.includes(item.source)) { + return false; + } + if (filters.confidence !== undefined && item.confidence !== filters.confidence) { + return false; + } + if ( + filters.classificationType?.length && + (!item.classificationType || + !filters.classificationType.includes(item.classificationType)) + ) { + return false; + } + + return true; + }); + +export const createCaptureItem = ( + projectId: string, + body: CreateCaptureItemRequestDto, +): CaptureItemDto => { + const createdAt = new Date().toISOString(); + const title = body.content.trim().slice(0, 64); + const item: CaptureItemDto = { + id: `cap-${projectId}-${captureCounter++}`, + projectId, + type: body.type, + title: title || "Captured item", + content: body.content.trim(), + source: body.source?.trim() || "manual", + status: "captured", + createdAt, + updatedAt: createdAt, + }; + + captureFixtures.push(item); + return item; +}; + +export const classifyCaptureItem = ( + projectId: string, + captureItemId: string, +): CaptureClassificationDto | undefined | "CAPTURE_ALREADY_IN_REVIEW" => { + const item = findCaptureItem(projectId, captureItemId); + if (!item) return undefined; + if (item.status === "in_review") return "CAPTURE_ALREADY_IN_REVIEW"; + + const classification = classifyContent(item); + item.status = "classified"; + item.classificationType = classification.classificationType; + item.confidence = classification.confidence; + item.evidenceStrength = classification.evidenceStrength; + item.suggestedAction = classification.suggestedAction; + item.updatedAt = new Date().toISOString(); + + return { + captureItemId, + ...classification, + }; +}; + +export const markCaptureItemInReview = ( + projectId: string, + captureItemId: string, + reviewItemId: string, +): CaptureItemDto | undefined => { + const item = findCaptureItem(projectId, captureItemId); + if (!item) return undefined; + + item.status = "in_review"; + item.linkedReviewItemId = reviewItemId; + item.updatedAt = new Date().toISOString(); + return item; +}; + +const classifyContent = ( + item: CaptureItemDto, +): Omit => { + const text = `${item.type} ${item.content}`.toLowerCase(); + if (text.includes("bug") || text.includes("error")) { + return { + classificationType: "bug", + confidence: 86, + evidenceStrength: "medium", + suggestedAction: "Send to review as bug candidate.", + }; + } + if (text.includes("decision")) { + return { + classificationType: "decision", + confidence: 82, + evidenceStrength: "medium", + suggestedAction: "Send to review as decision candidate.", + }; + } + if (text.includes("rule") || text.includes("must") || text.includes("do not")) { + return { + classificationType: "memory_rule", + confidence: 90, + evidenceStrength: "strong", + suggestedAction: "Send to review as memory rule.", + }; + } + if (text.includes("docs") || text.includes("documentation")) { + return { + classificationType: "documentation_update", + confidence: 78, + evidenceStrength: "weak", + suggestedAction: "Send to review as documentation update.", + }; + } + + return { + classificationType: "knowledge", + confidence: 72, + evidenceStrength: "weak", + suggestedAction: "Send to review as knowledge item.", + }; +}; diff --git a/backend/src/fixtures/checkFixtures.ts b/backend/src/fixtures/checkFixtures.ts new file mode 100644 index 0000000..105283e --- /dev/null +++ b/backend/src/fixtures/checkFixtures.ts @@ -0,0 +1,321 @@ +import type { + ApprovalState, + CheckCategory, + CheckItemDto, + CheckListFilters, + CheckPipelineStage, + CheckSeverity, + CheckStatus, + ConvertTarget, + ReviewAction, + ReviewBoardDto, + ReviewMutationResponseDto, + TrustMetadataDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; + +const trust = ( + confidence: number, + approvalState: ApprovalState = "pending", +): TrustMetadataDto => ({ + source: "deterministic check fixture", + confidence, + evidence: [ + { + id: "e-arch-01", + type: "analysis", + refId: "arch-widefrog-v1", + label: "architecture model baseline", + }, + ], + lastChecked: trustNow, + approvalState, +}); + +let conversionCounter = 1; + +export const checkFixtures: CheckItemDto[] = [ + { + id: "ck-wide-01", + projectId: "widefrog", + title: "Authentication evidence check", + severity: "warning", + status: "open", + pipelineStage: "risk_analysis", + category: "missing evidence", + architectureModelId: "arch-widefrog-v1", + linkedFeatureIds: ["f-auth-mfa", "f-auth-social"], + linkedEvidenceIds: ["ev-wide-01", "ev-wide-02"], + linkedRelationshipIds: ["rel-01"], + linkedContextPackIds: ["cp-widefrog-core"], + linkedEntities: [ + { + type: "check", + id: "ck-wide-01", + label: "Authentication evidence check", + link: "/projects/widefrog/checks?section=review", + }, + { + type: "feature", + id: "f-auth-mfa", + label: "Multi-factor Authentication", + link: "/projects/widefrog/features/f-auth-mfa", + }, + { + type: "feature", + id: "f-auth-social", + label: "Social Login", + link: "/projects/widefrog/features/f-auth-social", + }, + ], + detail: + "Two checks are incomplete due missing queue telemetry in streaming states.", + trust: trust(94), + }, + { + id: "ck-wide-02", + projectId: "widefrog", + title: "MFA implementation confirmed", + severity: "info", + status: "resolved", + pipelineStage: "feature_detection", + category: "confirmed feature", + linkedFeatureIds: ["f-auth-mfa"], + linkedEvidenceIds: ["ev-wide-01"], + linkedContextPackIds: ["cp-widefrog-core"], + detail: "MFA exists in the feature map and has linked implementation evidence.", + trust: trust(91, "approved"), + }, + { + id: "ck-wide-03", + projectId: "widefrog", + title: "Streaming queue regression candidate", + severity: "high", + status: "open", + pipelineStage: "risk_analysis", + category: "possible regression", + linkedFeatureIds: ["f-auth-mfa"], + linkedEvidenceIds: ["ev-wide-02"], + linkedContextPackIds: ["cp-widefrog-core"], + detail: + "Queue telemetry may regress under low-bandwidth Safari conditions.", + trust: trust(87), + }, + { + id: "ck-wide-04", + projectId: "widefrog", + title: "Relationship graph duplicate risk", + severity: "warning", + status: "in_progress", + pipelineStage: "recommendation", + category: "architecture conflict", + linkedFeatureIds: ["f-auth-mfa", "f-auth-social"], + linkedRelationshipIds: ["rel-01"], + linkedContextPackIds: ["cp-widefrog-core"], + detail: "Auth fallback and MFA relationship semantics need review.", + trust: trust(79), + }, + { + id: "ck-wide-05", + projectId: "widefrog", + title: "Recovery codes candidate", + severity: "info", + status: "open", + pipelineStage: "feature_detection", + category: "new feature candidate", + linkedFeatureIds: ["f-auth-mfa"], + linkedContextPackIds: ["cp-widefrog-core"], + detail: "Backup recovery codes are suggested for privileged action recovery.", + trust: trust(76), + }, + { + id: "ck-wide-06", + projectId: "widefrog", + title: "Unclear Codex import review mapping", + severity: "warning", + status: "open", + pipelineStage: "module_inference", + category: "uncertain finding", + linkedContextPackIds: ["cp-widefrog-core"], + detail: "Codex output import review state needs a stronger mapping.", + trust: trust(68), + }, + { + id: "ck-wide-07", + projectId: "widefrog", + title: "Missing prompt verification", + severity: "critical", + status: "open", + pipelineStage: "recommendation", + category: "missing verification", + linkedContextPackIds: ["cp-widefrog-core"], + detail: "Prompt generation needs explicit validation before backend switch.", + trust: trust(83), + }, + { + id: "ck-nebula-01", + projectId: "nebula", + title: "Onboarding policy evidence placeholder", + severity: "warning", + status: "open", + pipelineStage: "risk_analysis", + category: "missing evidence", + linkedFeatureIds: ["f-onboard-flow"], + linkedContextPackIds: ["cp-nebula-core"], + detail: "Onboarding policy needs verified evidence before implementation.", + trust: trust(72), + }, +]; + +export const listChecksForProject = (projectId: string): CheckItemDto[] => + checkFixtures.filter((check) => check.projectId === projectId); + +export const findCheck = ( + projectId: string, + checkId: string, +): CheckItemDto | undefined => + checkFixtures.find((check) => check.projectId === projectId && check.id === checkId); + +export const applyCheckFilters = ( + checks: CheckItemDto[], + filters: CheckListFilters, +): CheckItemDto[] => + checks.filter((check) => { + if (filters.status?.length && !filters.status.includes(check.status)) { + return false; + } + if (filters.severity?.length && !filters.severity.includes(check.severity)) { + return false; + } + if (filters.source?.length && !filters.source.includes(check.pipelineStage)) { + return false; + } + if (filters.category?.length && !filters.category.includes(check.category)) { + return false; + } + if ( + filters.confidence !== undefined && + check.trust.confidence !== filters.confidence + ) { + return false; + } + if ( + filters.approvalState !== undefined && + check.trust.approvalState !== filters.approvalState + ) { + return false; + } + + return true; + }); + +export const toReviewBoard = (checks: CheckItemDto[]): ReviewBoardDto => ({ + confirmedFeatures: checks.filter( + (check) => check.category === "confirmed feature", + ), + newFeatureCandidates: checks.filter( + (check) => check.category === "new feature candidate", + ), + possibleRegressions: checks.filter( + (check) => check.category === "possible regression", + ), + uncertainFindings: checks.filter( + (check) => check.category === "uncertain finding", + ), + missingEvidence: checks.filter((check) => check.category === "missing evidence"), + missingVerification: checks.filter( + (check) => check.category === "missing verification", + ), + architectureConflicts: checks.filter( + (check) => check.category === "architecture conflict", + ), +}); + +export const applyReviewAction = ( + projectId: string, + sourceId: string, + action: ReviewAction, + reason?: string, + targetEntityId?: string, +): ReviewMutationResponseDto | undefined => { + const check = findCheck(projectId, sourceId); + if (!check) return undefined; + + check.status = "resolved"; + check.reviewAction = action; + check.reviewReason = reason; + check.reviewedAt = new Date().toISOString(); + check.trust = { + ...check.trust, + approvalState: action === "reject" || action === "ignore" ? "rejected" : "approved", + lastChecked: check.reviewedAt, + }; + + if (action === "merge") { + check.mergedIntoEntityId = targetEntityId; + } + + return { + success: true, + message: `Check ${sourceId} review action ${action} applied.`, + updatedEntityId: sourceId, + affectedEntities: targetEntityId ? [sourceId, targetEntityId] : [sourceId], + }; +}; + +export const convertCheck = ( + projectId: string, + sourceId: string, + target: ConvertTarget, + title?: string, + reason?: string, +): ReviewMutationResponseDto | undefined => { + const check = findCheck(projectId, sourceId); + if (!check) return undefined; + + const createdEntityId = `mock-${target.replace("_", "-")}-${conversionCounter++}`; + const convertedAt = new Date().toISOString(); + check.status = "resolved"; + check.convertedTarget = target; + check.convertedEntityId = createdEntityId; + check.reviewReason = reason; + check.reviewedAt = convertedAt; + check.trust = { + ...check.trust, + approvalState: "approved", + lastChecked: convertedAt, + }; + + const titleSuffix = title?.trim() ? ` with title '${title.trim()}'` : ""; + + return { + success: true, + message: `Converted check ${sourceId} to ${target} ${createdEntityId}${titleSuffix}.`, + updatedEntityId: sourceId, + createdEntityId, + affectedEntities: [sourceId, createdEntityId], + }; +}; + +export const checkFilterValues = { + status: ["open", "in_progress", "resolved"] satisfies CheckStatus[], + severity: ["info", "warning", "high", "critical"] satisfies CheckSeverity[], + source: [ + "ingest", + "architecture_discovery", + "module_inference", + "feature_detection", + "risk_analysis", + "recommendation", + ] satisfies CheckPipelineStage[], + category: [ + "confirmed feature", + "new feature candidate", + "possible regression", + "uncertain finding", + "missing evidence", + "missing verification", + "architecture conflict", + ] satisfies CheckCategory[], +}; diff --git a/backend/src/fixtures/contextPackFixtures.ts b/backend/src/fixtures/contextPackFixtures.ts new file mode 100644 index 0000000..30f1ad5 --- /dev/null +++ b/backend/src/fixtures/contextPackFixtures.ts @@ -0,0 +1,383 @@ +import { + evidenceFixtures, + featureFixtures, + paginate, + type PaginationInput, +} from "./featureFixtures.js"; +import { findProject } from "./projectFixtures.js"; +import type { + ContextPackDto, + ContextPackGeneratedBy, + ContextPackListFilters, + ContextPackSourceType, + ContextPackSummaryDto, + CreateContextPackRequestDto, + GeneratePromptRequestDto, + GeneratedPromptDto, + TrustMetadataDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; + +const coreTrust: TrustMetadataDto = { + source: "Mock import analysis v1", + confidence: 94, + evidence: [ + { + id: "e-arch-01", + type: "analysis", + refId: "arch-widefrog-v1", + label: "architecture model baseline", + }, + ], + lastChecked: trustNow, + approvalState: "approved", +}; + +let contextPackCreateCounter = 1; +let promptGenerateCounter = 1; + +export const contextPackFixtures: ContextPackDto[] = [ + { + id: "cp-widefrog-core", + projectId: "widefrog", + title: "Core Identity and Streams", + category: "architecture", + dna: findProject("widefrog")?.dna ?? { + vision: "", + goals: [], + nonGoals: [], + targetAudience: "", + principles: [], + successMetrics: [], + maturityStage: "Discovery", + revision: 0, + updatedAt: trustNow, + }, + summary: + "Authentication and stream modules have the highest evidence value and must stay explicit before adding scope.", + assumptions: [ + "Identity service is synchronous and can be used across all modules", + "Streaming stack needs explicit buffering policy", + ], + constraints: [ + "No auth regression tolerance above low confidence", + "Every new feature must include evidence entry", + ], + evidence: [ + { + id: "er-01", + type: "artifact", + refId: "f-auth-mfa", + label: "MFA implementation and evidence", + }, + ], + featureIds: ["f-auth-mfa", "f-auth-social"], + bugIds: ["B-MFA-01"], + taskIds: ["T-MFA-01"], + decisionIds: ["D-MFA-01"], + memoryRuleIds: ["R-UX-01", "R-SEC-02"], + architectureNotes: [ + "Frontend has domain grouped architecture with shared auth services.", + ], + risks: [ + "Streaming regression from queue underflow.", + "Context mismatch across auth fallback screens.", + ], + regressions: ["ck-wide-01"], + quality: { + dnaIncluded: true, + constitutionIncluded: true, + constraintsIncluded: true, + featuresIncluded: true, + evidenceIncluded: true, + risksIncluded: true, + memoryIncluded: true, + doNotBreakIncluded: true, + verificationIncluded: false, + score: 88, + }, + currentFocus: { + id: "fa-auth", + title: "Identity and Access", + purpose: "quality", + }, + signals: ["open-bug", "stale-evidence", "architecture-risk"], + trust: coreTrust, + generatedBy: "import", + createdAt: "2026-06-03T09:00:00Z", + updatedAt: trustNow, + }, + { + id: "cp-nebula-core", + projectId: "nebula", + title: "Onboarding Policy Baseline", + category: "quality", + dna: findProject("nebula")?.dna ?? { + vision: "", + goals: [], + nonGoals: [], + targetAudience: "", + principles: [], + successMetrics: [], + maturityStage: "Discovery", + revision: 0, + updatedAt: trustNow, + }, + summary: "Focus on policy traceability and stable onboarding defaults.", + assumptions: ["Policy updates are infrequent during execution"], + constraints: ["Onboarding policy changes must be auditable"], + evidence: [ + { + id: "er-02", + type: "artifact", + refId: "f-onboard-flow", + label: "Feature evidence placeholder", + }, + ], + featureIds: ["f-onboard-flow"], + memoryRuleIds: ["R-NB-01"], + architectureNotes: ["Onboarding is policy-centric and stateful."], + risks: ["Policy drift risk when role templates are updated externally."], + quality: { + dnaIncluded: true, + constitutionIncluded: false, + constraintsIncluded: true, + featuresIncluded: true, + evidenceIncluded: false, + risksIncluded: true, + memoryIncluded: true, + doNotBreakIncluded: false, + verificationIncluded: false, + score: 61, + }, + currentFocus: { + id: "fa-onboard", + title: "Onboarding Policy and Delivery", + purpose: "architecture", + }, + signals: ["knowledge-gap", "confidence-drift"], + trust: { + ...coreTrust, + source: "User-defined context pack", + confidence: 78, + approvalState: "pending", + }, + generatedBy: "user", + createdAt: "2026-06-02T14:00:00Z", + updatedAt: "2026-06-03T16:00:00Z", + }, +]; + +export const listContextPacksForProject = ( + projectId: string, +): ContextPackDto[] => + contextPackFixtures.filter((contextPack) => contextPack.projectId === projectId); + +export const findContextPack = ( + projectId: string, + contextPackId: string, +): ContextPackDto | undefined => + contextPackFixtures.find( + (contextPack) => + contextPack.projectId === projectId && contextPack.id === contextPackId, + ); + +export const toContextPackSummary = ( + contextPack: ContextPackDto, +): ContextPackSummaryDto => ({ + projectId: contextPack.projectId, + contextPackId: contextPack.id, + title: contextPack.title, + category: contextPack.category, + summary: contextPack.summary, + counts: { + assumptions: contextPack.assumptions.length, + constraints: contextPack.constraints.length, + evidence: contextPack.evidence.length, + features: contextPack.featureIds?.length ?? 0, + bugs: contextPack.bugIds?.length ?? 0, + tasks: contextPack.taskIds?.length ?? 0, + decisions: contextPack.decisionIds?.length ?? 0, + memoryRules: contextPack.memoryRuleIds?.length ?? 0, + risks: contextPack.risks?.length ?? 0, + }, + qualityScore: contextPack.quality?.score, + createdAt: contextPack.createdAt, + updatedAt: contextPack.updatedAt, +}); + +export const findContextPackSummary = ( + projectId: string, + contextPackId: string, +): ContextPackSummaryDto | undefined => { + const contextPack = findContextPack(projectId, contextPackId); + return contextPack ? toContextPackSummary(contextPack) : undefined; +}; + +export const listContextPacksPaginated = ( + projectId: string, + input: PaginationInput, +): ReturnType> => + paginate(listContextPacksForProject(projectId), input); + +export const createContextPack = ( + projectId: string, + request: CreateContextPackRequestDto, +): ContextPackDto | undefined => { + const project = findProject(projectId); + if (!project) return undefined; + + const createdAt = new Date().toISOString(); + const selectedFeatureIds = + request.sourceType === "feature" && request.sourceId + ? [request.sourceId] + : featureFixtures + .filter((feature) => feature.projectId === projectId) + .map((feature) => feature.id); + const selectedEvidence = evidenceFixtures.filter( + (evidence) => + evidence.projectId === projectId && + (selectedFeatureIds.length === 0 || + evidence.featureIds.some((featureId) => + selectedFeatureIds.includes(featureId), + )), + ); + const packId = `cp-${projectId}-${request.sourceType}-${contextPackCreateCounter++}`; + + const contextPack: ContextPackDto = { + id: packId, + projectId, + title: + request.title?.trim() || + `${project.name} ${request.sourceType.replace("_", " ")} context pack`, + category: request.sourceType === "feature" ? "feature" : "quality", + dna: project.dna, + summary: `Created from project ${projectId} via ${request.sourceType} context source. DNA is always included.`, + assumptions: + request.sourceType === "manual" + ? ["Manual context source requires human review."] + : ["Generated fixture context should be verified before use."], + constraints: project.constraints.map((constraint) => constraint.title), + evidence: + request.includeEvidence === false + ? [] + : selectedEvidence.map((evidence) => ({ + id: `er-${evidence.id}`, + type: "analysis", + refId: evidence.id, + label: evidence.linkedEntity.label, + })), + signals: ["fixture-generated", request.sourceType], + ...(request.includeFeatures === false ? {} : { featureIds: selectedFeatureIds }), + ...(request.includeMemoryRules === false + ? {} + : { memoryRuleIds: project.memoryRules.map((rule) => rule.id) }), + ...(request.includeArchitecture === false + ? {} + : { + architectureNotes: [ + `Architecture notes are fixture-generated for ${project.name}.`, + ], + }), + ...(request.includeRisks === false + ? {} + : { risks: ["Fixture risk: generated context still needs review."] }), + ...(request.includeRegressions === false ? {} : { regressions: [] }), + quality: { + dnaIncluded: true, + constitutionIncluded: Boolean(project.constitution), + constraintsIncluded: true, + featuresIncluded: request.includeFeatures !== false, + evidenceIncluded: request.includeEvidence !== false, + risksIncluded: request.includeRisks !== false, + memoryIncluded: request.includeMemoryRules !== false, + doNotBreakIncluded: true, + verificationIncluded: false, + score: request.includeEvidence === false ? 58 : 74, + }, + trust: { + source: "deterministic fixture context-pack create", + confidence: request.includeEvidence === false ? 64 : 82, + evidence: [], + lastChecked: createdAt, + approvalState: "pending", + }, + generatedBy: toGeneratedBy(request.sourceType), + createdAt, + updatedAt: createdAt, + }; + + contextPackFixtures.push(contextPack); + return contextPack; +}; + +export const generatePrompt = ( + projectId: string, + request: GeneratePromptRequestDto, +): GeneratedPromptDto | undefined => { + const contextPack = findContextPack(projectId, request.contextPackId); + if (!contextPack) return undefined; + + const createdAt = new Date().toISOString(); + const id = `prompt-${projectId}-${promptGenerateCounter++}`; + const expectedOutput = + request.expectedOutput?.trim() || "Clear implementation notes and validation steps."; + const generatedText = [ + `Generated from context pack ${contextPack.id} (${contextPack.title}).`, + `Target tool: ${request.targetTool}.`, + `Persona: ${request.persona}.`, + `Objective: ${request.objective.trim()}.`, + `Expected output: ${expectedOutput}`, + `Context summary: ${contextPack.summary}`, + `Project vision: ${contextPack.dna.vision}`, + `Relevant features: ${(contextPack.featureIds ?? []).join(", ") || "none"}.`, + `Risks: ${(contextPack.risks ?? []).join(" ") || "none listed"}.`, + "Use only the supplied context pack facts and mark uncertainty explicitly.", + ].join("\n"); + + return { + id, + projectId, + contextPackId: contextPack.id, + targetTool: request.targetTool, + persona: request.persona, + objective: request.objective.trim(), + expectedOutput, + generatedText, + generatedFrom: "context-pack", + createdAt, + }; +}; + +export const applyContextPackFilters = ( + contextPacks: ContextPackDto[], + filters: ContextPackListFilters, +): ContextPackDto[] => { + const generatedBy = filters.source ?? filters.generatedBy; + + return contextPacks.filter((contextPack) => { + if (filters.category?.length && !filters.category.includes(contextPack.category)) { + return false; + } + if (generatedBy?.length && !generatedBy.includes(contextPack.generatedBy)) { + return false; + } + if ( + filters.confidence !== undefined && + contextPack.trust.confidence !== filters.confidence + ) { + return false; + } + + return true; + }); +}; + +const toGeneratedBy = ( + sourceType: ContextPackSourceType, +): ContextPackGeneratedBy => { + if (sourceType === "check") return "check-run"; + if (sourceType === "manual") return "manual"; + return "user"; +}; diff --git a/backend/src/fixtures/featureFixtures.ts b/backend/src/fixtures/featureFixtures.ts new file mode 100644 index 0000000..c3a10e5 --- /dev/null +++ b/backend/src/fixtures/featureFixtures.ts @@ -0,0 +1,278 @@ +import type { + EvidenceDto, + FeatureDto, + FeatureGroupDto, + PaginatedListResponse, +} from "../types/api.js"; + +export type PaginationInput = { + page: number; + pageSize: number; + cursor?: string; + sort?: string; + filters?: Record; +}; + +export const featureGroupFixtures: FeatureGroupDto[] = [ + { + id: "auth", + projectId: "widefrog", + name: "Authentication", + description: "Authentication and identity layer", + focusArea: "architecture", + featureIds: ["f-auth-social", "f-auth-mfa"], + }, + { + id: "onboarding", + projectId: "nebula", + name: "Onboarding", + description: "Identity and team onboarding modules", + focusArea: "delivery", + featureIds: ["f-onboard-flow"], + }, +]; + +export const featureFixtures: FeatureDto[] = [ + { + id: "f-auth-social", + projectId: "widefrog", + groupId: "auth", + name: "Social Login", + description: + "OAuth2 login via Google and GitHub with account linking and fallback recovery.", + status: "Done", + confidence: 97, + health: 95, + bugCount: 0, + taskCount: 0, + decisionCount: 0, + codeEvidence: [ + { + file: "src/services/auth.service.ts", + lines: "120-245", + type: "Logic", + link: "file://src/services/auth.service.ts", + }, + { + file: "src/components/LoginForm.tsx", + lines: "45-82", + type: "UI", + link: "file://src/components/LoginForm.tsx", + }, + ], + aiSuggestionCount: 0, + evidenceIds: ["ev-wide-03"], + }, + { + id: "f-auth-mfa", + projectId: "widefrog", + groupId: "auth", + name: "Multi-factor Authentication", + description: + "SMS and TOTP based second factor, enforced for privileged actions.", + status: "In Progress", + lifecycle: "active", + intent: "Protect privileged actions without increasing account recovery risk.", + initiativeId: "init-wide-reliability", + evidenceStrength: "medium", + confidence: 88, + health: 80, + bugCount: 1, + taskCount: 1, + decisionCount: 1, + codeEvidence: [ + { + file: "src/hooks/useMFA.ts", + lines: "10-78", + type: "Hook", + link: "file://src/hooks/useMFA.ts", + }, + ], + aiSuggestionCount: 1, + evidenceIds: ["ev-wide-01", "ev-wide-02", "ev-wide-03", "ev-wide-04"], + }, + { + id: "f-onboard-flow", + projectId: "nebula", + groupId: "onboarding", + name: "Team Onboarding Flow", + description: "Wizard with role-based defaults and policy checks.", + status: "Todo", + confidence: 72, + health: 70, + bugCount: 0, + taskCount: 0, + decisionCount: 0, + codeEvidence: [], + aiSuggestionCount: 0, + evidenceIds: ["ev-nebula-01"], + }, +]; + +export const evidenceFixtures: EvidenceDto[] = [ + { + id: "ev-wide-01", + projectId: "widefrog", + type: "analysis", + linkedEntity: { + type: "feature", + id: "f-auth-mfa", + label: "Multi-factor Authentication", + }, + featureIds: ["f-auth-mfa"], + source: "architecture-first import pipeline", + confidence: 94, + reason: + "Feature flagged for telemetry verification due missing queue coverage.", + lastSeen: "2026-06-03T09:20:00Z", + approvalState: "approved", + route: "/projects/widefrog/features/f-auth-mfa", + }, + { + id: "ev-wide-02", + projectId: "widefrog", + type: "check", + linkedEntity: { + type: "check", + id: "ck-wide-01", + label: "Authentication evidence check", + }, + featureIds: ["f-auth-mfa"], + source: "mock check engine", + confidence: 88, + reason: "Evidence missing for queue telemetry in streaming contexts.", + lastSeen: "2026-06-03T09:40:00Z", + approvalState: "pending", + route: "/projects/widefrog/checks", + }, + { + id: "ev-wide-03", + projectId: "widefrog", + type: "relationship", + linkedEntity: { + type: "feature", + id: "f-auth-social", + label: "Social Login", + }, + featureIds: ["f-auth-social", "f-auth-mfa"], + source: "review-board", + confidence: 86, + reason: + "Dependency edge indicates fallback flow should stay aligned with MFA.", + lastSeen: "2026-06-03T10:00:00Z", + approvalState: "approved", + route: "/projects/widefrog/relationships", + }, + { + id: "ev-wide-04", + projectId: "widefrog", + type: "memory-rule", + linkedEntity: { + type: "memory-rule", + id: "R-SEC-02", + label: "Global API Error Handling", + }, + featureIds: ["f-auth-mfa"], + source: "manual review", + confidence: 97, + reason: + "Memory rule linked to all API-related checks and evidence generation.", + lastSeen: "2026-06-03T12:20:00Z", + approvalState: "approved", + route: "/projects/widefrog/memory", + }, + { + id: "ev-wide-05", + projectId: "widefrog", + type: "architecture", + linkedEntity: { + type: "architecture", + id: "arch-widefrog-v1", + label: "Frontend architecture map", + }, + featureIds: [], + source: "import scan", + confidence: 91, + reason: "Stack and layers inferred before feature checks.", + lastSeen: "2026-06-03T09:00:00Z", + approvalState: "approved", + route: "/projects/widefrog/checks?section=pipeline", + }, + { + id: "ev-nebula-01", + projectId: "nebula", + type: "artifact", + linkedEntity: { + type: "feature", + id: "f-onboard-flow", + label: "Team Onboarding Flow", + }, + featureIds: ["f-onboard-flow"], + source: "feature evidence placeholder", + confidence: 72, + reason: "Fixture evidence for onboarding policy baseline.", + lastSeen: "2026-06-03T16:00:00Z", + approvalState: "pending", + route: "/projects/nebula/features/f-onboard-flow", + }, +]; + +export const listFeatureGroupsForProject = ( + projectId: string, +): FeatureGroupDto[] => + featureGroupFixtures.filter((group) => group.projectId === projectId); + +export const listFeaturesForProject = (projectId: string): FeatureDto[] => + featureFixtures.filter((feature) => feature.projectId === projectId); + +export const findFeature = ( + projectId: string, + featureId: string, +): FeatureDto | undefined => + featureFixtures.find( + (feature) => feature.projectId === projectId && feature.id === featureId, + ); + +export const listEvidenceForProject = (projectId: string): EvidenceDto[] => + evidenceFixtures.filter((evidence) => evidence.projectId === projectId); + +export const findEvidence = ( + projectId: string, + evidenceId: string, +): EvidenceDto | undefined => + evidenceFixtures.find( + (evidence) => evidence.projectId === projectId && evidence.id === evidenceId, + ); + +export const listEvidenceForFeature = ( + projectId: string, + featureId: string, +): EvidenceDto[] => + evidenceFixtures.filter( + (evidence) => + evidence.projectId === projectId && evidence.featureIds.includes(featureId), + ); + +export const paginate = ( + items: T[], + input: PaginationInput, +): PaginatedListResponse => { + const cursorOffset = input.cursor ? Number.parseInt(input.cursor, 10) : NaN; + const start = Number.isFinite(cursorOffset) + ? cursorOffset + : (input.page - 1) * input.pageSize; + const pageItems = items.slice(start, start + input.pageSize); + const nextOffset = start + input.pageSize; + + return { + items: pageItems, + total: items.length, + page: input.page, + pageSize: input.pageSize, + hasNextPage: nextOffset < items.length, + hasPreviousPage: input.page > 1, + ...(nextOffset < items.length ? { nextCursor: String(nextOffset) } : {}), + ...(input.sort ? { sort: input.sort } : {}), + ...(input.filters ? { filters: input.filters } : {}), + }; +}; diff --git a/backend/src/fixtures/insightFixtures.ts b/backend/src/fixtures/insightFixtures.ts new file mode 100644 index 0000000..c1b6a52 --- /dev/null +++ b/backend/src/fixtures/insightFixtures.ts @@ -0,0 +1,114 @@ +import type { + InsightListFilters, + InsightReviewAction, + ProjectInsightDto, + TrustMetadataDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; + +const trust = (source: string, confidence: number): TrustMetadataDto => ({ + source, + confidence, + evidence: [], + lastChecked: trustNow, + approvalState: "pending", +}); + +export const insightFixtures: ProjectInsightDto[] = [ + { + id: "ins-wide-01", + projectId: "widefrog", + title: "Auth evidence confidence is strong but stale", + category: "evidence", + severity: "warning", + status: "open", + summary: + "Authentication features have good evidence coverage, but the last confidence check predates the newest build.", + recommendation: + "Refresh feature evidence before using the auth context pack for implementation work.", + reviewed: false, + confidence: 86, + linkedFeatureIds: ["f-auth-mfa", "f-auth-social"], + linkedEvidenceIds: ["ev-auth-mfa-01"], + trust: trust("deterministic insight fixture", 86), + createdAt: "2026-06-04T09:15:00Z", + }, + { + id: "ins-wide-02", + projectId: "widefrog", + title: "Memory rules should be included in prompt context", + category: "memory", + severity: "high", + status: "open", + summary: + "Do-not-break style memory rules are available but must be explicitly carried into generated context packs.", + recommendation: + "Include R-UX-01 and R-SEC-02 before generating developer prompts.", + reviewed: false, + confidence: 91, + linkedFeatureIds: ["f-auth-mfa"], + linkedEvidenceIds: [], + trust: trust("deterministic insight fixture", 91), + createdAt: "2026-06-04T10:20:00Z", + }, +]; + +export const listInsightsForProject = (projectId: string): ProjectInsightDto[] => + insightFixtures.filter((insight) => insight.projectId === projectId); + +export const findInsight = ( + projectId: string, + insightId: string, +): ProjectInsightDto | undefined => + insightFixtures.find( + (insight) => insight.projectId === projectId && insight.id === insightId, + ); + +export const applyInsightFilters = ( + insights: ProjectInsightDto[], + filters: InsightListFilters, +): ProjectInsightDto[] => + insights.filter((insight) => { + if (filters.category?.length && !filters.category.includes(insight.category)) { + return false; + } + if (filters.severity?.length && !filters.severity.includes(insight.severity)) { + return false; + } + if (filters.status?.length && !filters.status.includes(insight.status)) { + return false; + } + if (filters.confidence !== undefined && insight.confidence !== filters.confidence) { + return false; + } + if (filters.reviewed !== undefined && insight.reviewed !== filters.reviewed) { + return false; + } + + return true; + }); + +export const reviewInsight = ( + projectId: string, + insightId: string, + action: InsightReviewAction, + reason?: string, +): ProjectInsightDto | undefined => { + const insight = findInsight(projectId, insightId); + if (!insight) return undefined; + + const reviewedAt = new Date().toISOString(); + insight.status = + action === "accept" ? "accepted" : action === "reject" ? "rejected" : "ignored"; + insight.reviewed = true; + insight.reviewedAt = reviewedAt; + insight.reviewReason = reason; + insight.trust = { + ...insight.trust, + approvalState: action === "accept" ? "approved" : "rejected", + lastChecked: reviewedAt, + }; + + return insight; +}; diff --git a/backend/src/fixtures/knowledgeFixtures.ts b/backend/src/fixtures/knowledgeFixtures.ts new file mode 100644 index 0000000..2bad7d0 --- /dev/null +++ b/backend/src/fixtures/knowledgeFixtures.ts @@ -0,0 +1,157 @@ +import type { + KnowledgeItemDto, + KnowledgeListFilters, + KnowledgeMutationRequestDto, + TrustMetadataDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; +let knowledgeCounter = 3; + +const trust = (source: string, confidence: number): TrustMetadataDto => ({ + source, + confidence, + evidence: [], + lastChecked: trustNow, + approvalState: "pending", +}); + +export const knowledgeFixtures: KnowledgeItemDto[] = [ + { + id: "kn-wide-01", + projectId: "widefrog", + title: "Auth fallback evidence policy", + category: "Technical", + content: + "Authentication fallback changes need evidence that both success and failure API states are handled.", + linkedFeatureIds: ["f-auth-mfa"], + source: "fixture-analysis", + confidence: 90, + includeInContextPacks: ["cp-widefrog-core"], + trust: trust("deterministic knowledge fixture", 90), + createdAt: "2026-06-03T15:00:00Z", + updatedAt: "2026-06-03T15:00:00Z", + }, + { + id: "kn-wide-02", + projectId: "widefrog", + title: "Context pack prompt boundary", + category: "Workflow", + content: + "Generated prompts must state when context comes from a context pack and must preserve uncertainty.", + linkedFeatureIds: [], + source: "manual", + confidence: 84, + includeInContextPacks: [], + trust: trust("deterministic knowledge fixture", 84), + createdAt: "2026-06-03T16:00:00Z", + updatedAt: "2026-06-03T16:00:00Z", + }, +]; + +export const listKnowledgeForProject = (projectId: string): KnowledgeItemDto[] => + knowledgeFixtures.filter((knowledge) => knowledge.projectId === projectId); + +export const findKnowledge = ( + projectId: string, + knowledgeId: string, +): KnowledgeItemDto | undefined => + knowledgeFixtures.find( + (knowledge) => knowledge.projectId === projectId && knowledge.id === knowledgeId, + ); + +export const applyKnowledgeFilters = ( + items: KnowledgeItemDto[], + filters: KnowledgeListFilters, +): KnowledgeItemDto[] => + items.filter((item) => { + if (filters.category?.length && !filters.category.includes(item.category)) { + return false; + } + if (filters.featureId && !item.linkedFeatureIds.includes(filters.featureId)) { + return false; + } + if (filters.source?.length && !filters.source.includes(item.source)) { + return false; + } + if (filters.confidence !== undefined && item.confidence !== filters.confidence) { + return false; + } + if (filters.search) { + const haystack = `${item.title} ${item.content} ${item.category}`.toLowerCase(); + if (!haystack.includes(filters.search.toLowerCase())) return false; + } + + return true; + }); + +export const createKnowledge = ( + projectId: string, + body: KnowledgeMutationRequestDto & { title: string; content: string }, +): KnowledgeItemDto => { + const createdAt = new Date().toISOString(); + const confidence = body.confidence ?? 75; + const item: KnowledgeItemDto = { + id: `kn-${projectId}-${knowledgeCounter++}`, + projectId, + title: body.title.trim(), + category: body.category?.trim() || "Technical", + content: body.content.trim(), + linkedFeatureIds: body.linkedFeatureIds ?? [], + source: body.source?.trim() || "manual", + confidence, + includeInContextPacks: [], + trust: { + source: "deterministic knowledge create", + confidence, + evidence: [], + lastChecked: createdAt, + approvalState: "pending", + }, + createdAt, + updatedAt: createdAt, + }; + + knowledgeFixtures.push(item); + return item; +}; + +export const updateKnowledge = ( + projectId: string, + knowledgeId: string, + body: KnowledgeMutationRequestDto, +): KnowledgeItemDto | undefined => { + const item = findKnowledge(projectId, knowledgeId); + if (!item) return undefined; + + const updatedAt = new Date().toISOString(); + if (body.title !== undefined) item.title = body.title.trim(); + if (body.category !== undefined) item.category = body.category.trim(); + if (body.content !== undefined) item.content = body.content.trim(); + if (body.linkedFeatureIds !== undefined) { + item.linkedFeatureIds = body.linkedFeatureIds; + } + if (body.source !== undefined) item.source = body.source.trim(); + if (body.confidence !== undefined) { + item.confidence = body.confidence; + item.trust.confidence = body.confidence; + } + item.updatedAt = updatedAt; + item.trust.lastChecked = updatedAt; + + return item; +}; + +export const includeKnowledgeInContextPack = ( + projectId: string, + knowledgeId: string, + contextPackId: string, +): KnowledgeItemDto | undefined => { + const item = findKnowledge(projectId, knowledgeId); + if (!item) return undefined; + if (!item.includeInContextPacks.includes(contextPackId)) { + item.includeInContextPacks.push(contextPackId); + } + item.updatedAt = new Date().toISOString(); + return item; +}; diff --git a/backend/src/fixtures/memoryRuleFixtures.ts b/backend/src/fixtures/memoryRuleFixtures.ts new file mode 100644 index 0000000..e947e48 --- /dev/null +++ b/backend/src/fixtures/memoryRuleFixtures.ts @@ -0,0 +1,161 @@ +import type { + MemoryRuleDto, + MemoryRuleListFilters, + MemoryRuleMutationRequestDto, + TrustMetadataDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; +let memoryRuleCounter = 3; + +const trust = (source: string, confidence: number): TrustMetadataDto => ({ + source, + confidence, + evidence: [], + lastChecked: trustNow, + approvalState: "pending", +}); + +export const memoryRuleFixtures: MemoryRuleDto[] = [ + { + id: "R-UX-01", + projectId: "widefrog", + title: "Primary Action Placement", + rule: "Primary action buttons must stay visually stable across workspace screens.", + scope: "global", + category: "UX", + severity: "important", + linkedFeatureIds: ["f-auth-mfa"], + includeInContextPacks: ["cp-widefrog-core"], + includeInContextPacksDefault: true, + trust: trust("deterministic memory-rule fixture", 93), + createdAt: "2026-06-02T08:00:00Z", + updatedAt: "2026-06-02T08:00:00Z", + }, + { + id: "R-SEC-02", + projectId: "widefrog", + title: "Global API Error Handling", + rule: "Do not remove explicit API error envelopes from backend read or mock mutation flows.", + scope: "global", + category: "Security", + severity: "do_not_break", + linkedFeatureIds: ["f-auth-mfa", "f-auth-social"], + includeInContextPacks: ["cp-widefrog-core"], + includeInContextPacksDefault: true, + trust: trust("deterministic memory-rule fixture", 96), + createdAt: "2026-06-02T08:30:00Z", + updatedAt: "2026-06-02T08:30:00Z", + }, +]; + +export const listMemoryRulesForProject = (projectId: string): MemoryRuleDto[] => + memoryRuleFixtures.filter((rule) => rule.projectId === projectId); + +export const findMemoryRule = ( + projectId: string, + ruleId: string, +): MemoryRuleDto | undefined => + memoryRuleFixtures.find((rule) => rule.projectId === projectId && rule.id === ruleId); + +export const applyMemoryRuleFilters = ( + rules: MemoryRuleDto[], + filters: MemoryRuleListFilters, +): MemoryRuleDto[] => + rules.filter((rule) => { + if (filters.scope?.length && !filters.scope.includes(rule.scope)) return false; + if (filters.category?.length && !filters.category.includes(rule.category)) { + return false; + } + if (filters.severity?.length && !filters.severity.includes(rule.severity)) { + return false; + } + if ( + filters.includeInContextPacks !== undefined && + rule.includeInContextPacksDefault !== filters.includeInContextPacks + ) { + return false; + } + if (filters.featureId && !rule.linkedFeatureIds.includes(filters.featureId)) { + return false; + } + if (filters.search) { + const haystack = `${rule.title} ${rule.rule} ${rule.category}`.toLowerCase(); + if (!haystack.includes(filters.search.toLowerCase())) return false; + } + + return true; + }); + +export const createMemoryRule = ( + projectId: string, + body: MemoryRuleMutationRequestDto & { rule: string }, +): MemoryRuleDto => { + const createdAt = new Date().toISOString(); + const includeDefault = body.includeInContextPacks ?? false; + const rule: MemoryRuleDto = { + id: `R-${projectId.toUpperCase()}-${memoryRuleCounter++}`, + projectId, + title: body.title?.trim() || body.rule.trim().slice(0, 60), + rule: body.rule.trim(), + scope: body.scope ?? "global", + category: body.category ?? "Technical", + severity: body.severity ?? "important", + linkedFeatureIds: body.linkedFeatureIds ?? [], + includeInContextPacks: [], + includeInContextPacksDefault: includeDefault, + trust: { + source: "deterministic memory-rule create", + confidence: 78, + evidence: [], + lastChecked: createdAt, + approvalState: "pending", + }, + createdAt, + updatedAt: createdAt, + }; + + memoryRuleFixtures.push(rule); + return rule; +}; + +export const updateMemoryRule = ( + projectId: string, + ruleId: string, + body: MemoryRuleMutationRequestDto, +): MemoryRuleDto | undefined => { + const rule = findMemoryRule(projectId, ruleId); + if (!rule) return undefined; + + const updatedAt = new Date().toISOString(); + if (body.title !== undefined) rule.title = body.title.trim(); + if (body.rule !== undefined) rule.rule = body.rule.trim(); + if (body.scope !== undefined) rule.scope = body.scope; + if (body.category !== undefined) rule.category = body.category; + if (body.severity !== undefined) rule.severity = body.severity; + if (body.linkedFeatureIds !== undefined) { + rule.linkedFeatureIds = body.linkedFeatureIds; + } + if (body.includeInContextPacks !== undefined) { + rule.includeInContextPacksDefault = body.includeInContextPacks; + } + rule.updatedAt = updatedAt; + rule.trust.lastChecked = updatedAt; + + return rule; +}; + +export const includeMemoryRuleInContextPack = ( + projectId: string, + ruleId: string, + contextPackId: string, +): MemoryRuleDto | undefined => { + const rule = findMemoryRule(projectId, ruleId); + if (!rule) return undefined; + if (!rule.includeInContextPacks.includes(contextPackId)) { + rule.includeInContextPacks.push(contextPackId); + } + rule.updatedAt = new Date().toISOString(); + rule.includeInContextPacksDefault = true; + return rule; +}; diff --git a/backend/src/fixtures/projectFixtures.ts b/backend/src/fixtures/projectFixtures.ts new file mode 100644 index 0000000..61e80fd --- /dev/null +++ b/backend/src/fixtures/projectFixtures.ts @@ -0,0 +1,453 @@ +import type { + ContextPackSummaryDto, + CurrentStateSummaryDto, + ProjectDetailDto, + ProjectDna, + ProjectSummaryDto, + WorkspaceReadDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; + +const widefrogDna: ProjectDna = { + vision: "Deliver accessible, adaptive media-first workflows with high reliability.", + goals: [ + "Ship quality features in 2 week cycles", + "Increase test confidence for authentication and streaming", + "Track architectural risks before implementation", + ], + nonGoals: [ + "No direct platform migration", + "No cross-region multi-tenancy in phase 1", + ], + targetAudience: "Small to medium engineering teams with one release train", + principles: [ + "Evidence-first decisions", + "Small, measurable feature changes", + "No hidden architecture assumptions", + ], + successMetrics: [ + { + metric: "release-cycle-duration", + target: "<= 14", + targetValue: 14, + operator: "<=", + }, + { + metric: "regression-rate", + target: "<2%", + targetValue: 2, + operator: "<", + }, + ], + maturityStage: "Execution", + revision: 2, + updatedAt: trustNow, +}; + +const nebulaDna: ProjectDna = { + vision: "Create a secure onboarding toolkit for internal operations teams.", + goals: [ + "Reduce manual onboarding time by 30%", + "Keep architecture explicit with documented trust metadata", + ], + nonGoals: ["No native mobile app in phase 1"], + targetAudience: "Operations and product teams in enterprise settings", + principles: [ + "Policy-driven behavior", + "Traceable AI output", + "Minimal accidental complexity", + ], + successMetrics: [ + { + metric: "ticket-resolution-time", + target: "-25%", + targetValue: 25, + operator: ">=", + }, + ], + maturityStage: "Definition", + revision: 1, + updatedAt: trustNow, +}; + +export const projectFixtures: ProjectDetailDto[] = [ + { + id: "widefrog", + name: "WideFrog", + description: "Cross-platform streaming service for real-time media teams.", + health: 92, + healthStatus: "healthy", + lastSync: "2026-06-04 09:20", + domain: "streaming", + status: "active", + createdAt: "2026-01-10T09:00:00Z", + updatedAt: trustNow, + activeContextPackId: "cp-widefrog-core", + dna: widefrogDna, + constitution: { + id: "const-widefrog-01", + projectId: "widefrog", + title: "ProjectBrain Constitution", + intent: "Keep the project operating system context-first, traceable, and review-gated.", + rules: [ + "Context first, prompt second.", + "AI may suggest but not silently mutate truth.", + "Everything must be traceable.", + "Features are operational truth.", + "Do-not-break rules override new feature requests.", + "Verification before expansion.", + ], + updatedAt: trustNow, + revision: 1, + }, + constraints: [ + { + id: "con-wide-01", + projectId: "widefrog", + title: "Self-hosted by default", + description: + "The product must support local operation without depending on managed cloud services.", + category: "hosting", + status: "active", + intent: "Preserve deployment independence for Unraid and home-lab users.", + }, + { + id: "con-wide-02", + projectId: "widefrog", + title: "No paid API requirement", + description: + "Core workflows must remain useful without mandatory paid AI or SaaS APIs.", + category: "cost", + status: "active", + intent: "Avoid vendor lock-in and surprise operational cost.", + }, + { + id: "con-wide-03", + projectId: "widefrog", + title: "Local-first where possible", + description: + "Project knowledge, context packs and imports should be modelled for local storage first.", + category: "portability", + status: "active", + intent: "Make project memory durable outside any single provider.", + }, + ], + principles: [ + { + id: "pri-wide-01", + projectId: "widefrog", + title: "Context first", + description: "Every AI or Codex task starts from a current context pack.", + intent: "Reduce prompt drift and missing project history.", + status: "active", + }, + { + id: "pri-wide-02", + projectId: "widefrog", + title: "Review before truth", + description: + "AI outputs enter the review queue before they affect project truth.", + intent: "Keep human approval as the boundary between suggestion and state.", + status: "active", + }, + { + id: "pri-wide-03", + projectId: "widefrog", + title: "Traceability over speed", + description: + "Prefer slower accepted changes with evidence over fast untraceable updates.", + intent: "Make future context packs trustworthy.", + status: "active", + }, + ], + initiatives: [ + { + id: "init-wide-reliability", + projectId: "widefrog", + title: "Improve Reliability", + intent: "Make regressions visible before implementation continues.", + status: "active", + featureIds: ["f-auth-mfa", "f-auth-social"], + priority: "important", + }, + { + id: "init-wide-ai-ops", + projectId: "widefrog", + title: "AI Operations Layer", + intent: + "Make Capture, Review, Current State and Codex Task flows visible before backend work resumes.", + status: "planned", + featureIds: ["f-auth-mfa"], + priority: "important", + }, + ], + featureGroups: [ + { + id: "auth", + name: "Authentication", + description: "Authentication and identity layer", + focusArea: "architecture", + featureIds: ["f-auth-social", "f-auth-mfa"], + }, + ], + memoryRules: [ + { + id: "R-UX-01", + title: "Primary Action Placement", + category: "UX", + scope: "project", + priority: "High", + status: "Verified", + }, + { + id: "R-SEC-02", + title: "Global API Error Handling", + category: "Technical", + scope: "project", + priority: "Critical", + status: "Verified", + }, + ], + promptTemplates: [ + { + id: "tmpl-feature", + name: "New Feature", + description: "Generate scaffold for a new feature.", + icon: "Plus", + }, + { + id: "tmpl-bug", + name: "Fix Bug", + description: "Create bug-fix task with context.", + icon: "Bug", + }, + ], + workspaceState: { + projectId: "widefrog", + activeContextPackId: "cp-widefrog-core", + primaryObjective: "Stabilize auth and stream experiences before next release.", + focusAreas: [ + { + id: "fa-auth", + title: "Identity and Access", + purpose: "quality", + owner: "Alex", + confidence: 4, + summary: "Increase trust on auth flow and reduce login regressions.", + state: "active", + entities: [ + { + entityType: "feature", + entityId: "f-auth-mfa", + projectId: "widefrog", + label: "Multi-factor Authentication", + link: "/projects/widefrog/features/f-auth-mfa", + }, + { + entityType: "bug", + entityId: "B-MFA-01", + projectId: "widefrog", + label: "SMS delivery fails on some carriers", + link: "/projects/widefrog/bugs", + }, + ], + }, + ], + openTasksCount: 1, + openBugCount: 1, + activeSection: "workspace", + updatedAt: trustNow, + notes: [ + "Auth is primary risk area.", + "Run AI checks after each auth change.", + ], + }, + }, + { + id: "nebula", + name: "Nebula Console", + description: "Operations-first internal console for role-driven onboarding.", + health: 84, + healthStatus: "warn", + lastSync: "2026-06-03 16:10", + domain: "operations", + status: "active", + createdAt: "2026-03-01T10:00:00Z", + updatedAt: "2026-06-03T16:10:00Z", + activeContextPackId: "cp-nebula-core", + dna: nebulaDna, + constraints: [], + principles: [], + initiatives: [], + featureGroups: [ + { + id: "onboarding", + name: "Onboarding", + description: "Identity and team onboarding modules", + focusArea: "delivery", + featureIds: ["f-onboard-flow"], + }, + ], + memoryRules: [ + { + id: "R-NB-01", + title: "Keep onboarding policy explicit", + category: "Architecture", + scope: "project", + priority: "High", + status: "Pending", + }, + ], + promptTemplates: [ + { + id: "tmpl-onboard", + name: "Onboarding Flow", + description: "Draft onboarding execution plan.", + icon: "User", + }, + ], + }, +]; + +const contextPackSummaries: ContextPackSummaryDto[] = [ + { + projectId: "widefrog", + contextPackId: "cp-widefrog-core", + title: "Core Identity and Streams", + category: "architecture", + summary: + "Authentication and stream modules have the highest evidence value and must stay explicit before adding scope.", + counts: { + assumptions: 2, + constraints: 2, + evidence: 1, + features: 2, + bugs: 1, + tasks: 1, + decisions: 1, + memoryRules: 2, + risks: 2, + }, + qualityScore: 88, + createdAt: "2026-06-03T09:00:00Z", + updatedAt: trustNow, + }, + { + projectId: "nebula", + contextPackId: "cp-nebula-core", + title: "Onboarding Policy Baseline", + category: "quality", + summary: "Focus on policy traceability and stable onboarding defaults.", + counts: { + assumptions: 1, + constraints: 1, + evidence: 1, + features: 1, + bugs: 0, + tasks: 0, + decisions: 0, + memoryRules: 1, + risks: 1, + }, + qualityScore: 61, + createdAt: "2026-06-02T14:00:00Z", + updatedAt: "2026-06-03T16:00:00Z", + }, +]; + +const currentStateSummaries: Record = { + widefrog: { + generatedAt: "2026-06-05T21:45:00Z", + exists: [ + "Project DNA", + "Feature Map", + "Context Packs", + "Mock Build Journal", + "Memory Rules", + ], + works: [ + "Context-first navigation", + "Mock evidence display", + "Workspace focus areas", + ], + uncertain: [ + "Final evidence threshold for feature lifecycle", + "Codex import review mapping", + ], + broken: [], + currentRisks: [ + "AI output could be mistaken for truth without visible review state.", + ], + pendingReviewItemIds: ["rq-wide-01", "rq-wide-02"], + recommendedNextActions: [ + "Run the database smoke suite against the active Tower deployment.", + "Review stale review queue items before promoting new project truth.", + "Use Repo Builder and Handoffs to package the next release slice.", + ], + }, +}; + +export const toProjectSummary = ( + project: ProjectDetailDto, +): ProjectSummaryDto => ({ + id: project.id, + name: project.name, + description: project.description, + health: project.health, + healthStatus: project.healthStatus, + lastSync: project.lastSync, + domain: project.domain, + status: project.status, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + activeContextPackId: project.activeContextPackId, +}); + +export const listProjects = (): ProjectSummaryDto[] => + projectFixtures.map(toProjectSummary); + +export const findProject = (projectId: string): ProjectDetailDto | undefined => + projectFixtures.find((project) => project.id === projectId); + +export const findContextPackSummary = ( + projectId: string, + contextPackId: string, +): ContextPackSummaryDto | undefined => + contextPackSummaries.find( + (contextPack) => + contextPack.projectId === projectId && + contextPack.contextPackId === contextPackId, + ); + +export const getWorkspaceRead = ( + project: ProjectDetailDto, +): WorkspaceReadDto => { + const focusAreas = project.workspaceState?.focusAreas ?? []; + const activeContextPackId = + project.workspaceState?.activeContextPackId ?? project.activeContextPackId; + const currentStateSummary = currentStateSummaries[project.id]; + + return { + project: toProjectSummary(project), + dna: project.dna, + activeFocusAreaId: focusAreas.find((area) => area.state === "active")?.id, + activeContextPackId, + focusAreas, + workspaceState: project.workspaceState, + currentStateSummary, + healthSummary: { + health: project.health, + healthStatus: project.healthStatus, + openTasksCount: project.workspaceState?.openTasksCount ?? 0, + openBugCount: project.workspaceState?.openBugCount ?? 0, + riskCount: + activeContextPackId === undefined + ? 0 + : (findContextPackSummary(project.id, activeContextPackId)?.counts + .risks ?? 0), + pendingReviewCount: + currentStateSummary?.pendingReviewItemIds.length ?? 0, + }, + }; +}; diff --git a/backend/src/fixtures/regressionFixtures.ts b/backend/src/fixtures/regressionFixtures.ts new file mode 100644 index 0000000..7003a85 --- /dev/null +++ b/backend/src/fixtures/regressionFixtures.ts @@ -0,0 +1,168 @@ +import type { + ConvertRegressionRuleSeverity, + CreateRegressionRequestDto, + RegressionListFilters, + RegressionMemoryDto, + RegressionRuleDto, + RegressionSeverity, + TrustMetadataDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; + +const trust = (confidence: number): TrustMetadataDto => ({ + source: "deterministic regression fixture", + confidence, + evidence: [], + lastChecked: trustNow, + approvalState: "pending", +}); + +let regressionCounter = 2; +let ruleCounter = 1; + +export const regressionFixtures: RegressionMemoryDto[] = [ + { + id: "rm-wide-01", + projectId: "widefrog", + title: "Streaming queue telemetry fallback regression", + description: + "Telemetry timing can drift when fallback queue handling changes.", + affectedFeatureIds: ["f-auth-mfa", "f-auth-social"], + firstSeen: "2026-06-01T08:30:00Z", + timesOccurred: 3, + typicalCause: + "Incomplete telemetry update in fallback code path after architecture refactor.", + preventionRule: "Global API Error Handling", + linkedBuildIds: ["build-wide-01"], + severity: "medium", + status: "watch", + includeInContextPacks: ["cp-widefrog-core"], + trust: trust(91), + }, +]; + +export const regressionRuleFixtures: RegressionRuleDto[] = []; + +export const listRegressionsForProject = ( + projectId: string, +): RegressionMemoryDto[] => + regressionFixtures.filter((regression) => regression.projectId === projectId); + +export const findRegression = ( + projectId: string, + regressionId: string, +): RegressionMemoryDto | undefined => + regressionFixtures.find( + (regression) => + regression.projectId === projectId && regression.id === regressionId, + ); + +export const applyRegressionFilters = ( + regressions: RegressionMemoryDto[], + filters: RegressionListFilters, +): RegressionMemoryDto[] => + regressions.filter((regression) => { + if (filters.status?.length && !filters.status.includes(regression.status)) { + return false; + } + if ( + filters.featureId && + !regression.affectedFeatureIds.includes(filters.featureId) + ) { + return false; + } + if ( + filters.severity?.length && + !filters.severity.includes(regression.severity) + ) { + return false; + } + if ( + filters.timesOccurredMin !== undefined && + regression.timesOccurred < filters.timesOccurredMin + ) { + return false; + } + if ( + filters.timesOccurredMax !== undefined && + regression.timesOccurred > filters.timesOccurredMax + ) { + return false; + } + + return true; + }); + +export const createRegression = ( + projectId: string, + body: CreateRegressionRequestDto, +): RegressionMemoryDto => { + const createdAt = new Date().toISOString(); + const regression: RegressionMemoryDto = { + id: `rm-${projectId}-${regressionCounter++}`, + projectId, + title: body.title.trim(), + description: body.description, + affectedFeatureIds: body.affectedFeatureIds ?? [], + firstSeen: createdAt, + timesOccurred: 1, + typicalCause: body.typicalCause?.trim() || "Fixture-created regression cause.", + preventionRule: + body.preventionRule?.trim() || "Add deterministic prevention rule.", + linkedBuildIds: [], + severity: body.severity ?? "medium", + status: "active", + includeInContextPackStatus: "not_in_pack", + trust: { + source: "deterministic regression create", + confidence: 76, + evidence: [], + lastChecked: createdAt, + approvalState: "pending", + }, + }; + + regressionFixtures.push(regression); + return regression; +}; + +export const convertRegressionToRule = ( + projectId: string, + regressionId: string, + ruleTitle?: string, + severity?: ConvertRegressionRuleSeverity, +): { createdRule: RegressionRuleDto } | undefined => { + const regression = findRegression(projectId, regressionId); + if (!regression) return undefined; + + const createdAt = new Date().toISOString(); + const createdRule: RegressionRuleDto = { + id: `rule-${projectId}-${ruleCounter++}`, + projectId, + regressionId, + title: ruleTitle?.trim() || regression.preventionRule, + severity: severity ?? toRuleSeverity(regression.severity), + source: "regression-memory", + createdAt, + }; + + regression.convertedRuleId = createdRule.id; + regression.status = "resolved"; + regression.trust = { + ...regression.trust, + approvalState: "approved", + lastChecked: createdAt, + }; + regressionRuleFixtures.push(createdRule); + + return { createdRule }; +}; + +const toRuleSeverity = ( + severity: RegressionSeverity, +): ConvertRegressionRuleSeverity => { + if (severity === "critical") return "critical"; + if (severity === "high") return "do_not_break"; + return "important"; +}; diff --git a/backend/src/fixtures/relationshipFixtures.ts b/backend/src/fixtures/relationshipFixtures.ts new file mode 100644 index 0000000..a69fc39 --- /dev/null +++ b/backend/src/fixtures/relationshipFixtures.ts @@ -0,0 +1,130 @@ +import type { + CreateRelationshipRequestDto, + RelationshipDto, + RelationshipListFilters, + TrustMetadataDto, +} from "../types/api.js"; + +const trustNow = "2026-06-04T09:00:00Z"; +let relationshipCounter = 3; + +const trust = (confidence: number): TrustMetadataDto => ({ + source: "deterministic relationship fixture", + confidence, + evidence: [], + lastChecked: trustNow, + approvalState: "pending", +}); + +export const relationshipFixtures: RelationshipDto[] = [ + { + id: "rel-wide-01", + projectId: "widefrog", + from: "f-auth-mfa", + to: "R-SEC-02", + type: "depends_on", + rationale: + "MFA implementation depends on global API error handling staying stable.", + strength: 5, + confidence: 92, + source: "fixture-analysis", + trust: trust(92), + createdAt: "2026-06-03T12:00:00Z", + }, + { + id: "rel-wide-02", + projectId: "widefrog", + from: "cp-widefrog-core", + to: "f-auth-mfa", + type: "references", + rationale: "The core context pack references MFA as active implementation context.", + strength: 4, + confidence: 88, + source: "fixture-analysis", + trust: trust(88), + createdAt: "2026-06-03T12:30:00Z", + }, +]; + +export const listRelationshipsForProject = ( + projectId: string, +): RelationshipDto[] => + relationshipFixtures.filter( + (relationship) => + relationship.projectId === projectId && relationship.deletedAt === undefined, + ); + +export const findRelationship = ( + projectId: string, + relationshipId: string, +): RelationshipDto | undefined => + relationshipFixtures.find( + (relationship) => + relationship.projectId === projectId && + relationship.id === relationshipId && + relationship.deletedAt === undefined, + ); + +export const applyRelationshipFilters = ( + relationships: RelationshipDto[], + filters: RelationshipListFilters, +): RelationshipDto[] => + relationships.filter((relationship) => { + if (filters.from && relationship.from !== filters.from) return false; + if (filters.to && relationship.to !== filters.to) return false; + if (filters.type?.length && !filters.type.includes(relationship.type)) { + return false; + } + if ( + filters.confidence !== undefined && + relationship.confidence !== filters.confidence + ) { + return false; + } + if (filters.strength !== undefined && relationship.strength !== filters.strength) { + return false; + } + + return true; + }); + +export const createRelationship = ( + projectId: string, + body: CreateRelationshipRequestDto, +): RelationshipDto => { + const createdAt = new Date().toISOString(); + const confidence = body.confidence ?? 75; + const relationship: RelationshipDto = { + id: `rel-${projectId}-${relationshipCounter++}`, + projectId, + from: body.from.trim(), + to: body.to.trim(), + type: body.type, + rationale: body.rationale.trim(), + strength: body.strength ?? 3, + confidence, + source: "deterministic relationship create", + trust: { + source: "deterministic relationship create", + confidence, + evidence: [], + lastChecked: createdAt, + approvalState: "pending", + }, + createdAt, + }; + + relationshipFixtures.push(relationship); + return relationship; +}; + +export const deleteRelationship = ( + projectId: string, + relationshipId: string, +): RelationshipDto | undefined => { + const relationship = findRelationship(projectId, relationshipId); + if (!relationship) return undefined; + + relationship.deletedAt = new Date().toISOString(); + return relationship; +}; diff --git a/backend/src/fixtures/reviewQueueFixtures.ts b/backend/src/fixtures/reviewQueueFixtures.ts new file mode 100644 index 0000000..cc55bcf --- /dev/null +++ b/backend/src/fixtures/reviewQueueFixtures.ts @@ -0,0 +1,244 @@ +import type { + CaptureItemDto, + ReviewMutationResponseDto, + ReviewQueueConvertTarget, + ReviewQueueItemDto, + ReviewQueueListFilters, + UpdateReviewQueueItemRequestDto, +} from "../types/api.js"; + +let reviewItemCounter = 3; + +export const reviewQueueFixtures: ReviewQueueItemDto[] = [ + { + id: "rq-wide-01", + projectId: "widefrog", + type: "feature", + title: "Capture Center routes intake into Review Queue", + description: + "Create a central intake surface that classifies notes, build summaries, AI output and errors before they can change project truth.", + priority: "important", + status: "pending", + source: "scope update", + confidence: 89, + evidenceStrength: "medium", + verificationState: "needs evidence", + suggestedAction: + "Accept as planned backend mock scope and keep project truth review-gated.", + createdAt: "2026-06-05T21:20:00Z", + updatedAt: "2026-06-05T21:20:00Z", + }, + { + id: "rq-wide-02", + projectId: "widefrog", + type: "architecture_conflict", + title: "Prompts must remain downstream from Context Packs", + description: + "The model needs structured Codex task exports while preserving context packs as the upstream source.", + priority: "normal", + status: "pending", + source: "product constitution", + confidence: 82, + evidenceStrength: "strong", + verificationState: "unverified", + suggestedAction: + "Convert prompt-builder planning into Codex task planning without real Codex integration.", + createdAt: "2026-06-05T21:24:00Z", + updatedAt: "2026-06-05T21:24:00Z", + }, +]; + +export const listReviewItemsForProject = ( + projectId: string, +): ReviewQueueItemDto[] => + reviewQueueFixtures.filter((item) => item.projectId === projectId); + +export const findReviewItem = ( + projectId: string, + reviewItemId: string, +): ReviewQueueItemDto | undefined => + reviewQueueFixtures.find( + (item) => item.projectId === projectId && item.id === reviewItemId, + ); + +export const applyReviewQueueFilters = ( + items: ReviewQueueItemDto[], + filters: ReviewQueueListFilters, +): ReviewQueueItemDto[] => + items.filter((item) => { + if (filters.priority?.length && !filters.priority.includes(item.priority)) { + return false; + } + if (filters.type?.length && !filters.type.includes(item.type)) return false; + if (filters.status?.length && !filters.status.includes(item.status)) { + return false; + } + if (filters.source?.length && !filters.source.includes(item.source)) { + return false; + } + if (filters.confidence !== undefined && item.confidence !== filters.confidence) { + return false; + } + if ( + filters.evidenceStrength?.length && + !filters.evidenceStrength.includes(item.evidenceStrength) + ) { + return false; + } + if ( + filters.verificationState?.length && + !filters.verificationState.includes(item.verificationState) + ) { + return false; + } + + return true; + }); + +export const updateReviewItem = ( + projectId: string, + reviewItemId: string, + body: UpdateReviewQueueItemRequestDto, +): ReviewQueueItemDto | undefined | "REVIEW_ALREADY_DECIDED" => { + const item = findReviewItem(projectId, reviewItemId); + if (!item) return undefined; + if (item.status !== "pending") return "REVIEW_ALREADY_DECIDED"; + + if (body.title !== undefined) item.title = body.title.trim(); + if (body.description !== undefined) item.description = body.description.trim(); + if (body.priority !== undefined) item.priority = body.priority; + if (body.notes !== undefined) item.notes = body.notes.trim(); + item.updatedAt = new Date().toISOString(); + return item; +}; + +export const createReviewItemFromCapture = ( + captureItem: CaptureItemDto, +): ReviewQueueItemDto => { + const createdAt = new Date().toISOString(); + const item: ReviewQueueItemDto = { + id: `rq-${captureItem.projectId}-${reviewItemCounter++}`, + projectId: captureItem.projectId, + type: toReviewType(captureItem.classificationType ?? "knowledge"), + title: captureItem.title, + description: captureItem.content, + priority: captureItem.confidence && captureItem.confidence >= 90 ? "important" : "normal", + status: "pending", + source: captureItem.source, + confidence: captureItem.confidence ?? 72, + evidenceStrength: captureItem.evidenceStrength ?? "weak", + verificationState: "needs evidence", + suggestedAction: + captureItem.suggestedAction ?? "Review captured item before project truth changes.", + createdAt, + updatedAt: createdAt, + }; + + reviewQueueFixtures.push(item); + return item; +}; + +export const createReviewPreviewFromAIAction = ( + projectId: string, + source: string, + title: string, + description: string, +): ReviewQueueItemDto => { + const createdAt = new Date().toISOString(); + return { + id: `rq-preview-${projectId}-${reviewItemCounter++}`, + projectId, + type: "feature", + title, + description, + priority: "normal", + status: "pending", + source, + confidence: 74, + evidenceStrength: "weak", + verificationState: "unverified", + suggestedAction: "Preview only: route to Review Queue before accepting truth.", + createdAt, + updatedAt: createdAt, + }; +}; + +export const applyReviewAction = ( + projectId: string, + reviewItemId: string, + action: "accept" | "reject", + notes?: string, +): ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED" => { + const item = findReviewItem(projectId, reviewItemId); + if (!item) return undefined; + if (item.status !== "pending") return "REVIEW_ALREADY_DECIDED"; + + item.status = action === "accept" ? "accepted" : "rejected"; + item.notes = notes; + item.updatedAt = new Date().toISOString(); + + return { + success: true, + message: `Review item '${reviewItemId}' ${item.status}.`, + updatedEntityId: reviewItemId, + affectedEntities: [reviewItemId], + }; +}; + +export const mergeReviewItem = ( + projectId: string, + reviewItemId: string, + targetEntityId: string, + notes?: string, +): ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED" => { + const item = findReviewItem(projectId, reviewItemId); + if (!item) return undefined; + if (item.status !== "pending") return "REVIEW_ALREADY_DECIDED"; + + item.status = "merged"; + item.targetEntityId = targetEntityId; + item.notes = notes; + item.updatedAt = new Date().toISOString(); + + return { + success: true, + message: `Review item '${reviewItemId}' merged into '${targetEntityId}'.`, + updatedEntityId: reviewItemId, + affectedEntities: [reviewItemId, targetEntityId], + }; +}; + +export const convertReviewItem = ( + projectId: string, + reviewItemId: string, + target: ReviewQueueConvertTarget, + notes?: string, +): ReviewMutationResponseDto | undefined | "REVIEW_ALREADY_DECIDED" => { + const item = findReviewItem(projectId, reviewItemId); + if (!item) return undefined; + if (item.status !== "pending") return "REVIEW_ALREADY_DECIDED"; + + const createdEntityId = `${target}-${projectId}-${reviewItemCounter++}`; + item.status = "converted"; + item.convertedTarget = target; + item.createdEntityId = createdEntityId; + item.notes = notes; + item.updatedAt = new Date().toISOString(); + + return { + success: true, + message: `Review item '${reviewItemId}' converted to ${target}.`, + updatedEntityId: reviewItemId, + createdEntityId, + affectedEntities: [reviewItemId, createdEntityId], + }; +}; + +const toReviewType = ( + classificationType: NonNullable, +): ReviewQueueItemDto["type"] => { + if (classificationType === "documentation_update") return "documentation_update"; + if (classificationType === "open_question") return "open_question"; + if (classificationType === "memory_rule") return "memory_rule"; + return classificationType; +}; diff --git a/backend/src/fixtures/snapshotFixtures.ts b/backend/src/fixtures/snapshotFixtures.ts new file mode 100644 index 0000000..6c81f21 --- /dev/null +++ b/backend/src/fixtures/snapshotFixtures.ts @@ -0,0 +1,123 @@ +import type { + CreateSnapshotRequestDto, + SnapshotCompareDto, + SnapshotDto, + SnapshotListFilters, +} from "../types/api.js"; + +let snapshotCounter = 2; + +export const snapshotFixtures: SnapshotDto[] = [ + { + id: "sn-widefrog-001", + timestamp: "2026-06-03T14:00:00Z", + sourceRun: "import-analysis", + projectId: "widefrog", + itemsAdded: ["f-auth-mfa", "B-MFA-01", "cp-widefrog-core"], + itemsRemoved: [], + diffs: [ + { + section: "Feature Map", + before: "Auth flow had no MFA feature", + after: "Added MFA feature with checks and constraints", + reason: "Architecture-first ingest revealed critical auth risk", + }, + ], + keyRisks: ["MFA edge-case not covered in streaming contexts"], + summary: "First architecture-first snapshot for auth stabilisation.", + }, +]; + +export const listSnapshotsForProject = (projectId: string): SnapshotDto[] => + snapshotFixtures.filter((snapshot) => snapshot.projectId === projectId); + +export const findSnapshot = ( + projectId: string, + snapshotId: string, +): SnapshotDto | undefined => + snapshotFixtures.find( + (snapshot) => snapshot.projectId === projectId && snapshot.id === snapshotId, + ); + +export const applySnapshotFilters = ( + snapshots: SnapshotDto[], + filters: SnapshotListFilters, +): SnapshotDto[] => + snapshots.filter((snapshot) => { + if ( + filters.sourceRun?.length && + !filters.sourceRun.includes(snapshot.sourceRun) + ) { + return false; + } + if (filters.from && snapshot.timestamp < filters.from) return false; + if (filters.to && snapshot.timestamp > filters.to) return false; + if ( + filters.containsRisk && + !snapshot.keyRisks.some((risk) => + risk.toLowerCase().includes(filters.containsRisk?.toLowerCase() ?? ""), + ) + ) { + return false; + } + + return true; + }); + +export const createSnapshot = ( + projectId: string, + body: CreateSnapshotRequestDto, +): SnapshotDto => { + const timestamp = new Date().toISOString(); + const snapshot: SnapshotDto = { + id: `sn-${projectId}-${String(snapshotCounter++).padStart(3, "0")}`, + timestamp, + sourceRun: body.sourceRun ?? "manual", + projectId, + itemsAdded: [], + itemsRemoved: [], + diffs: [ + { + section: "R6 Snapshot", + before: "Previous fixture state", + after: "New deterministic snapshot state", + reason: "Snapshot created through real backend R6 API.", + }, + ], + keyRisks: body.keyRisks ?? [], + summary: body.summary?.trim() || "Deterministic backend snapshot.", + }; + + snapshotFixtures.push(snapshot); + return snapshot; +}; + +export const compareSnapshots = ( + projectId: string, + baseSnapshotId: string, + targetSnapshotId: string, +): SnapshotCompareDto | undefined => { + const baseSnapshot = findSnapshot(projectId, baseSnapshotId); + const targetSnapshot = findSnapshot(projectId, targetSnapshotId); + if (!baseSnapshot || !targetSnapshot) return undefined; + + const added = targetSnapshot.itemsAdded.filter( + (item) => !baseSnapshot.itemsAdded.includes(item), + ); + const removed = baseSnapshot.itemsAdded.filter( + (item) => !targetSnapshot.itemsAdded.includes(item), + ); + const conflictCount = targetSnapshot.keyRisks.filter((risk) => + baseSnapshot.keyRisks.includes(risk), + ).length; + + return { + baseSnapshotId, + targetSnapshotId, + added, + removed, + sectionCount: targetSnapshot.diffs.length, + conflictCount, + summary: `Compared ${baseSnapshotId} to ${targetSnapshotId}: ${added.length} added, ${removed.length} removed.`, + }; +}; diff --git a/backend/src/integrations/gitea/client.ts b/backend/src/integrations/gitea/client.ts new file mode 100644 index 0000000..02ac2f4 --- /dev/null +++ b/backend/src/integrations/gitea/client.ts @@ -0,0 +1,264 @@ +import type { CreateGiteaRepositoryInput, GiteaActionJobList, GiteaActionRunnerList, GiteaActionRunList, GiteaBranch, GiteaContent, GiteaIssue, GiteaIssueComment, GiteaLabel, GiteaPullRequest, GiteaRepository, GiteaSearchResult, GiteaWebhook } from "./types.js"; + +export class GiteaApiError extends Error { + constructor(public readonly status: number, public readonly code: string, message: string) { + super(message); + } +} + +type RequestOptions = { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; query?: Record; body?: unknown; allowEmptyResponse?: boolean }; +type GiteaBranchProtection = { enable_status_check?: boolean; status_check_contexts?: string[] }; +type GiteaCombinedStatus = { state?: string; statuses?: Array<{ context?: string; status?: string }> }; + +export class GiteaClient { + private readonly apiBase: string; + private mergeStrategyKey?: "Do" | "do"; + + constructor(baseUrl: string, private readonly token: string, private readonly fetcher: typeof fetch = fetch) { + this.apiBase = `${baseUrl.replace(/\/+$/, "")}/api/v1`; + } + + private async request(path: string, options: RequestOptions = {}): Promise { + const url = new URL(`${this.apiBase}${path}`); + for (const [key, value] of Object.entries(options.query ?? {})) if (value !== undefined) url.searchParams.set(key, String(value)); + const response = await this.fetcher(url, { + method: options.method ?? "GET", + headers: { + Accept: "application/json", + Authorization: `token ${this.token}`, + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + const payload = await response.json().catch(() => undefined) as { message?: string } | undefined; + throw new GiteaApiError(response.status, `GITEA_HTTP_${response.status}`, payload?.message ?? `Gitea request failed with HTTP ${response.status}`); + } + if (response.status === 204) return undefined as T; + if (options.allowEmptyResponse) { + const body = await response.text(); + return body.trim() ? JSON.parse(body) as T : undefined as T; + } + return await response.json() as T; + } + + getCurrentUser() { return this.request<{ id: number; login: string; full_name?: string }>("/user"); } + createRepository(input: CreateGiteaRepositoryInput) { + return this.request("/user/repos", { + method: "POST", + body: { + name: input.name, + description: input.description, + private: input.private, + auto_init: input.autoInit ?? true, + default_branch: input.defaultBranch ?? "main", + }, + }); + } + async listRepositories() { + const repositories: GiteaRepository[] = []; + const seenIds = new Set(); + for (let page = 1; page <= 10; page += 1) { + const batch = await this.request("/user/repos", { query: { limit: 100, page } }); + if (!Array.isArray(batch)) throw new GiteaApiError(502, "GITEA_INVENTORY_INVALID", "Gitea returned an invalid repository inventory page."); + // Gitea may clamp limit=100 to a smaller server-configured page size. + // Only an empty page proves exhaustion without trusting arbitrary Link URLs. + if (!batch.length) return repositories; + for (const repository of batch) { + if (!Number.isSafeInteger(repository.id) || repository.id <= 0 || seenIds.has(repository.id)) { + throw new GiteaApiError(502, "GITEA_INVENTORY_NONADVANCING", "Gitea repository pagination returned invalid or repeated identities; inventory is incomplete."); + } + seenIds.add(repository.id); + } + repositories.push(...batch); + } + throw new GiteaApiError(502, "GITEA_INVENTORY_LIMIT_EXCEEDED", "Gitea repository inventory exceeded the bounded pagination window; no partial inventory was returned."); + } + getRepository(owner: string, repository: string) { return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`); } + listBranches(owner: string, repository: string) { return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches`, { query: { limit: 100 } }); } + getBranch(owner: string, repository: string, branch: string) { return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`); } + compareCommits(owner: string, repository: string, base: string, head: string) { + return this.request<{ total_commits: number }>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/compare/${encodeURIComponent(base)}..${encodeURIComponent(head)}`); + } + + getBranchProtection(owner: string, repository: string, branch: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branch_protections/${encodeURIComponent(branch)}`); + } + + getCombinedCommitStatus(owner: string, repository: string, sha: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/commits/${encodeURIComponent(sha)}/status`); + } + + async waitForRequiredPullRequestChecks(owner: string, repository: string, branch: string, headSha: string, options: { timeoutMs?: number; pollIntervalMs?: number } = {}) { + let protection: GiteaBranchProtection; + try { protection = await this.getBranchProtection(owner, repository, branch); } + catch (error) { + if (error instanceof GiteaApiError && error.status === 404) return { requiredContexts: [], state: "not-required" as const }; + throw error; + } + const requiredContexts = protection.enable_status_check ? (protection.status_check_contexts ?? []).filter(Boolean) : []; + if (!requiredContexts.length) return { requiredContexts, state: "not-required" as const }; + const timeoutMs = options.timeoutMs ?? 8_000; + const pollIntervalMs = options.pollIntervalMs ?? 2_000; + const deadline = Date.now() + timeoutMs; + while (true) { + const combined = await this.getCombinedCommitStatus(owner, repository, headSha); + const latestByContext = new Map(); + for (const status of combined.statuses ?? []) if (status.context && !latestByContext.has(status.context)) latestByContext.set(status.context, status.status ?? ""); + const states = requiredContexts.map((context) => latestByContext.get(context)); + if (states.every((state) => state === "success")) return { requiredContexts, state: "success" as const }; + if (states.some((state) => state === "failure" || state === "error")) throw new GiteaApiError(409, "GITEA_REQUIRED_CHECKS_FAILED", "A required pull-request check failed for the exact head commit"); + if (Date.now() >= deadline) throw new GiteaApiError(409, "GITEA_REQUIRED_CHECKS_PENDING", "Required pull-request checks did not finish before the bounded merge deadline"); + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } + + getContents(owner: string, repository: string, path: string, ref?: string) { + const encodedPath = path.split("/").map(encodeURIComponent).join("/"); + const suffix = encodedPath ? `/${encodedPath}` : ""; + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents${suffix}`, { query: { ref } }); + } + + searchCode(owner: string, repository: string, query: string, ref?: string) { + return this.request<{ total_count?: number; data?: GiteaSearchResult[] } | GiteaSearchResult[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/search/code`, { query: { q: query, ref, limit: 50 } }); + } + + createBranch(owner: string, repository: string, branch: string, sourceBranch: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches`, { + method: "POST", body: { new_branch_name: branch, old_branch_name: sourceBranch }, + }); + } + + createFile(owner: string, repository: string, path: string, branch: string, content: string, message: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents/${path.split("/").map(encodeURIComponent).join("/")}`, { + method: "POST", body: { branch, content: Buffer.from(content, "utf8").toString("base64"), message }, + }); + } + + updateFile(owner: string, repository: string, path: string, branch: string, sha: string, content: string, message: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents/${path.split("/").map(encodeURIComponent).join("/")}`, { + method: "PUT", body: { branch, sha, content: Buffer.from(content, "utf8").toString("base64"), message }, + }); + } + + deleteFile(owner: string, repository: string, path: string, branch: string, sha: string, message: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/contents/${path.split("/").map(encodeURIComponent).join("/")}`, { + method: "DELETE", body: { branch, sha, message }, + }); + } + + listPullRequests(owner: string, repository: string, state: "open" | "closed" | "all" = "open") { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls`, { query: { state, limit: 50 } }); + } + + getPullRequest(owner: string, repository: string, number: number) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls/${number}`); + } + + createPullRequest(owner: string, repository: string, input: { title: string; body: string; head: string; base: string }) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls`, { method: "POST", body: input }); + } + + private async getMergeStrategyKey(): Promise<"Do" | "do"> { + if (this.mergeStrategyKey) return this.mergeStrategyKey; + const { version } = await this.request<{ version?: string }>("/version"); + const match = /^1\.(25|26|27)(?:\.\d+)?(?:[-+].*)?$/.exec(version ?? ""); + if (!match) throw new GiteaApiError(500, "GITEA_VERSION_UNSUPPORTED", "Gitea merge compatibility requires an identified 1.25, 1.26, or 1.27 server"); + this.mergeStrategyKey = match[1] === "25" ? "Do" : "do"; + return this.mergeStrategyKey; + } + + async mergePullRequest(owner: string, repository: string, number: number, input: { method: "merge" | "rebase" | "rebase-merge" | "squash" | "fast-forward-only"; headCommitId: string }) { + const strategyKey = await this.getMergeStrategyKey(); + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/pulls/${number}/merge`, { + method: "POST", + allowEmptyResponse: true, + body: { + [strategyKey]: input.method, + head_commit_id: input.headCommitId, + force_merge: false, + merge_when_checks_succeed: false, + delete_branch_after_merge: false, + }, + }); + } + + deleteBranch(owner: string, repository: string, branch: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`, { method: "DELETE", allowEmptyResponse: true }); + } + + listIssues(owner: string, repository: string, state: "open" | "closed" | "all" = "open", page = 1, limit = 50) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues`, { query: { state, page, limit, type: "issues" } }); + } + + getIssue(owner: string, repository: string, issueNumber: number) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}`); + } + + createIssue(owner: string, repository: string, input: { title: string; body: string; labels?: number[] }) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues`, { method: "POST", body: input }); + } + + editIssue(owner: string, repository: string, issueNumber: number, input: { title?: string; body?: string; state?: "open" | "closed" }) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}`, { method: "PATCH", body: input }); + } + + listIssueComments(owner: string, repository: string, issueNumber: number) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}/comments`, { query: { limit: 50 } }); + } + + createIssueComment(owner: string, repository: string, issueNumber: number, body: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}/comments`, { method: "POST", body: { body } }); + } + + listLabels(owner: string, repository: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/labels`, { query: { limit: 100 } }); + } + + createLabel(owner: string, repository: string, input: { name: string; color: string; description?: string }) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/labels`, { method: "POST", body: input }); + } + + replaceIssueLabels(owner: string, repository: string, issueNumber: number, labels: number[]) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/issues/${issueNumber}/labels`, { method: "PUT", body: { labels } }); + } + + listActionRuns(owner: string, repository: string, input: { branch?: string; headSha?: string; limit?: number } = {}) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/actions/runs`, { + // Filtering by branch upstream hides native PR runs with head_branch="". + // Exact-head callers perform branch/PR reconciliation in policy.ts. + query: { branch: input.headSha ? undefined : input.branch, head_sha: input.headSha, limit: input.limit ?? 30 }, + }); + } + + listUserActionRunners() { + return this.request("/user/actions/runners"); + } + + listUserActionJobs(input: { limit?: number; page?: number; status?: "pending" | "waiting" | "queued" | "in_progress" | "failure" | "success" | "skipped" } = {}) { + return this.request("/user/actions/jobs", { + query: { status: input.status, limit: input.limit ?? 50, page: input.page ?? 1, sort: "id", order: "desc" }, + }); + } + + listRepositoryWebhooks(owner: string, repository: string) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/hooks`, { query: { limit: 100 } }); + } + + createRepositoryWebhook(owner: string, repository: string, input: { url: string; secret: string; events?: string[] }) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/hooks`, { + method: "POST", body: { type: "gitea", active: true, events: input.events ?? ["pull_request", "issues", "issue_comment"], config: { url: input.url, content_type: "json", secret: input.secret } }, + }); + } + + editRepositoryWebhook(owner: string, repository: string, hookId: number, input: { url: string; secret: string; events: string[] }) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/hooks/${hookId}`, { + method: "PATCH", body: { active: true, events: input.events, config: { url: input.url, content_type: "json", secret: input.secret } }, + }); + } + + deleteRepositoryWebhook(owner: string, repository: string, hookId: number) { + return this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/hooks/${hookId}`, { method: "DELETE" }); + } +} diff --git a/backend/src/integrations/gitea/policy.ts b/backend/src/integrations/gitea/policy.ts new file mode 100644 index 0000000..26b7436 --- /dev/null +++ b/backend/src/integrations/gitea/policy.ts @@ -0,0 +1,92 @@ +import type { GiteaActionRun, GiteaPullRequest } from "./types.js"; + +export const findReusableDeliveryPullRequest = (pullRequests: GiteaPullRequest[], branch: string, base: string, headSha: string) => { + const matches = pullRequests.filter((pr) => pr.head.ref === branch && pr.head.sha === headSha && pr.base.ref === base && + (pr.state === "open" || pr.merged)); + if (matches.length > 1) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_AMBIGUOUS"); + const match = matches[0]; + if (match?.merged && !/^[0-9a-f]{40,64}$/i.test(match.merge_commit_sha ?? "")) throw new Error("FEATURE_DELIVERY_MERGE_EVIDENCE_REQUIRED"); + return match; +}; + +const forbiddenSegments = new Set([".git", ".ssh"]); +const sensitiveNames = /(^|\/)(\.env($|\.(?!example$|sample$))|id_(rsa|dsa|ecdsa|ed25519)|.*\.(pem|p12|pfx|key)|credentials(?:\.[^/]*)?|secrets?(?:\.[^/]*)?)$/i; + +export const MAX_GITEA_FILE_BYTES = 512 * 1024; + +export const normalizeRepositoryPath = (input: string): string => { + const path = input.trim().replace(/\\/g, "/").replace(/^\/+/, ""); + const segments = path.split("/").filter(Boolean); + if (!path || segments.some((segment) => segment === ".." || segment === "." || forbiddenSegments.has(segment.toLowerCase()))) { + throw new Error("REPOSITORY_PATH_FORBIDDEN"); + } + return segments.join("/"); +}; + +export const assertReadablePath = (input: string): string => { + const path = normalizeRepositoryPath(input); + if (sensitiveNames.test(path)) throw new Error("SENSITIVE_PATH_FORBIDDEN"); + return path; +}; + +export const assertWritablePath = (input: string): string => assertReadablePath(input); + +export const assertExpectedFileSha = (expectedSha: string | undefined, actualSha: string | undefined): void => { + if (expectedSha !== actualSha) throw new Error("GITEA_FILE_CONFLICT"); +}; + +const assertNonDefaultBranch = (branch: string, defaultBranch: string, errorCode: string): string => { + const normalized = branch.trim(); + if (!/^[a-z0-9][a-z0-9._/-]{0,299}$/i.test(normalized) || normalized.includes("..") || normalized.includes("//") || normalized.endsWith("/") || normalized.endsWith(".lock")) { + throw new Error(errorCode); + } + if (normalized === defaultBranch) throw new Error("DEFAULT_BRANCH_WRITE_FORBIDDEN"); + return normalized; +}; + +export const assertChatGptBranch = (branch: string, defaultBranch: string): string => + assertNonDefaultBranch(branch, defaultBranch, "MCP_BRANCH_FORBIDDEN"); + +export const assertProjectBrainBranch = (branch: string, defaultBranch: string): string => + assertNonDefaultBranch(branch, defaultBranch, "PROJECTBRAIN_BRANCH_FORBIDDEN"); + +export const assertFileSize = (content: string): void => { + if (Buffer.byteLength(content, "utf8") > MAX_GITEA_FILE_BYTES) throw new Error("MCP_FILE_TOO_LARGE"); +}; + +const actionWorkflowKey = (run: GiteaActionRun): string => + `${run.event}:${run.path.split("@")[0]?.trim() || run.display_title.trim() || `workflow-${run.id}`}`; + +// Native Gitea PR runs may omit head_branch. Keep the immutable SHA as the +// primary identity, and reject contradictory PR metadata when it is supplied. +export const exactHeadActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] => + runs.filter((run) => { + if (run.head_sha !== headSha) return false; + if (run.head_branch === branch) return true; + if (run.head_branch?.trim() || run.event !== "pull_request") return false; + const references = run.pull_requests ?? []; + return references.length === 0 || references.some((pr) => + (!pr.head?.ref || pr.head.ref === branch) && (!pr.head?.sha || pr.head.sha === headSha)); + }); + +export const assertSuccessfulActionRuns = (runs: GiteaActionRun[], branch: string, headSha: string): GiteaActionRun[] => { + const exactRuns = exactHeadActionRuns(runs, branch, headSha); + if (exactRuns.length === 0) throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + + // Gitea retains failed attempts when the same workflow is rerun for an + // unchanged revision. Authorization therefore follows the newest run of each + // workflow, while callers still receive the complete exact-SHA evidence set. + const latestByWorkflow = new Map(); + for (const run of exactRuns) { + const key = actionWorkflowKey(run); + const current = latestByWorkflow.get(key); + if (!current || run.run_number > current.run_number || (run.run_number === current.run_number && run.id > current.id)) { + latestByWorkflow.set(key, run); + } + } + + if ([...latestByWorkflow.values()].some((run) => run.status !== "completed" || run.conclusion !== "success")) { + throw new Error("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + } + return exactRuns; +}; diff --git a/backend/src/integrations/gitea/portfolio.ts b/backend/src/integrations/gitea/portfolio.ts new file mode 100644 index 0000000..f42d73f --- /dev/null +++ b/backend/src/integrations/gitea/portfolio.ts @@ -0,0 +1,97 @@ +import type { AppEnv } from "../../config/env.js"; +import { getGiteaClient, listLinkedRepositories, type LinkedRepository } from "./service.js"; + +export type PortfolioRepository = { + id: string; + owner: string; + repository: string; + fullName: string; + description?: string; + private: boolean; + archived: boolean; + defaultBranch: string; + htmlUrl: string; + updatedAt: string; + canPush: boolean; + linked: boolean; + link?: Pick< + LinkedRepository, + | "id" + | "projectId" + | "projectSlug" + | "projectName" + | "accessMode" + | "role" + | "isPrimary" + | "syncStatus" + >; +}; + +/** + * Project the complete repository inventory visible to ProjectBrain's scoped + * Gitea identity without creating or moving any ProjectBrain ownership links. + * + * GiteaRepositoryLink intentionally remains the write/delivery boundary. This + * projection exists only for portfolio discovery, search and read-only fetch so + * an aggregate workspace can see repositories that have not been linked to an + * individual ProjectBrain project yet. + */ +export const listPortfolioRepositories = async (env: AppEnv): Promise => { + const [repositories, links] = await Promise.all([ + getGiteaClient(env).listRepositories(), + listLinkedRepositories(), + ]); + const linksByRepository = new Map(links.map((link) => [link.fullName.toLowerCase(), link])); + + return repositories + .map((repository) => { + const link = linksByRepository.get(repository.full_name.toLowerCase()); + return { + id: `gitea-repository:${repository.id}`, + owner: repository.owner.login, + repository: repository.name, + fullName: repository.full_name, + description: repository.description, + private: repository.private, + archived: repository.archived, + defaultBranch: repository.default_branch, + htmlUrl: repository.html_url, + updatedAt: repository.updated_at, + canPush: Boolean(repository.permissions?.push || repository.permissions?.admin), + linked: Boolean(link), + ...(link + ? { + link: { + id: link.id, + projectId: link.projectId, + projectSlug: link.projectSlug, + projectName: link.projectName, + accessMode: link.accessMode, + role: link.role, + isPrimary: link.isPrimary, + syncStatus: link.syncStatus, + }, + } + : {}), + } satisfies PortfolioRepository; + }) + .sort((left, right) => left.fullName.localeCompare(right.fullName)); +}; + +export const portfolioFetchId = ( + repository: Pick, + ref: string, + path: string, +) => + `gitea-repository://${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repository)}/${encodeURIComponent(ref)}/${encodeURIComponent(path)}`; + +export const parsePortfolioFetchId = (id: string) => { + const match = id.match(/^gitea-repository:\/\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/); + if (!match) throw new Error("MCP_FETCH_ID_INVALID"); + return { + owner: decodeURIComponent(match[1]), + repository: decodeURIComponent(match[2]), + ref: decodeURIComponent(match[3]), + path: decodeURIComponent(match[4]), + }; +}; diff --git a/backend/src/integrations/gitea/service.ts b/backend/src/integrations/gitea/service.ts new file mode 100644 index 0000000..a1a0000 --- /dev/null +++ b/backend/src/integrations/gitea/service.ts @@ -0,0 +1,419 @@ +import { randomUUID } from "node:crypto"; +import { loadEnv, type AppEnv } from "../../config/env.js"; +import { prisma } from "../../db/prisma.js"; +import { GiteaApiError, GiteaClient } from "./client.js"; + +export const PROJECTBRAIN_GITEA_WEBHOOK_EVENTS = ["push", "pull_request", "issues", "issue_comment"] as const; + +export type LinkedRepository = { + id: string; + projectId: string; + projectSlug: string; + projectName: string; + owner: string; + repository: string; + fullName: string; + defaultBranch: string; + accessMode: "read_only" | "read_write"; + role: "primary" | "frontend" | "backend" | "infra" | "docs" | "shared" | "other"; + isPrimary: boolean; + syncStatus: "unverified" | "ready" | "degraded" | "unavailable"; + capabilities?: Record; + lastVerifiedAt?: string; + lastSyncAt?: string; + htmlUrl?: string; +}; + +export class GiteaRepositoryOwnershipConflictError extends Error { + readonly code = "GITEA_REPOSITORY_ALREADY_LINKED"; + + constructor( + readonly repositoryFullName: string, + readonly sourceProjectId: string, + readonly sourceProjectName: string, + ) { + super("GITEA_REPOSITORY_ALREADY_LINKED"); + this.name = "GiteaRepositoryOwnershipConflictError"; + } +} + +export class GiteaRepositoryTransferBlockedError extends Error { + readonly code = "GITEA_REPOSITORY_TRANSFER_BLOCKED"; + + constructor( + readonly repositoryFullName: string, + readonly deliveryRunCount: number, + ) { + super("GITEA_REPOSITORY_TRANSFER_BLOCKED"); + this.name = "GiteaRepositoryTransferBlockedError"; + } +} + +export const assertExplicitRepositoryTransfer = ( + existing: { projectId: string; project: { name: string } } | null | undefined, + targetProjectId: string, + repositoryFullName: string, + transfer: boolean, +) => { + if (existing && existing.projectId !== targetProjectId && !transfer) { + throw new GiteaRepositoryOwnershipConflictError(repositoryFullName, existing.projectId, existing.project.name); + } +}; + +export const assertRepositoryTransferHasNoDeliveryHistory = ( + repositoryFullName: string, + deliveryRunCount: number, +) => { + if (deliveryRunCount > 0) { + throw new GiteaRepositoryTransferBlockedError(repositoryFullName, deliveryRunCount); + } +}; + +type UnlinkContext = { env?: AppEnv; actor?: string }; + +const serviceErrorCode = (error: unknown) => + error instanceof GiteaApiError ? error.code : error instanceof Error ? error.message : "GITEA_INTEGRATION_FAILED"; + +const webhookUrlForEnv = (env: AppEnv) => + env.PROJECTBRAIN_PUBLIC_URL ? `${env.PROJECTBRAIN_PUBLIC_URL.replace(/\/$/, "")}/api/integrations/gitea/webhook` : undefined; + +const jsonObject = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + +export const getGiteaClient = (env: AppEnv): GiteaClient => { + if (!env.PROJECTBRAIN_GITEA_URL || !env.PROJECTBRAIN_GITEA_TOKEN) throw new Error("GITEA_NOT_CONFIGURED"); + return new GiteaClient(env.PROJECTBRAIN_GITEA_URL, env.PROJECTBRAIN_GITEA_TOKEN); +}; + +export const getGiteaIssueClient = (env: AppEnv): GiteaClient => { + if (!env.PROJECTBRAIN_GITEA_URL || !(env.PROJECTBRAIN_GITEA_WRITE_TOKEN || env.PROJECTBRAIN_GITEA_TOKEN)) throw new Error("GITEA_NOT_CONFIGURED"); + return new GiteaClient(env.PROJECTBRAIN_GITEA_URL, env.PROJECTBRAIN_GITEA_WRITE_TOKEN || env.PROJECTBRAIN_GITEA_TOKEN!); +}; + +const serializeLink = (link: { + id: string; projectId: string; owner: string; repository: string; defaultBranch: string; + accessMode: "read_only" | "read_write"; + role: LinkedRepository["role"]; isPrimary: boolean; syncStatus: LinkedRepository["syncStatus"]; + capabilitiesJson: unknown; lastVerifiedAt: Date | null; lastSyncAt: Date | null; + project: { slug: string; name: string }; +}, htmlUrl?: string): LinkedRepository => ({ + id: link.id, + projectId: link.projectId, + projectSlug: link.project.slug, + projectName: link.project.name, + owner: link.owner, + repository: link.repository, + fullName: `${link.owner}/${link.repository}`, + defaultBranch: link.defaultBranch, + accessMode: link.accessMode, + role: link.role, + isPrimary: link.isPrimary, + syncStatus: link.syncStatus, + ...(link.capabilitiesJson && typeof link.capabilitiesJson === "object" && !Array.isArray(link.capabilitiesJson) + ? { capabilities: link.capabilitiesJson as Record } + : {}), + ...(link.lastVerifiedAt ? { lastVerifiedAt: link.lastVerifiedAt.toISOString() } : {}), + ...(link.lastSyncAt ? { lastSyncAt: link.lastSyncAt.toISOString() } : {}), + ...(htmlUrl ? { htmlUrl } : {}), +}); + +export const listLinkedRepositories = async (): Promise => { + const links = await prisma.giteaRepositoryLink.findMany({ include: { project: { select: { slug: true, name: true } } }, orderBy: { updatedAt: "desc" } }); + return links.map((link) => serializeLink(link)); +}; + +export const getLinkedRepository = async (projectId: string): Promise => { + const link = await prisma.giteaRepositoryLink.findFirst({ + where: { project: { OR: [{ id: projectId }, { slug: projectId }] } }, + orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }], + include: { project: { select: { slug: true, name: true } } }, + }); + return link ? serializeLink(link) : undefined; +}; + +export const listProjectRepositories = async (projectId: string): Promise => { + const links = await prisma.giteaRepositoryLink.findMany({ + where: { project: { OR: [{ id: projectId }, { slug: projectId }] } }, + include: { project: { select: { slug: true, name: true } } }, + orderBy: [{ isPrimary: "desc" }, { role: "asc" }, { createdAt: "asc" }], + }); + return links.map((link) => serializeLink(link)); +}; + +export const linkRepository = async ( + env: AppEnv, + projectId: string, + fullName: string, + accessMode: "read_only" | "read_write", + linkedBy: string, + options: { role?: LinkedRepository["role"]; isPrimary?: boolean; transfer?: boolean } = {}, +): Promise => { + const [owner, repository, ...rest] = fullName.split("/"); + if (!owner || !repository || rest.length) throw new Error("GITEA_REPOSITORY_INVALID"); + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectId }, { slug: projectId }] }, select: { id: true } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + const remote = await getGiteaClient(env).getRepository(owner, repository); + const writeRemote = accessMode === "read_write" && env.PROJECTBRAIN_GITEA_WRITE_TOKEN && !remote.permissions?.push && !remote.permissions?.admin + ? await getGiteaIssueClient(env).getRepository(owner, repository) + : remote; + if (accessMode === "read_write" && (!writeRemote.permissions?.push && !writeRemote.permissions?.admin)) throw new Error("GITEA_WRITE_ACCESS_REQUIRED"); + + const [existingCount, existingLink] = await Promise.all([ + prisma.giteaRepositoryLink.count({ where: { projectId: project.id } }), + prisma.giteaRepositoryLink.findUnique({ + where: { owner_repository: { owner, repository } }, + select: { id: true, projectId: true, isPrimary: true, role: true, capabilitiesJson: true, project: { select: { name: true } } }, + }), + ]); + assertExplicitRepositoryTransfer(existingLink, project.id, fullName, options.transfer === true); + const isTransfer = Boolean(existingLink && existingLink.projectId !== project.id); + if (existingLink && isTransfer && options.transfer === true) { + const deliveryRunCount = await prisma.deliveryRun.count({ where: { repositoryLinkId: existingLink.id } }); + assertRepositoryTransferHasNoDeliveryHistory(fullName, deliveryRunCount); + } + const isPrimary = options.isPrimary ?? (existingLink?.projectId === project.id ? existingLink.isPrimary : existingCount === 0); + const role = options.role ?? (isPrimary ? "primary" : existingLink?.projectId === project.id ? existingLink.role : "other"); + const previousCapabilities = jsonObject(existingLink?.capabilitiesJson); + const desiredWebhookEvents = [...PROJECTBRAIN_GITEA_WEBHOOK_EVENTS]; + let webhookConfigured = previousCapabilities.webhookConfigured === true; + let webhookId = typeof previousCapabilities.webhookId === "number" ? previousCapabilities.webhookId : undefined; + let webhookUrl = typeof previousCapabilities.webhookUrl === "string" ? previousCapabilities.webhookUrl : undefined; + let webhookEvents = Array.isArray(previousCapabilities.webhookEvents) + ? previousCapabilities.webhookEvents.filter((event): event is string => typeof event === "string") + : []; + let externalOperationId: string | undefined; + let externalOperationAction: string | undefined; + let remoteMutationReturned = false; + const externalStarted = Date.now(); + + try { + if (env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET && env.PROJECTBRAIN_PUBLIC_URL) { + const client = getGiteaIssueClient(env); + webhookUrl = webhookUrlForEnv(env)!; + const hooks = await client.listRepositoryWebhooks(owner, repository); + const existingHook = hooks.find((hook) => hook.config.url === webhookUrl); + const needsMutation = !existingHook || !existingHook.active || desiredWebhookEvents.some((event) => !existingHook.events?.includes(event)); + if (needsMutation) { + externalOperationAction = existingHook ? "gitea.webhook.update" : "gitea.webhook.create"; + const prepared = await prepareExternalOperation({ + projectId: project.id, + actor: linkedBy, + action: externalOperationAction, + target: { repository: fullName, webhookId: existingHook?.id, webhookUrl, events: desiredWebhookEvents }, + }); + externalOperationId = prepared.operationId; + await markExternalOperationExecuting(externalOperationId); + const hook = existingHook + ? await client.editRepositoryWebhook(owner, repository, existingHook.id, { url: webhookUrl, secret: env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET, events: desiredWebhookEvents }) + : await client.createRepositoryWebhook(owner, repository, { url: webhookUrl, secret: env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET, events: desiredWebhookEvents }); + remoteMutationReturned = true; + webhookId = hook.id; + } else { + webhookId = existingHook.id; + } + webhookConfigured = true; + webhookEvents = desiredWebhookEvents; + } + + const capabilitiesJson = { + ...previousCapabilities, + canPush: Boolean(writeRemote.permissions?.push || writeRemote.permissions?.admin), + actions: previousCapabilities.actions ?? "unknown", + webhookConfigured, + webhookEvents, + ...(webhookId ? { webhookId } : {}), + ...(webhookUrl ? { webhookUrl } : {}), + }; + const link = await prisma.$transaction(async (tx) => { + if (isPrimary) await tx.giteaRepositoryLink.updateMany({ where: { projectId: project.id }, data: { isPrimary: false } }); + const updatedLink = await tx.giteaRepositoryLink.upsert({ + where: { owner_repository: { owner, repository } }, + create: { + projectId: project.id, owner, repository, defaultBranch: remote.default_branch, accessMode, + role, isPrimary, syncStatus: "ready", + capabilitiesJson: capabilitiesJson as never, + linkedBy, lastVerifiedAt: new Date(), lastSyncAt: new Date(), + }, + update: { + projectId: project.id, defaultBranch: remote.default_branch, accessMode, + role, isPrimary, syncStatus: "ready", + capabilitiesJson: capabilitiesJson as never, + linkedBy, lastVerifiedAt: new Date(), lastSyncAt: new Date(), + }, + include: { project: { select: { slug: true, name: true } } }, + }); + if (existingLink?.projectId !== project.id && existingLink?.isPrimary) { + const replacement = await tx.giteaRepositoryLink.findFirst({ where: { projectId: existingLink.projectId }, orderBy: { createdAt: "asc" } }); + if (replacement) { + await tx.giteaRepositoryLink.update({ + where: { id: replacement.id }, + data: { isPrimary: true, role: replacement.role === "other" ? "primary" : replacement.role }, + }); + } + } + if (existingLink && isTransfer) { + await tx.repositoryFinding.updateMany({ where: { repositoryLinkId: existingLink.id }, data: { projectId: project.id } }); + await tx.portfolioRepository.updateMany({ + where: { owner, repository }, + data: { projectId: project.id, status: "onboarded", exclusionReason: null, excludedAt: null, onboardedAt: new Date() }, + }); + } + return updatedLink; + }); + + if (externalOperationId) { + await finishExternalOperation(externalOperationId, { status: "succeeded", result: { repository: fullName, webhookId, webhookUrl } }); + await recordMcpAudit({ + operationId: externalOperationId, projectId: project.id, actor: linkedBy, + toolName: externalOperationAction!, repository: fullName, outcome: "succeeded", durationMs: Date.now() - externalStarted, + metadata: { webhookId, webhookUrl }, + }).catch(() => undefined); + } + return serializeLink(link, remote.html_url); + } catch (error) { + const code = serviceErrorCode(error); + if (externalOperationId) { + await finishExternalOperation(externalOperationId, { status: remoteMutationReturned ? "unknown" : "failed", errorCode: code }).catch(() => undefined); + await recordMcpAudit({ + operationId: externalOperationId, projectId: project.id, actor: linkedBy, + toolName: externalOperationAction ?? "gitea.webhook.align", repository: fullName, + outcome: "failed", errorCode: code, durationMs: Date.now() - externalStarted, + }).catch(() => undefined); + } + throw error; + } +}; + +export const unlinkRepository = async (projectId: string, context: UnlinkContext = {}): Promise => { + const env = context.env ?? loadEnv(); + const actor = context.actor ?? "owner"; + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectId }, { slug: projectId }] }, select: { id: true } }); + if (!project) return false; + const primary = await prisma.giteaRepositoryLink.findFirst({ where: { projectId: project.id }, orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] }); + if (!primary) return false; + return unlinkRepositoryById(project.id, primary.id, { env, actor }); +}; + +export const unlinkRepositoryById = async (projectId: string, repositoryLinkId: string, context: UnlinkContext = {}): Promise => { + const env = context.env ?? loadEnv(); + const actor = context.actor ?? "owner"; + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectId }, { slug: projectId }] }, select: { id: true } }); + if (!project) return false; + const link = await prisma.giteaRepositoryLink.findFirst({ where: { id: repositoryLinkId, projectId: project.id } }); + if (!link) return false; + + const fullName = `${link.owner}/${link.repository}`; + const capabilities = jsonObject(link.capabilitiesJson); + const recordedWebhookId = typeof capabilities.webhookId === "number" ? capabilities.webhookId : undefined; + const recordedWebhookUrl = typeof capabilities.webhookUrl === "string" ? capabilities.webhookUrl : undefined; + const expectedWebhookUrl = recordedWebhookUrl ?? webhookUrlForEnv(env); + let webhookId = recordedWebhookId; + let externalOperationId: string | undefined; + let remoteMutationReturned = false; + const started = Date.now(); + + try { + if (!webhookId && expectedWebhookUrl) { + const hooks = await getGiteaIssueClient(env).listRepositoryWebhooks(link.owner, link.repository); + webhookId = hooks.find((hook) => hook.config.url === expectedWebhookUrl)?.id; + } + if (!webhookId && capabilities.webhookConfigured === true && !expectedWebhookUrl) { + throw new Error("GITEA_WEBHOOK_CLEANUP_UNAVAILABLE"); + } + + if (webhookId) { + const prepared = await prepareExternalOperation({ + projectId: project.id, + actor, + action: "gitea.webhook.delete", + target: { repository: fullName, webhookId, webhookUrl: expectedWebhookUrl }, + }); + externalOperationId = prepared.operationId; + await markExternalOperationExecuting(externalOperationId); + try { + await getGiteaIssueClient(env).deleteRepositoryWebhook(link.owner, link.repository, webhookId); + remoteMutationReturned = true; + } catch (error) { + if (!(error instanceof GiteaApiError && error.status === 404)) throw error; + remoteMutationReturned = true; + } + } + + await prisma.$transaction(async (tx) => { + await tx.giteaRepositoryLink.delete({ where: { id: link.id } }); + if (link.isPrimary) { + const replacement = await tx.giteaRepositoryLink.findFirst({ where: { projectId: project.id }, orderBy: { createdAt: "asc" } }); + if (replacement) await tx.giteaRepositoryLink.update({ where: { id: replacement.id }, data: { isPrimary: true, role: replacement.role === "other" ? "primary" : replacement.role } }); + } + }); + + if (externalOperationId) { + await finishExternalOperation(externalOperationId, { status: "succeeded", result: { repository: fullName, webhookId, webhookUrl: expectedWebhookUrl, linkRemoved: true } }); + await recordMcpAudit({ + operationId: externalOperationId, projectId: project.id, actor, toolName: "gitea.webhook.delete", + repository: fullName, outcome: "succeeded", durationMs: Date.now() - started, metadata: { webhookId, linkRemoved: true }, + }).catch(() => undefined); + } else { + await recordMcpAudit({ projectId: project.id, actor, toolName: "gitea.repository.unlink", repository: fullName, outcome: "succeeded", durationMs: Date.now() - started }).catch(() => undefined); + } + return true; + } catch (error) { + const code = serviceErrorCode(error); + if (externalOperationId) { + await finishExternalOperation(externalOperationId, { status: remoteMutationReturned ? "unknown" : "failed", errorCode: code }).catch(() => undefined); + await recordMcpAudit({ + operationId: externalOperationId, projectId: project.id, actor, toolName: "gitea.webhook.delete", + repository: fullName, outcome: "failed", errorCode: code, durationMs: Date.now() - started, + }).catch(() => undefined); + } else { + await recordMcpAudit({ projectId: project.id, actor, toolName: "gitea.repository.unlink", repository: fullName, outcome: "failed", errorCode: code, durationMs: Date.now() - started }).catch(() => undefined); + } + throw error; + } +}; + +export const recordMcpAudit = async (input: { + operationId?: string; + projectId?: string; actor: string; toolName: string; repository?: string; branch?: string; + outcome: "succeeded" | "failed" | "denied"; errorCode?: string; durationMs: number; metadata?: Record; +}) => prisma.mcpAuditEvent.create({ data: { + operationId: input.operationId ?? randomUUID(), projectId: input.projectId, actor: input.actor, toolName: input.toolName, + repository: input.repository, branch: input.branch, outcome: input.outcome, errorCode: input.errorCode, + durationMs: input.durationMs, metadataJson: input.metadata as never, +} }); + +export const prepareExternalOperation = async (input: { + projectId: string; actor: string; action: string; target: Record; +}) => { + const operationId = randomUUID(); + const operation = await prisma.$transaction(async (tx) => { + const row = await tx.externalOperation.create({ data: { + operationId, projectId: input.projectId, actor: input.actor, action: input.action, + targetJson: input.target as never, + } }); + await tx.outboxEvent.create({ data: { + eventId: randomUUID(), projectId: input.projectId, aggregateType: "external_operation", + aggregateId: row.id, eventType: "external_operation.prepared", + payloadJson: { operationId, actor: input.actor, action: input.action, target: input.target } as never, + } }); + return row; + }); + return operation; +}; + +export const markExternalOperationExecuting = async (operationId: string) => + prisma.externalOperation.update({ where: { operationId }, data: { status: "executing", startedAt: new Date() } }); + +export const finishExternalOperation = async (operationId: string, input: { + status: "succeeded" | "failed" | "unknown"; result?: Record; errorCode?: string; +}) => prisma.$transaction(async (tx) => { + const row = await tx.externalOperation.update({ where: { operationId }, data: { + status: input.status, resultJson: input.result as never, errorCode: input.errorCode, completedAt: new Date(), + } }); + await tx.outboxEvent.create({ data: { + eventId: randomUUID(), projectId: row.projectId, aggregateType: "external_operation", + aggregateId: row.id, eventType: `external_operation.${input.status}`, + payloadJson: { operationId, errorCode: input.errorCode } as never, + } }); + return row; +}); diff --git a/backend/src/integrations/gitea/types.ts b/backend/src/integrations/gitea/types.ts new file mode 100644 index 0000000..c5c4fc2 --- /dev/null +++ b/backend/src/integrations/gitea/types.ts @@ -0,0 +1,140 @@ +export type GiteaRepository = { + id: number; + name: string; + full_name: string; + description?: string; + private: boolean; + archived: boolean; + default_branch: string; + html_url: string; + updated_at: string; + permissions?: { admin?: boolean; push?: boolean; pull?: boolean }; + owner: { login: string }; +}; + +export type CreateGiteaRepositoryInput = { + name: string; + description?: string; + private: boolean; + autoInit?: boolean; + defaultBranch?: string; +}; + +export type GiteaContent = { + type: "file" | "dir" | "symlink" | "submodule"; + name: string; + path: string; + sha: string; + size: number; + encoding?: string; + content?: string; + html_url?: string; + download_url?: string; +}; + +export type GiteaPullRequest = { + id: number; + number: number; + title: string; + state: string; + html_url: string; + merged: boolean; + mergeable?: boolean; + draft?: boolean; + merged_at?: string; + merge_commit_sha?: string; + created_at: string; + updated_at: string; + head: { ref: string; sha: string }; + base: { ref: string; sha: string }; +}; + +export type GiteaBranch = { + name: string; + commit: { id: string; message?: string; timestamp?: string }; + protected: boolean; +}; + +export type GiteaSearchResult = { + name: string; + path: string; + sha: string; + html_url?: string; + repository?: { full_name?: string }; +}; + +export type GiteaActionRun = { + id: number; + run_number: number; + display_title: string; + path: string; + event: string; + head_sha: string; + head_branch?: string; + status: "pending" | "queued" | "in_progress" | "completed" | string; + conclusion?: "success" | "failure" | "cancelled" | "skipped" | string; + html_url: string; + started_at?: string; + completed_at?: string; + pull_requests?: Array<{ number?: number; head?: { ref?: string; sha?: string }; base?: { ref?: string; sha?: string } }>; +}; + +export type GiteaActionRunList = { total_count: number; workflow_runs: GiteaActionRun[] }; + +export type GiteaActionRunner = { + id: number; + name: string; + status: string; + busy: boolean; + disabled: boolean; + ephemeral: boolean; + labels: Array<{ id: number; name: string; type: string }>; +}; + +export type GiteaActionRunnerList = { total_count: number; runners: GiteaActionRunner[] }; + +export type GiteaActionJobStep = { + name: string; + number: number; + status: string; + conclusion?: string; + started_at?: string; + completed_at?: string; +}; + +export type GiteaActionJob = { + id: number; + html_url: string; + run_id: number; + name: string; + labels: string[]; + run_attempt: number; + head_sha: string; + head_branch?: string; + status: string; + conclusion?: string; + runner_id: number; + runner_name?: string; + steps: GiteaActionJobStep[]; + created_at: string; + started_at?: string; + completed_at?: string; +}; + +export type GiteaActionJobList = { total_count: number; jobs: GiteaActionJob[] }; +export type GiteaLabel = { id: number; name: string; color: string; description?: string; url?: string }; +export type GiteaIssue = { + id: number; + number: number; + title: string; + body: string; + state: "open" | "closed" | string; + html_url: string; + labels: GiteaLabel[]; + created_at: string; + updated_at: string; + closed_at?: string; + user?: { login: string }; +}; +export type GiteaIssueComment = { id: number; body: string; html_url?: string; created_at: string; updated_at: string; user?: { login: string } }; +export type GiteaWebhook = { id: number; type: string; active: boolean; events?: string[]; config: { url?: string; content_type?: string } }; diff --git a/backend/src/mcp/analysisWidget.ts b/backend/src/mcp/analysisWidget.ts new file mode 100644 index 0000000..dabad14 --- /dev/null +++ b/backend/src/mcp/analysisWidget.ts @@ -0,0 +1,36 @@ +export const PROJECTBRAIN_ANALYSIS_WIDGET_URI = "ui://projectbrain/analysis-job-v1.html"; +export const PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE = "text/html;profile=mcp-app"; + +export const PROJECTBRAIN_ANALYSIS_WIDGET_HTML = ` + + + + + + + +
+

Repositoryanalyse

Job laden…
—
+
+
Nog geen voortgang ontvangen.
+
0Afgerond
0Bevindingen
0Mislukt
+
ProjectBrain bewaart resultaten buiten deze chat.
+
+
+
+ + +`; diff --git a/backend/src/mcp/autonomyServer.ts b/backend/src/mcp/autonomyServer.ts new file mode 100644 index 0000000..4943e62 --- /dev/null +++ b/backend/src/mcp/autonomyServer.ts @@ -0,0 +1,325 @@ +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, normalizeAutonomyState, reportAutonomyWork, resumeAutonomy, retryAutonomyBlockerIfDue } from "../repositories/autonomyRepository.js"; +import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js"; +import { compactAutonomyState, recordAutonomyExecutorHeartbeat, touchAutonomyExecutorHeartbeat } from "../services/autonomyRuntime.js"; +import { syncGitSources } from "../services/gitSourceSync.js"; +import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js"; +import { reconcileGiteaAutonomyExecution } from "../services/giteaAutonomyExecution.js"; +import { createProjectBrainMcpServer as createBaseProjectBrainMcpServer } from "./server.js"; + +const result = (data: Record, text: string) => ({ + content: [{ type: "text" as const, text }], + structuredContent: data, +}); + +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; + +const autonomyStatuses = ["off", "waiting_roadmap", "running", "blocked", "completed"] as const; + +type AutoModeProjectRow = { + status: string; + name: string; + pending_work_items: number; + active_work_kind?: string; + last_run_at?: string; +}; + +const autoModeQueueRank = (row: AutoModeProjectRow) => + row.status === "running" && !row.active_work_kind ? 0 : row.active_work_kind ? 1 : row.status === "blocked" ? 2 : 3; + +const queueTimestamp = (value?: string) => { + const parsed = value ? Date.parse(value) : 0; + return Number.isFinite(parsed) ? parsed : 0; +}; + +export const compareAutoModeProjectRows = (left: AutoModeProjectRow, right: AutoModeProjectRow) => { + const rankDifference = autoModeQueueRank(left) - autoModeQueueRank(right); + if (rankDifference) return rankDifference; + if (autoModeQueueRank(left) === 0) { + const ageDifference = queueTimestamp(left.last_run_at) - queueTimestamp(right.last_run_at); + if (ageDifference) return ageDifference; + } + return right.pending_work_items - left.pending_work_items || left.name.localeCompare(right.name); +}; + +const reportWithConflictRetry = async ( + projectId: string, + input: Parameters[1], + actor: string, +) => { + try { + return await reportAutonomyWork(projectId, input, actor); + } catch (error) { + if (!(error instanceof Error) || error.message !== "AUTO_MODE_CONFLICT") throw error; + return reportAutonomyWork(projectId, input, actor); + } +}; + +export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => { + const server = createBaseProjectBrainMcpServer(env, actor); + + server.registerTool("list_auto_mode_projects", { + title: "List ProjectBrain Auto Mode projects", + description: "Discover projects that have durable Auto Mode state without scanning every project through separate MCP calls. Intended as the first call for scheduled or newly resumed ChatGPT runs so they can find enabled autonomous roadmaps efficiently.", + inputSchema: { + enabled_only: z.boolean().default(true), + statuses: z.array(z.enum(autonomyStatuses)).max(autonomyStatuses.length).optional(), + max_projects: z.number().int().min(1).max(50).default(20), + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ enabled_only, statuses, max_projects }) => { + const projects = await prisma.project.findMany({ + where: { deletedAt: null }, + orderBy: { updatedAt: "desc" }, + select: { slug: true, readableId: true, name: true, workspaceState: { select: { notesJson: true } } }, + }); + const selectedStatuses = statuses?.length ? new Set(statuses) : undefined; + const rows = projects.flatMap((project) => { + const notes = asRecord(project.workspaceState?.notesJson); + const autonomy = asRecord(notes.autonomy); + if (!Object.keys(autonomy).length) return []; + const state = retryAutonomyBlockerIfDue(normalizeAutonomyState(autonomy)); + const enabled = state.enabled; + const status = state.status; + if (enabled_only && !enabled) return []; + if (selectedStatuses && !selectedStatuses.has(status)) return []; + const roadmap = state.roadmap; + const phases = roadmap?.phases ?? []; + const currentPhaseIndex = roadmap?.currentPhaseIndex ?? 0; + const currentPhase = phases[currentPhaseIndex]; + const activeLease = state.activeLease && Date.parse(state.activeLease.expiresAt) > Date.now() ? state.activeLease : undefined; + const phaseItems = phases.flatMap((phase) => phase.items); + const lastRun = state.runs.reduce<(typeof state.runs)[number] | undefined>((latest, candidate) => + !latest || queueTimestamp(candidate.startedAt) > queueTimestamp(latest.startedAt) ? candidate : latest, undefined); + return [{ + project_id: project.slug, + readable_id: project.readableId ?? undefined, + name: project.name, + enabled, + status, + roadmap_title: roadmap?.title, + current_phase_index: phases.length ? currentPhaseIndex : undefined, + phase_count: phases.length, + current_phase_title: currentPhase?.title, + pending_work_items: phaseItems.filter((item) => item.status === "pending" || item.status === "in_progress").length, + completed_work_items: phaseItems.filter((item) => item.status === "completed").length, + max_work_items_per_run: state.maxWorkItemsPerRun, + max_run_minutes: state.maxRunMinutes, + last_run_at: lastRun?.startedAt, + active_work_kind: activeLease?.kind, + active_run_id: activeLease?.runId, + lease_expires_at: activeLease?.expiresAt, + blocker_reason: state.blocker?.reason, + blocker_kind: state.blocker?.kind, + retry_at: state.blocker?.retryAt, + }]; + }).sort(compareAutoModeProjectRows).slice(0, max_projects); + return result({ projects: rows }, `${rows.length} Auto Mode project(s) matched. Claimable projects are ordered fairly by oldest last run (never-run projects first), so select the first row and keep processing claim_auto_work → execute → report_auto_work until that project's item or time budget is reached, work becomes busy or terminal, or user action is required.`); + }); + + server.registerTool("heartbeat_auto_executor", { + title: "Report ProjectBrain autonomous executor heartbeat", + description: "Report one bounded scheduled executor wake-up so ProjectBrain can distinguish queued autonomous work from a connected executor. Ordinary ChatGPT web tasks may run different projects concurrently; active_project_id selects the isolated ownership slot. Call at the start and end of every scheduled run. Normal claim/report calls refresh the heartbeat automatically; add an explicit heartbeat only immediately before and after an external wait that may exceed five minutes. A different run cannot replace the current owner of the same project inside the server-enforced run window.", + inputSchema: { + instance_id: z.string().trim().min(3).max(120), + run_id: z.string().trim().min(1).max(160).optional(), + status: z.enum(["idle", "working", "degraded"]), + active_project_id: z.string().trim().min(1).max(240).optional(), + detail: z.string().trim().min(1).max(1_000).optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ instance_id, run_id, status, active_project_id, detail }) => { + const recorded = await recordAutonomyExecutorHeartbeat({ + instanceId: instance_id, + ...(run_id ? { runId: run_id } : {}), + status, + ...(active_project_id ? { activeProjectId: active_project_id } : {}), + ...(detail ? { detail } : {}), + }); + const heartbeat = recorded.heartbeat; + return result({ + accepted: recorded.accepted, + ...(recorded.accepted ? {} : { rejection_reason: recorded.reason }), + executor: { + instance_id: heartbeat.instanceId, + run_id: heartbeat.runId ?? undefined, + status: heartbeat.status, + active_project_id: heartbeat.activeProjectId ?? undefined, + detail: heartbeat.detail ?? undefined, + last_seen_at: heartbeat.lastSeenAt.toISOString(), + }, + }, recorded.accepted + ? `Autonomous executor '${heartbeat.instanceId}' is ${heartbeat.status}; heartbeat recorded.` + : `Heartbeat rejected (${recorded.reason}); active run '${heartbeat.runId ?? "unknown"}' retains executor ownership. Do not claim work for the rejected run.`); + }); + + server.registerTool("get_auto_mode_state", { + title: "Get ProjectBrain Auto Mode state", + description: "Read the durable Auto Mode controller for one project and reconcile any newly observed canonical Gitea branch/PR/validation/merge evidence first. The default summary omits the large roadmap Markdown and returns bounded current-phase items and recent events. Request full only for targeted diagnostics that genuinely require the complete durable state.", + inputSchema: { + project_id: z.string().min(1), + view: z.enum(["summary", "full"]).default("summary"), + item_limit: z.number().int().min(1).max(25).default(25), + event_limit: z.number().int().min(0).max(20).default(5), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, view, item_limit, event_limit }) => { + let state = await getAutonomyState(project_id); + try { + state = (await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-read")).state; + } catch { + // Durable ProjectBrain state remains readable during a transient Gitea read failure. + } + const projected = view === "full" ? state : compactAutonomyState(state, { itemLimit: item_limit, eventLimit: event_limit }); + return result({ state: projected as unknown as Record, view }, `Auto Mode is ${state.enabled ? "enabled" : "disabled"}; state=${state.status}${state.roadmap ? `, roadmap='${state.roadmap.title}', phase ${state.roadmap.currentPhaseIndex + 1}/${state.roadmap.phases.length}` : ""}; view=${view}.`); + }); + + server.registerTool("configure_auto_mode", { + title: "Configure ProjectBrain Auto Mode", + description: "Enable or disable autonomous roadmap execution for one project and configure the bounded brainstorm/failure/lease policy. When a linked repository exposes ROADMAP.md or a supported structured backlog, enabling Auto Mode synchronizes and bootstraps that repository plan automatically; explicit manually imported plans are preserved.", + inputSchema: { + project_id: z.string().min(1), + profile: z.enum(["conservative", "normal", "autonomous", "custom"]).optional(), + enabled: z.boolean().optional(), + auto_brainstorm: z.boolean().optional(), + max_brainstorm_iterations: z.number().int().min(1).max(10).optional(), + max_consecutive_failures: z.number().int().min(1).max(10).optional(), + lease_minutes: z.number().int().min(5).max(120).optional(), + max_work_items_per_run: z.number().int().min(1).max(20).optional(), + max_run_minutes: z.number().int().min(5).max(55).optional(), + max_recovery_minutes: z.number().int().min(15).max(1_440).optional(), + pause_after_current: z.boolean().optional(), + scheduling_policy: z.enum(["balanced", "downstream-impact"]).optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, profile, enabled, auto_brainstorm, max_brainstorm_iterations, max_consecutive_failures, lease_minutes, max_work_items_per_run, max_run_minutes, max_recovery_minutes, pause_after_current, scheduling_policy }) => { + let state = await configureAutonomy(project_id, { + ...(profile ? { profile } : {}), + ...(enabled !== undefined ? { enabled } : {}), + ...(auto_brainstorm !== undefined ? { autoBrainstorm: auto_brainstorm } : {}), + ...(max_brainstorm_iterations !== undefined ? { maxBrainstormIterations: max_brainstorm_iterations } : {}), + ...(max_consecutive_failures !== undefined ? { maxConsecutiveFailures: max_consecutive_failures } : {}), + ...(lease_minutes !== undefined ? { leaseMinutes: lease_minutes } : {}), + ...(max_work_items_per_run !== undefined ? { maxWorkItemsPerRun: max_work_items_per_run } : {}), + ...(max_run_minutes !== undefined ? { maxRunMinutes: max_run_minutes } : {}), + ...(max_recovery_minutes !== undefined ? { maxRecoveryMinutes: max_recovery_minutes } : {}), + ...(pause_after_current !== undefined ? { pauseAfterCurrent: pause_after_current } : {}), + ...(scheduling_policy ? { schedulingPolicy: scheduling_policy } : {}), + }, actor); + let repositorySyncWarning: string | undefined; + if (state.enabled) { + try { + const sourceSync = await syncGitSources(env, { projectId: project_id }); + if (sourceSync.errors.length) repositorySyncWarning = sourceSync.errors.map((entry) => `${entry.repository}: ${entry.code}`).join("; "); + } catch (error) { + repositorySyncWarning = error instanceof Error ? error.message : "REPOSITORY_SOURCE_SYNC_FAILED"; + } + const repositoryPlan = await reconcileRepositoryAutonomyRoadmap(project_id, actor); + state = repositoryPlan.state; + } + return result({ + state: state as unknown as Record, + ...(repositorySyncWarning ? { repository_sync_warning: repositorySyncWarning } : {}), + }, `Auto Mode configuration saved; enabled=${state.enabled}, state=${state.status}.${repositorySyncWarning ? ` Repository source refresh warning: ${repositorySyncWarning}` : ""}`); + }); + + server.registerTool("import_auto_roadmap", { + title: "Import ProjectBrain autonomous roadmap", + description: "Import a Markdown roadmap into ProjectBrain Auto Mode. H2+ headings become ordered phases and checkbox, bullet or numbered lines become work items. Import replaces the active autonomous roadmap but preserves Auto Mode policy settings.", + inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(2).max(200).optional(), markdown: z.string().trim().min(3).max(100_000) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, title, markdown }) => { + const state = await importAutonomyRoadmap(project_id, { title, markdown }, actor); + const roadmap = state.roadmap!; + return result({ state: state as unknown as Record }, `Roadmap '${roadmap.title}' imported with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`); + }); + + server.registerTool("resume_auto_mode", { + title: "Resume blocked ProjectBrain Auto Mode", + description: "Clear the current Auto Mode blocker and make its blocked roadmap phase/item claimable again. The roadmap, completed phases and completed work are preserved; only blocker/retry state for the blocked work is reset.", + inputSchema: { project_id: z.string().min(1) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id }) => { + const state = await resumeAutonomy(project_id, actor); + return result({ state: state as unknown as Record }, `Auto Mode resume applied; enabled=${state.enabled}, state=${state.status}.`); + }); + + server.registerTool("claim_auto_work", { + title: "Claim next ProjectBrain autonomous work", + description: "Backward-compatible controller claim for one Auto work item. Implementation work returns a canonical Gitea execution target whose deterministic branch is the atomic execution claim. Heartbeat and lease ownership only coordinate legacy controller runs; repository correctness is reconciled from Gitea branch, pull-request, exact-head validation and merge evidence.", + inputSchema: { + project_id: z.string().min(1), + run_id: z.string().trim().min(1).max(160), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, run_id }) => { + const ownsExecutor = await touchAutonomyExecutorHeartbeat(run_id, project_id); + if (!ownsExecutor) { + const state = await getAutonomyState(project_id); + return result({ state: state as unknown as Record, work: undefined, busy: true, heartbeat_required: true }, "This run does not own an accepted working executor heartbeat. Do not claim or execute work; another run may be active, or heartbeat_auto_executor must be called first."); + } + let repositorySyncWarning: string | undefined; + const beforeClaimState = await getAutonomyState(project_id); + if (!beforeClaimState.runs.some((run) => run.id === run_id)) { + try { + const sourceSync = await syncGitSources(env, { projectId: project_id }); + if (sourceSync.errors.length) repositorySyncWarning = sourceSync.errors.map((entry) => `${entry.repository}: ${entry.code}`).join("; "); + } catch (error) { + repositorySyncWarning = error instanceof Error ? error.message : "REPOSITORY_SOURCE_SYNC_FAILED"; + } + } + try { + try { + await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-claim"); + } catch { + // Keep the compatibility claim path available during transient Gitea read failures. + } + const claim = await claimAutonomyWork(project_id, actor, run_id); + const work = claim.work; + return result({ ...claim, state: compactAutonomyState(claim.state, { itemLimit: 5, eventLimit: 2 }), ...(repositorySyncWarning ? { repository_sync_warning: repositorySyncWarning } : {}) } as unknown as Record, (work ? `${claim.resumed ? "Resumed" : "Claimed"} ${work.kind}: ${work.itemTitle ?? work.phaseTitle}. Execute the returned prompt, report with token ${work.token}, and continue within the run budget.` : claim.budgetReached ? `This run reached its server-enforced ${claim.budgetReason === "item_limit" ? "item" : "time"} budget; stop cleanly and leave the recurring task enabled.` : claim.busy ? `Autonomous work is already leased by another executor run; do not duplicate it. Controller state=${claim.state.status}.` : `No autonomous work is currently claimable; controller state=${claim.state.status}.`) + (repositorySyncWarning ? ` Repository source refresh warning: ${repositorySyncWarning}` : "")); + } catch (error) { + if (!(error instanceof Error) || error.message !== "AUTO_MODE_CONFLICT") throw error; + const claim = await claimAutonomyWork(project_id, actor, run_id); + const work = claim.work; + return result({ ...claim, state: compactAutonomyState(claim.state, { itemLimit: 5, eventLimit: 2 }) } as unknown as Record, work ? `Concurrent claim resolved by resuming this run's durable ${work.kind} lease for ${work.itemTitle ?? work.phaseTitle}. Report with token ${work.token} and continue within the run budget.` : `Concurrent claim resolved without duplicating another run; controller state=${claim.state.status}.`); + } + }); + + server.registerTool("report_auto_work", { + title: "Report ProjectBrain autonomous work", + description: "Backward-compatible bookkeeping for a claimed lease. Canonical implementation truth comes from the deterministic Gitea branch, PR, exact-head Actions validation and merge; ProjectBrain reconciles that evidence even when this callback is never sent. delivery_id remains supported for legacy feature deliveries. Use discovered_tasks for repair prerequisites and reserve human blockers for actual owner decisions.", + inputSchema: { + project_id: z.string().min(1), token: z.string().uuid(), outcome: z.enum(["success", "failed", "blocked"]), summary: z.string().trim().min(3).max(12_000), + blocker_type: z.enum(["human", "retryable"]).optional(), + retry_after_minutes: z.number().int().min(5).max(1_440).optional(), + delivery_id: z.string().trim().min(1).max(240).optional(), + discovered_tasks: z.array(z.string().trim().min(2).max(1_000)).max(100).default([]), + brainstorm: z.object({ + context_summary: z.string().trim().min(3).max(8_000).optional(), + ideas: z.array(z.object({ id: z.string().min(1).max(160), title: z.string().min(1).max(300), description: z.string().min(1).max(4_000), score: z.number().min(0).max(100).optional() })).max(50).default([]), + outcomes: z.array(z.string().min(1).max(2_000)).max(50).default([]), + selected_idea_ids: z.array(z.string().min(1).max(160)).max(50).default([]), + next_actions: z.array(z.string().trim().min(2).max(1_000)).max(100).default([]), + }).optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, token, outcome, summary, blocker_type, retry_after_minutes, delivery_id, discovered_tasks, brainstorm }) => { + const reportInput = { + token, outcome, summary, ...(blocker_type ? { blockerType: blocker_type } : {}), ...(retry_after_minutes ? { retryAfterMinutes: retry_after_minutes } : {}), ...(delivery_id ? { deliveryId: delivery_id } : {}), discoveredTasks: discovered_tasks, + ...(brainstorm ? { brainstorm: { ...(brainstorm.context_summary ? { contextSummary: brainstorm.context_summary } : {}), ideas: brainstorm.ideas, outcomes: brainstorm.outcomes, selectedIdeaIds: brainstorm.selected_idea_ids, nextActions: brainstorm.next_actions } } : {}), + } satisfies Parameters[1]; + try { + await reconcileGiteaAutonomyExecution(env, project_id, "system:mcp-auto-report"); + } catch { + // A compatibility report can still be recorded if Gitea is temporarily unavailable. + } + const report = await reportWithConflictRetry(project_id, reportInput, actor); + if (report.runId) await touchAutonomyExecutorHeartbeat(report.runId, project_id); + return result({ ...report, state: compactAutonomyState(report.state, { itemLimit: 5, eventLimit: 2 }) } as unknown as Record, `${report.replayed ? "Replayed" : "Recorded"} autonomous work as ${outcome}; ${report.addedTasks.length} new task(s) added. Controller state=${report.state.status}.`); + }); + + return server; +}; diff --git a/backend/src/mcp/featureChangeset.ts b/backend/src/mcp/featureChangeset.ts new file mode 100644 index 0000000..1a19b0f --- /dev/null +++ b/backend/src/mcp/featureChangeset.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { assertFileSize, assertWritablePath } from "../integrations/gitea/policy.js"; + +export const featureChangesetOperationSchema = z.discriminatedUnion("operation", [ + z.object({ operation: z.literal("create"), path: z.string().min(1), content: z.string(), expected_sha: z.undefined().optional() }), + z.object({ operation: z.literal("update"), path: z.string().min(1), content: z.string(), expected_sha: z.string().min(1).max(200) }), + z.object({ operation: z.literal("delete"), path: z.string().min(1), expected_sha: z.string().min(1).max(200) }), +]); + +export type FeatureChangesetOperation = z.infer; + +export const assertFeatureChangesetBudget = (operations: FeatureChangesetOperation[]) => { + const paths = operations.map((operation) => assertWritablePath(operation.path)); + if (new Set(paths).size !== paths.length) throw new Error("FEATURE_CHANGESET_DUPLICATE_PATH"); + const bytes = operations.reduce((total, operation) => total + ("content" in operation ? Buffer.byteLength(operation.content, "utf8") : 0), 0); + if (bytes > 1024 * 1024) throw new Error("FEATURE_CHANGESET_TOO_LARGE"); + for (const operation of operations) if ("content" in operation) assertFileSize(operation.content); + return paths; +}; + +export const mapBounded = async (items: T[], limit: number, operation: (item: T, index: number) => Promise) => { + const output: R[] = []; + for (let index = 0; index < items.length; index += limit) { + output.push(...await Promise.all(items.slice(index, index + limit).map((item, offset) => operation(item, index + offset)))); + } + return output; +}; diff --git a/backend/src/mcp/portfolioTools.ts b/backend/src/mcp/portfolioTools.ts new file mode 100644 index 0000000..ded8c40 --- /dev/null +++ b/backend/src/mcp/portfolioTools.ts @@ -0,0 +1,239 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { assertReadablePath } from "../integrations/gitea/policy.js"; +import { + listPortfolioRepositories, + parsePortfolioFetchId, + portfolioFetchId, + type PortfolioRepository, +} from "../integrations/gitea/portfolio.js"; +import { getGiteaClient, recordMcpAudit } from "../integrations/gitea/service.js"; +import type { GiteaContent } from "../integrations/gitea/types.js"; + +type ToolResult = { + content: Array<{ type: "text"; text: string }>; + structuredContent?: Record; + isError?: boolean; +}; + +const asResult = (data: unknown, message = "Operation completed"): ToolResult => ({ + content: [{ type: "text", text: `${message}\n${JSON.stringify(data, null, 2)}` }], + structuredContent: + data && typeof data === "object" && !Array.isArray(data) + ? (data as Record) + : { data }, +}); + +const asKnowledgeResult = (data: Record): ToolResult => ({ + content: [{ type: "text", text: JSON.stringify(data) }], +}); + +const decodeFile = (content: GiteaContent) => { + if ( + content.type !== "file" || + content.encoding !== "base64" || + typeof content.content !== "string" + ) { + throw new Error("GITEA_CONTENT_NOT_FILE"); + } + return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8"); +}; + +const mapBounded = async ( + items: T[], + limit: number, + operation: (item: T) => Promise, +): Promise => { + const output: R[] = []; + for (let index = 0; index < items.length; index += limit) { + output.push(...(await Promise.all(items.slice(index, index + limit).map(operation)))); + } + return output; +}; + +const repositoryKey = (repository: Pick) => + `${repository.owner}/${repository.repository}`.toLowerCase(); + +const errorCode = (error: unknown) => + error instanceof Error ? error.message : "MCP_PORTFOLIO_OPERATION_FAILED"; + +const auditedRead = async ( + actor: string, + toolName: string, + operation: () => Promise, + context: { repository?: string; branch?: string } = {}, +): Promise => { + const started = Date.now(); + try { + const result = await operation(); + await recordMcpAudit({ + actor, + toolName, + repository: context.repository, + branch: context.branch, + outcome: "succeeded", + durationMs: Date.now() - started, + metadata: { projection: "gitea-portfolio", readOnly: true }, + }).catch(() => undefined); + return result; + } catch (error) { + const code = errorCode(error); + await recordMcpAudit({ + actor, + toolName, + repository: context.repository, + branch: context.branch, + outcome: "failed", + errorCode: code, + durationMs: Date.now() - started, + metadata: { projection: "gitea-portfolio", readOnly: true }, + }).catch(() => undefined); + return { + content: [{ type: "text", text: `Operation failed: ${code}` }], + structuredContent: { error: code }, + isError: true, + }; + } +}; + +/** + * Register cross-repository search and fetch tools without changing the + * existing portfolio catalog or the GiteaRepositoryLink ownership/write + * boundary used by ProjectBrain delivery. + */ +export const registerPortfolioMcpTools = ( + server: McpServer, + env: AppEnv, + actor: string, +) => { + const client = getGiteaClient(env); + + server.registerTool( + "search_portfolio_repositories", + { + title: "Search Gitea portfolio repositories", + description: + "Search code across every repository visible to ProjectBrain's scoped Gitea identity. Returned gitea-repository:// ids can be read with fetch_portfolio_file without creating ProjectBrain ownership links.", + inputSchema: { + query: z.string().trim().min(2).max(200).describe("Plain-text code search query"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ query }) => + auditedRead(actor, "search_portfolio_repositories", async () => { + const repositories = await listPortfolioRepositories(env); + const searched = repositories.filter((repository) => !repository.archived); + const batches = await mapBounded(searched, 6, async (repository) => { + try { + const response = await client.searchCode( + repository.owner, + repository.repository, + query, + repository.defaultBranch, + ); + const rows = Array.isArray(response) ? response : response.data ?? []; + return { + results: rows.map((row) => ({ + id: portfolioFetchId(repository, repository.defaultBranch, row.path), + title: `${repository.fullName}: ${row.path}`, + url: row.html_url, + metadata: { + repository: repository.fullName, + ref: repository.defaultBranch, + path: row.path, + linked: repository.linked, + projectId: repository.link?.projectSlug, + }, + })), + }; + } catch { + return { results: [], failedRepository: repository.fullName }; + } + }); + const allResults = batches.flatMap((batch) => batch.results); + const failedRepositories = batches + .map((batch) => batch.failedRepository) + .filter((value): value is string => typeof value === "string"); + return asKnowledgeResult({ + results: allResults.slice(0, 50), + metadata: { + repositoryCount: repositories.length, + searchedRepositoryCount: searched.length, + failedRepositories, + truncated: allResults.length > 50, + }, + }); + }), + ); + + server.registerTool( + "fetch_portfolio_file", + { + title: "Fetch Gitea portfolio file", + description: + "Read one text file by the canonical gitea-repository:// id returned by search_portfolio_repositories. The repository must still be visible to ProjectBrain's scoped Gitea identity and sensitive paths remain blocked.", + inputSchema: { + id: z + .string() + .startsWith("gitea-repository://") + .max(2_000) + .describe("Canonical gitea-repository://owner/repository/ref/path identifier"), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ id }) => { + let parsed: ReturnType; + try { + parsed = parsePortfolioFetchId(id); + } catch (error) { + return asResult({ error: errorCode(error) }, "Invalid portfolio fetch id"); + } + + return auditedRead( + actor, + "fetch_portfolio_file", + async () => { + const repositories = await listPortfolioRepositories(env); + const visible = new Map(repositories.map((repository) => [repositoryKey(repository), repository])); + const repository = visible.get(`${parsed.owner}/${parsed.repository}`.toLowerCase()); + if (!repository) throw new Error("GITEA_REPOSITORY_NOT_VISIBLE"); + const path = assertReadablePath(parsed.path); + const file = await client.getContents( + repository.owner, + repository.repository, + path, + parsed.ref, + ); + if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE"); + return asKnowledgeResult({ + id, + title: `${repository.fullName}: ${path}`, + text: decodeFile(file), + url: file.html_url, + metadata: { + repository: repository.fullName, + path, + ref: parsed.ref, + sha: file.sha, + size: file.size, + linked: repository.linked, + projectId: repository.link?.projectSlug, + }, + }); + }, + { repository: `${parsed.owner}/${parsed.repository}`, branch: parsed.ref }, + ); + }, + ); +}; diff --git a/backend/src/mcp/resultHelpers.ts b/backend/src/mcp/resultHelpers.ts new file mode 100644 index 0000000..922ee3c --- /dev/null +++ b/backend/src/mcp/resultHelpers.ts @@ -0,0 +1,60 @@ +import { GiteaApiError } from "../integrations/gitea/client.js"; +import type { GiteaContent } from "../integrations/gitea/types.js"; + +export type ToolResult = { content: Array<{ type: "text"; text: string }>; structuredContent?: Record; isError?: boolean }; + +export const MAX_MCP_READ_BYTES = 512 * 1024; + +export const asResult = (data: unknown, message = "Operation completed"): ToolResult => ({ content: [{ type: "text", text: message }], structuredContent: data && typeof data === "object" && !Array.isArray(data) ? data as Record : { data } }); +export const asStandardKnowledgeResult = (data: Record): ToolResult => ({ content: [{ type: "text", text: JSON.stringify(data) }] }); +export const asCompactResult = (data: Record, message: string): ToolResult => ({ content: [{ type: "text", text: message }], structuredContent: data }); +export const errorCode = (error: unknown) => error instanceof GiteaApiError ? error.code : error instanceof Error ? error.message : "MCP_OPERATION_FAILED"; + +export const operationRecovery = (code: string) => { + if (/CHECKS_PENDING/.test(code)) return { action: "wait_and_resume", retryable: true, detail: "Required checks are still queued/running. Keep the same delivery, branch and PR; inspect status after the indicated wait and retry the merge with the current head." }; + if (/CI_|CHECKS_FAILED|VALIDATION_FAILED/.test(code)) return { action: "repair_and_revalidate", retryable: true, detail: "Read exact-head validation runs and the selected failed-job evidence. Fix the cause on the existing delivery branch. For canceled/transient runs, rerun the affected workflow. Sync successful evidence, then retry this PR's merge; never create a replacement branch or weaken checks." }; + if (/HEAD_CONFLICT|FILE_CONFLICT|AUTO_MODE_CONFLICT/.test(code)) return { action: "reload_current_state", retryable: true, detail: "Reload the existing delivery, branch head and affected file SHAs. Reconcile actual progress before retrying; do not replay stale writes." }; + if (/MERGE_UNCONFIRMED|PULL_REQUEST_NOT_READY/.test(code)) return { action: "reconcile_existing_pr", retryable: true, detail: "Read the existing PR and retry merge_feature_delivery with its expected head. An already merged PR is reconciled without another merge." }; + return { action: "inspect_existing_delivery", retryable: false, detail: "Read the existing delivery and repository state before acting. Do not create another branch to work around a tool error." }; +}; + +export const pageCollection = (items: T[], cursor: number, pageSize: number) => ({ items: items.slice(cursor, cursor + pageSize), total: items.length, nextCursor: cursor + pageSize < items.length ? String(cursor + pageSize) : undefined }); + +export const compactRecord = (value: unknown): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) return { value }; + const item = value as Record; + const spec = item.spec && typeof item.spec === "object" && !Array.isArray(item.spec) ? item.spec as Record : undefined; + const summaryCandidate = [item.summary, item.contextSummary, item.rule, item.intent, item.content] + .find((candidate): candidate is string => typeof candidate === "string" && candidate.length > 0); + return Object.fromEntries(Object.entries({ + id: item.id, + projectId: item.projectId, + title: item.title, + status: item.status ?? spec?.status, + approvalState: item.approvalState, + kind: item.kind, + category: item.category, + scope: item.scope, + severity: item.severity, + confidence: item.confidence, + priority: item.priority, + branch: spec?.branch, + pullRequest: spec?.pullRequest, + summary: summaryCandidate ? `${summaryCandidate.slice(0, 600)}${summaryCandidate.length > 600 ? "…" : ""}` : undefined, + updatedAt: item.updatedAt, + }).filter(([, field]) => field !== undefined)); +}; + +export const compactDelivery = (value: unknown) => compactRecord(value); + +export const decodeFile = (content: GiteaContent) => { + if (content.type !== "file" || content.encoding !== "base64" || typeof content.content !== "string") throw new Error("GITEA_CONTENT_NOT_FILE"); + if (content.size > MAX_MCP_READ_BYTES) throw new Error("MCP_FILE_READ_TOO_LARGE"); + return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8"); +}; + +export const parseFetchId = (id: string) => { + const match = id.match(/^gitea:\/\/([^/]+)\/([^/]+)\/(.+)$/); + if (!match) throw new Error("MCP_FETCH_ID_INVALID"); + return { projectId: decodeURIComponent(match[1]), ref: decodeURIComponent(match[2]), path: decodeURIComponent(match[3]) }; +}; diff --git a/backend/src/mcp/scopeGuards.ts b/backend/src/mcp/scopeGuards.ts new file mode 100644 index 0000000..f0ce697 --- /dev/null +++ b/backend/src/mcp/scopeGuards.ts @@ -0,0 +1,23 @@ +import { getLinkedRepository, listProjectRepositories, type LinkedRepository } from "../integrations/gitea/service.js"; +import { getFindingInvestigation, getRepositoryFinding } from "../repositories/findingRepository.js"; + +export class PartialExternalMutationError extends Error {} + +export const requireLink = async (projectId: string, write = false, repositoryId?: string): Promise => { + const link = repositoryId ? (await listProjectRepositories(projectId)).find((item) => item.id === repositoryId) : await getLinkedRepository(projectId); + if (!link) throw new Error("GITEA_LINK_NOT_FOUND"); + if (write && link.accessMode !== "read_write") throw new Error("GITEA_REPOSITORY_READ_ONLY"); + return link; +}; + +export const assertFindingProject = async (projectId: string, findingId: string) => { + const [link, finding] = await Promise.all([requireLink(projectId), getRepositoryFinding(findingId)]); + if (finding.projectId !== link.projectId) throw new Error("FINDING_PROJECT_SCOPE_MISMATCH"); + return finding; +}; + +export const assertInvestigationProject = async (projectId: string, investigationId: string) => { + const [link, investigation] = await Promise.all([requireLink(projectId), getFindingInvestigation(investigationId)]); + if (investigation.projectId !== link.projectId) throw new Error("FINDING_PROJECT_SCOPE_MISMATCH"); + return investigation; +}; diff --git a/backend/src/mcp/server.ts b/backend/src/mcp/server.ts new file mode 100644 index 0000000..ba30a0c --- /dev/null +++ b/backend/src/mcp/server.ts @@ -0,0 +1,799 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { GiteaApiError } from "../integrations/gitea/client.js"; +import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, exactHeadActionRuns, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js"; +import { finishExternalOperation, getGiteaClient, listLinkedRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, type LinkedRepository } from "../integrations/gitea/service.js"; +import type { GiteaContent } from "../integrations/gitea/types.js"; +import { assertFeatureDeliveryActionEvidence, canSyncFeatureDeliveryValidation, getFeatureDelivery, listFeatureDeliveries, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js"; +import { createBrainstormForProject, listBrainstormsForProject } from "../repositories/brainstormRepository.js"; +import { createContextPackForProject, listContextPacksForProject } from "../repositories/contextPacksRepository.js"; +import { createDocumentationItem, createKnowledgeItem, createMemoryRuleItem, listDocumentationForProject, listKnowledgeForProject, listMemoryRulesForProject } from "../repositories/p8KnowledgeMemoryRepository.js"; +import { listProjects } from "../repositories/projectsRepository.js"; +import { listReviewItemsFromDatabase } from "../repositories/reviewQueueRepository.js"; +import { importCodexOutputInDatabase } from "../repositories/p7RuntimeRepository.js"; +import { PROJECTBRAIN_WORKSPACE_HTML, PROJECTBRAIN_WORKSPACE_MIME_TYPE, PROJECTBRAIN_WORKSPACE_URI } from "./widget.js"; +import { resolveProjectContext } from "../services/contextResolver.js"; +import { cancelAnalysisJob, createPortfolioAnalysisJob, getAnalysisJob, getAnalysisJobResults, listAnalysisJobs } from "../repositories/analysisJobRepository.js"; +import { PROJECTBRAIN_ANALYSIS_WIDGET_HTML, PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE, PROJECTBRAIN_ANALYSIS_WIDGET_URI } from "./analysisWidget.js"; +import { createDeliveryFromFindingInvestigation, getFindingInvestigation, getRepositoryFinding, listRepositoryFindings, queueFindingVerificationForDelivery, saveFindingInvestigationConclusion, startFindingInvestigation, suppressRepositoryFinding, syncFindingIssue } from "../repositories/findingRepository.js"; +import { excludePortfolioRepository, getPortfolioControlPlane, listPortfolioRepositories, reconcilePortfolioRepositories, restorePortfolioRepository } from "../services/portfolioControlPlane.js"; +import { assertFeatureChangesetBudget, featureChangesetOperationSchema, mapBounded } from "./featureChangeset.js"; +import { asCompactResult, asResult, asStandardKnowledgeResult, compactDelivery, compactRecord, decodeFile, errorCode, operationRecovery, pageCollection, parseFetchId, type ToolResult } from "./resultHelpers.js"; +import { assertFindingProject, assertInvestigationProject, PartialExternalMutationError, requireLink } from "./scopeGuards.js"; +import { assertDeliveryWriteAllowed, cleanupDeliveryBranch, deliveryContinuation } from "../services/featureDeliveryFlow.js"; +import { createOrResumeAutonomyDelivery } from "../services/autonomyDelivery.js"; +import { refreshDeliveryFromGitea, resolveReconciledProjectContext } from "../services/deliveryReconciliation.js"; + +const withAudit = async (input: { actor: string; tool: string; projectId?: string; branch?: string; repositoryId?: string; deliveryId?: string }, operation: (link?: LinkedRepository) => Promise, write = false): Promise => { + const started = Date.now(); + let link: LinkedRepository | undefined; + let externalOperationId: string | undefined; + let externalMutationReturned = false; + let externalOperationFinished = false; + try { + const deliveryRepositoryId = input.projectId && input.deliveryId ? (await getFeatureDelivery(input.projectId, input.deliveryId)).spec.repositoryId : undefined; + link = input.projectId ? await requireLink(input.projectId, write, input.repositoryId ?? deliveryRepositoryId) : undefined; + if (write && link) { + const prepared = await prepareExternalOperation({ + projectId: link.projectId, actor: input.actor, action: input.tool, + target: { repository: link.fullName, branch: input.branch }, + }); + externalOperationId = prepared.operationId; + await markExternalOperationExecuting(externalOperationId); + } + const result = await operation(link); + externalMutationReturned = write; + if (externalOperationId) { + await finishExternalOperation(externalOperationId, { status: "succeeded" }); + externalOperationFinished = true; + } + const audit = recordMcpAudit({ operationId: externalOperationId, projectId: link?.projectId, actor: input.actor, toolName: input.tool, repository: link?.fullName, branch: input.branch, outcome: "succeeded", durationMs: Date.now() - started }); + if (write) await audit; else await audit.catch(() => undefined); + return result; + } catch (error) { + if (error instanceof PartialExternalMutationError) externalMutationReturned = true; + const code = errorCode(error); + if (externalOperationId && !externalOperationFinished) await finishExternalOperation(externalOperationId, { status: externalMutationReturned ? "unknown" : "failed", errorCode: code }).catch(() => undefined); + await recordMcpAudit({ operationId: externalOperationId, projectId: link?.projectId, actor: input.actor, toolName: input.tool, repository: link?.fullName, branch: input.branch, outcome: ["GITEA_REPOSITORY_READ_ONLY", "MCP_BRANCH_FORBIDDEN", "DEFAULT_BRANCH_WRITE_FORBIDDEN", "SENSITIVE_PATH_FORBIDDEN", "REPOSITORY_PATH_FORBIDDEN"].includes(code) ? "denied" : "failed", errorCode: code, durationMs: Date.now() - started }).catch(() => undefined); + return { content: [{ type: "text", text: `Operation failed: ${code}` }], structuredContent: { error: code, recovery: operationRecovery(code) }, isError: true }; + } +}; + +export const createProjectBrainMcpServer = (env: AppEnv, actor: string) => { + const server = new McpServer({ name: "ProjectBrain", version: "0.7.1", websiteUrl: env.PROJECTBRAIN_PUBLIC_URL }); + const client = getGiteaClient(env); + + server.registerResource("ProjectBrain workspace", PROJECTBRAIN_WORKSPACE_URI, { + title: "ProjectBrain workspace", + description: "Interactive project-memory, review, and feature-delivery workspace.", + mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE, + _meta: { + ui: { + prefersBorder: true, + domain: env.PROJECTBRAIN_PUBLIC_URL, + csp: { connectDomains: [], resourceDomains: [] }, + }, + "openai/widgetDescription": "Shows durable ProjectBrain context, pending reviews, and active feature deliveries.", + }, + }, async () => ({ contents: [{ + uri: PROJECTBRAIN_WORKSPACE_URI, + mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE, + text: PROJECTBRAIN_WORKSPACE_HTML, + _meta: { + ui: { + prefersBorder: true, + domain: env.PROJECTBRAIN_PUBLIC_URL, + csp: { connectDomains: [], resourceDomains: [] }, + }, + "openai/widgetDescription": "Shows durable ProjectBrain context, pending reviews, and active feature deliveries.", + }, + }] })); + + server.registerResource("ProjectBrain repository analysis", PROJECTBRAIN_ANALYSIS_WIDGET_URI, { + title: "ProjectBrain repository analysis", + description: "Compact progress and result summary for a durable repository-analysis job.", + mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE, + _meta: { + ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } }, + "openai/widgetDescription": "Shows bounded progress for a server-side ProjectBrain repository analysis.", + }, + }, async () => ({ contents: [{ + uri: PROJECTBRAIN_ANALYSIS_WIDGET_URI, + mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE, + text: PROJECTBRAIN_ANALYSIS_WIDGET_HTML, + _meta: { + ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } }, + "openai/widgetDescription": "Shows bounded progress for a server-side ProjectBrain repository analysis.", + }, + }] })); + + server.registerTool("list_projects", { + title: "List ProjectBrain projects", description: "Use this to choose a ProjectBrain project. Results are paginated so the complete portfolio is never returned to the browser at once.", inputSchema: { cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ cursor, page_size }) => withAudit({ actor, tool: "list_projects" }, async () => { + const page = pageCollection(await listProjects(), Number.parseInt(cursor, 10), page_size); + return asCompactResult({ projects: page.items, total: page.total, nextCursor: page.nextCursor }, `${page.items.length} project(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`); + })); + + server.registerTool("list_project_repositories", { + title: "List ProjectBrain repositories", description: "Use this to find a linked Gitea repository and access mode. Results are paginated for browser-safe portfolio growth.", inputSchema: { cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ cursor, page_size }) => withAudit({ actor, tool: "list_project_repositories" }, async () => { + const page = pageCollection(await listLinkedRepositories(), Number.parseInt(cursor, 10), page_size); + return asCompactResult({ repositories: page.items, total: page.total, nextCursor: page.nextCursor }, `${page.items.length} repositorykoppeling(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`); + })); + + server.registerTool("get_portfolio_control_plane", { + title: "Get ProjectBrain portfolio control plane", + description: "Use this first whenever the user asks what needs attention across projects. It returns one recommended next action, a short actionable queue, live notifications, repository/project coverage and compact analysis state without loading repository contents.", + inputSchema: {}, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, async () => withAudit({ actor, tool: "get_portfolio_control_plane" }, async () => { + const { repositories: _repositories, projects: _projects, ...summary } = await getPortfolioControlPlane(env); + return asCompactResult(summary as unknown as Record, `Portfolio geladen: volgende actie is '${summary.nextAction.label}'. ${summary.coverage.onboardedRepositories}/${summary.coverage.visibleRepositories} zichtbare repositories zijn gekoppeld; ${summary.findings.active} actieve finding(s).`); + })); + + server.registerTool("list_portfolio_repositories", { + title: "List ProjectBrain portfolio repositories", + description: "List one bounded page of Gitea repositories and their durable ProjectBrain onboarding status. Use get_portfolio_control_plane first and paginate only when repository-level detail is needed.", + inputSchema: { status: z.enum(["discovered", "onboarded", "excluded", "unavailable"]).optional(), cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, async ({ status, cursor, page_size }) => withAudit({ actor, tool: "list_portfolio_repositories" }, async () => { + const repositories = (await listPortfolioRepositories(env)).filter((repository) => !status || repository.status === status); + const page = pageCollection(repositories, Number.parseInt(cursor, 10), page_size); + return asCompactResult(page as unknown as Record, `${page.items.length} repositoryrecords geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`); + })); + + server.registerTool("reconcile_portfolio_repositories", { + title: "Reconcile ProjectBrain portfolio repositories", + description: "Preview or apply idempotent onboarding for newly discovered Gitea repositories. Applying creates or matches a ProjectBrain project, configures the signed webhook, grants the available read/write mode and enables conservative critical-finding issue automation.", + inputSchema: { apply: z.boolean().default(false), repository_full_names: z.array(z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/)).max(50).optional() }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + _meta: { "openai/toolInvocation/invoking": "Portfolio reconciliëren…", "openai/toolInvocation/invoked": "Portfolio gereconcilieerd" }, + }, async ({ apply, repository_full_names }) => withAudit({ actor, tool: "reconcile_portfolio_repositories" }, async () => { + const result = await reconcilePortfolioRepositories(env, actor, { dryRun: !apply, repositoryFullNames: repository_full_names }); + return asCompactResult(result as unknown as Record, apply ? `${result.onboardedCount} repository(s) gekoppeld; ${result.failedCount} mislukt.` : `${result.candidateCount} repository(s) klaar voor onboarding. Roep opnieuw aan met apply=true na bevestiging.`); + })); + + server.registerTool("set_portfolio_repository_exclusion", { + title: "Set ProjectBrain repository onboarding exclusion", + description: "Exclude a discovered repository from current and future automatic onboarding, or restore it. Excluding an already onboarded repository is refused; unlinking remains a separate owner operation.", + inputSchema: { repository_id: z.string().min(1), excluded: z.boolean(), reason: z.string().trim().min(3).max(2_000).optional() }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, async ({ repository_id, excluded, reason }) => withAudit({ actor, tool: "set_portfolio_repository_exclusion" }, async () => { + if (excluded && !reason) throw new Error("PORTFOLIO_EXCLUSION_REASON_REQUIRED"); + const repository = excluded ? await excludePortfolioRepository(repository_id, reason!) : await restorePortfolioRepository(repository_id); + return asCompactResult({ repository } as unknown as Record, `${repository.fullName} is ${excluded ? "uitgesloten van" : "hersteld voor"} automatische onboarding.`); + })); + + server.registerTool("search", { + title: "Search ProjectBrain repositories", description: "Search code in all Gitea repositories linked to ProjectBrain. Use fetch with a returned id to read the complete file.", + inputSchema: { query: z.string().trim().min(2).max(200).describe("Plain-text code search query") }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ query }) => withAudit({ actor, tool: "search" }, async () => { + const links = await listLinkedRepositories(); + const batches = await mapBounded(links, 6, async (link) => { + try { + const response = await client.searchCode(link.owner, link.repository, query, link.defaultBranch); + const rows = Array.isArray(response) ? response : response.data ?? []; + return rows.map((row) => ({ id: `gitea://${encodeURIComponent(link.projectSlug)}/${encodeURIComponent(link.defaultBranch)}/${encodeURIComponent(row.path)}`, title: `${link.fullName}: ${row.path}`, url: row.html_url })); + } catch { return []; } + }); + const results = batches.flat().slice(0, 50); + return asStandardKnowledgeResult({ results }); + })); + + server.registerTool("fetch", { + title: "Fetch repository file", description: "Fetch a complete repository file by the canonical gitea:// id returned by search.", + inputSchema: { id: z.string().startsWith("gitea://").describe("Canonical gitea://project/ref/path identifier") }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ id }) => { + let parsed: ReturnType; + try { parsed = parseFetchId(id); } catch (error) { return asResult({ error: errorCode(error) }, "Invalid fetch id"); } + return withAudit({ actor, tool: "fetch", projectId: parsed.projectId, branch: parsed.ref }, async (link) => { + const path = assertReadablePath(parsed.path); + const file = await client.getContents(link!.owner, link!.repository, path, parsed.ref); + if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE"); + return asStandardKnowledgeResult({ id, title: `${link!.fullName}: ${path}`, text: decodeFile(file), url: file.html_url, metadata: { projectId: link!.projectSlug, repository: link!.fullName, path, ref: parsed.ref, sha: file.sha, size: file.size } }); + }); + }); + + server.registerTool("get_project_context", { + title: "Get ProjectBrain project context", description: "Use this first for one known project. It returns bounded approved context plus a stable resume state and one recommended next action; paginate only when resolver.nextCursor is present.", + inputSchema: { project_id: z.string().min(1).describe("ProjectBrain project id or slug"), focus: z.string().trim().max(500).optional(), max_items: z.number().int().min(1).max(25).default(10), cursor: z.string().regex(/^\d+$/).optional(), character_budget: z.number().int().min(4000).max(80000).default(24000) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, focus, max_items, cursor, character_budget }) => withAudit({ actor, tool: "get_project_context", projectId: project_id }, async (link) => { + const context = await resolveReconciledProjectContext(client, link!.projectId, { focus, maxItems: max_items, cursor, characterBudget: character_budget }); + return asCompactResult(context as unknown as Record, `Projectcontext geladen. Volgende actie: ${String((context.resume.nextAction as Record).type)}.`); + })); + + server.registerTool("render_project_workspace", { + title: "Open ProjectBrain workspace", + description: "Render an interactive overview of a project's durable context, repository, pending reviews, and feature-delivery work.", + inputSchema: { project_id: z.string().min(1).describe("ProjectBrain project id or slug") }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + _meta: { ui: { resourceUri: PROJECTBRAIN_WORKSPACE_URI }, "openai/outputTemplate": PROJECTBRAIN_WORKSPACE_URI }, + }, async ({ project_id }) => withAudit({ actor, tool: "render_project_workspace", projectId: project_id }, async (link) => { + const project = await prisma.project.findUnique({ where: { id: link!.projectId }, select: { id: true, slug: true, name: true, description: true, updatedAt: true } }); + const [contextPackCount, deliveries, reviews] = await Promise.all([ + prisma.contextPack.count({ where: { projectId: link!.projectId, deletedAt: null } }), + listFeatureDeliveries(project_id), + listReviewItemsFromDatabase(project_id, { status: ["pending"] }, { page: 1, pageSize: 20 }), + ]); + return asResult({ projectId: link!.projectSlug, project, repository: link, contextPackCount, featureDeliveries: deliveries.slice(0, 12).map(compactDelivery), pendingReviews: (reviews?.items ?? []).slice(0, 12).map(compactRecord) }, "ProjectBrain workspace"); + })); + + server.registerTool("get_project_modules", { + title: "Get ProjectBrain module state", description: "Load the current brainstorms, knowledge, documentation, memory rules, context packs, feature plans and pending reviews for a project. Use this to understand what already exists before adding or changing project knowledge.", + inputSchema: { project_id: z.string().min(1), max_items: z.number().int().min(1).max(25).default(10), cursor: z.string().regex(/^\d+$/).default("0") }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, max_items, cursor }) => withAudit({ actor, tool: "get_project_modules", projectId: project_id }, async () => { + const [brainstorms, knowledge, documentation, memoryRules, contextPacks, deliveries, reviews] = await Promise.all([ + listBrainstormsForProject(project_id), + listKnowledgeForProject(project_id), + listDocumentationForProject(project_id), + listMemoryRulesForProject(project_id), + listContextPacksForProject(project_id), + listFeatureDeliveries(project_id), + listReviewItemsFromDatabase(project_id, { status: ["pending"] }, { page: 1, pageSize: 25 }), + ]); + const offset = Number.parseInt(cursor, 10); + const compactPage = (items: unknown[]) => pageCollection(items.map(compactRecord), offset, max_items); + return asCompactResult({ cursor, pageSize: max_items, brainstorms: compactPage(brainstorms), knowledge: compactPage(knowledge), documentation: compactPage(documentation), memoryRules: compactPage(memoryRules), contextPacks: compactPage(contextPacks ?? []), featureDeliveries: compactPage(deliveries), pendingReviews: compactPage(reviews?.items ?? []) }, "Compact, paginated ProjectBrain module inventory"); + })); + + server.registerTool("start_repository_analysis", { + title: "Start server-side repository analysis", + description: "Use this when a repository or portfolio analysis would otherwise require many synchronous Gitea calls. It queues one durable, bounded server-side job and immediately returns its job id.", + inputSchema: { + project_ids: z.array(z.string().trim().min(1)).max(200).optional(), + repository_ids: z.array(z.string().trim().min(1)).max(200).optional(), + include_branches: z.boolean().default(true), + include_pull_requests: z.boolean().default(true), + include_actions: z.boolean().default(true), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + _meta: { "openai/toolInvocation/invoking": "Analyse in wachtrij plaatsen…", "openai/toolInvocation/invoked": "Analysejob aangemaakt" }, + }, async ({ project_ids, repository_ids, include_branches, include_pull_requests, include_actions }) => withAudit({ actor, tool: "start_repository_analysis" }, async () => { + const result = await createPortfolioAnalysisJob(env, actor, { projectIds: project_ids, repositoryIds: repository_ids, includeBranches: include_branches, includePullRequests: include_pull_requests, includeActions: include_actions }); + const job = result.job as Record; + return asCompactResult({ job, deduplicated: result.deduplicated }, `Analysejob ${job.id} staat ${job.status === "queued" ? "in de wachtrij" : "al actief"}. Gebruik get_analysis_job voor de voortgang.`); + })); + + server.registerTool("list_analysis_jobs", { + title: "List repository analysis jobs", + description: "Use this to find recent server-side repository-analysis jobs created by the current caller.", + inputSchema: { limit: z.number().int().min(1).max(25).default(10) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ limit }) => withAudit({ actor, tool: "list_analysis_jobs" }, async () => { + const jobs = await listAnalysisJobs(actor, limit); + return asCompactResult({ jobs }, `${jobs.length} analysejob(s) gevonden.`); + })); + + server.registerTool("get_analysis_job", { + title: "Get repository analysis status", + description: "Use this to check compact progress for one analysis job. Respect pollAfterMs and repeat only while terminal=false; it never returns per-repository details.", + inputSchema: { job_id: z.string().min(1) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ job_id }) => withAudit({ actor, tool: "get_analysis_job" }, async () => { + const job = await getAnalysisJob(actor, job_id) as Record; + return asCompactResult({ job }, `Analysejob ${job.id}: ${job.status}, ${job.progressCurrent}/${job.progressTotal}.`); + })); + + server.registerTool("get_analysis_job_results", { + title: "Get paginated repository analysis results", + description: "Use this only after get_analysis_job reports progress or completion. It returns a bounded page of repository results, never the entire portfolio at once.", + inputSchema: { job_id: z.string().min(1), cursor: z.string().regex(/^\d+$/).optional(), page_size: z.number().int().min(1).max(10).default(5), character_budget: z.number().int().min(4000).max(32000).default(24000) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ job_id, cursor, page_size, character_budget }) => withAudit({ actor, tool: "get_analysis_job_results" }, async () => { + const result = await getAnalysisJobResults(actor, job_id, { cursor, pageSize: page_size, characterBudget: character_budget }); + return asCompactResult(result as unknown as Record, `${result.items.length} begrensde repositoryresultaten geladen.${result.nextCursor ? ` Volgende cursor: ${result.nextCursor}.` : ""}`); + })); + + server.registerTool("cancel_analysis_job", { + title: "Cancel repository analysis", + description: "Use this when the user wants to stop a queued or running ProjectBrain repository-analysis job. Completed work remains available until retention expiry.", + inputSchema: { job_id: z.string().min(1) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ job_id }) => withAudit({ actor, tool: "cancel_analysis_job" }, async () => { + const job = await cancelAnalysisJob(actor, job_id) as Record; + return asCompactResult({ job }, `Annulering voor analysejob ${job.id} is verwerkt.`); + })); + + server.registerTool("render_analysis_job", { + title: "Open repository analysis progress", + description: "Render the compact progress card for an existing analysis job. Always call start_repository_analysis or get_analysis_job first.", + inputSchema: { job_id: z.string().min(1) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + _meta: { ui: { resourceUri: PROJECTBRAIN_ANALYSIS_WIDGET_URI }, "openai/outputTemplate": PROJECTBRAIN_ANALYSIS_WIDGET_URI }, + }, async ({ job_id }) => withAudit({ actor, tool: "render_analysis_job" }, async () => { + const job = await getAnalysisJob(actor, job_id) as Record; + return asCompactResult({ job }, `Voortgangskaart voor analysejob ${job.id}.`); + })); + + server.registerTool("list_repository_findings", { + title: "List durable repository findings", + description: "Use this when the user wants new, active, regressed, resolved, or suppressed ProjectBrain findings. Results are risk-ranked and paginated.", + inputSchema: { + project_id: z.string().min(1).optional(), + statuses: z.array(z.enum(["new", "triaged", "issue_open", "investigating", "planned", "fixing", "verifying", "resolved", "regressed", "suppressed"])).max(10).optional(), + severities: z.array(z.enum(["info", "warning", "critical"])).max(3).optional(), + cursor: z.string().regex(/^\d+$/).optional(), + limit: z.number().int().min(1).max(50).default(20), + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, statuses, severities, cursor, limit }) => withAudit({ actor, tool: "list_repository_findings", projectId: project_id }, async () => { + const result = await listRepositoryFindings({ projectId: project_id, statuses, severities, cursor, limit }); + return asCompactResult(result as unknown as Record, `${result.items.length} duurzame bevinding(en) geladen.${result.nextCursor ? ` Volgende cursor: ${result.nextCursor}.` : ""}`); + })); + + server.registerTool("get_repository_finding", { + title: "Get repository finding", + description: "Use this before creating an issue, starting an investigation, suppressing a finding, or planning a fix. Returns its lifecycle, occurrences, issue and recent investigations.", + inputSchema: { finding_id: z.string().min(1) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ finding_id }) => withAudit({ actor, tool: "get_repository_finding" }, async () => asCompactResult({ finding: await getRepositoryFinding(finding_id) } as unknown as Record, `Bevinding ${finding_id} geladen.`))); + + server.registerTool("create_finding_issue", { + title: "Create or synchronize Gitea issue for finding", + description: "Use this only when the user wants a durable Gitea issue for an existing ProjectBrain finding. The operation is idempotent: retries update or reopen the same marked issue instead of creating duplicates.", + inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + _meta: { "openai/toolInvocation/invoking": "Gitea-issue synchroniseren…", "openai/toolInvocation/invoked": "Gitea-issue gesynchroniseerd" }, + }, async ({ project_id, finding_id }) => { + const finding = await assertFindingProject(project_id, finding_id); + return withAudit({ actor, tool: "create_finding_issue", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => { + const result = await syncFindingIssue(env, finding_id, actor); + return asCompactResult({ finding: result.finding, issue: result.issue } as unknown as Record, `Gitea-issue #${result.issue.number} is gekoppeld aan bevinding ${finding_id}.`); + }, true); + }); + + server.registerTool("start_finding_investigation", { + title: "Start finding investigation", + description: "Use this when the user asks ChatGPT to investigate a ProjectBrain finding or its Gitea issue. ProjectBrain assembles one bounded evidence pack server-side and reuses an active investigation on retries.", + inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, async ({ project_id, finding_id }) => { + const finding = await assertFindingProject(project_id, finding_id); + return withAudit({ actor, tool: "start_finding_investigation", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => { + const investigation = await startFindingInvestigation(env, finding_id, actor); + return asCompactResult({ investigation } as unknown as Record, `Onderzoek ${investigation.id} is klaar voor analyse.`); + }); + }); + + server.registerTool("get_finding_investigation", { + title: "Get finding investigation", + description: "Use this to load the bounded evidence pack, conclusion and linked delivery for one ProjectBrain finding investigation.", + inputSchema: { investigation_id: z.string().min(1) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ investigation_id }) => withAudit({ actor, tool: "get_finding_investigation" }, async () => asCompactResult({ investigation: await getFindingInvestigation(investigation_id) } as unknown as Record, `Onderzoek ${investigation_id} geladen.`))); + + server.registerTool("save_finding_investigation", { + title: "Save finding investigation conclusion", + description: "Use this after examining the evidence pack. Persists a concise diagnosis, root cause, proposed file-level changes, confidence and conclusion without starting repository changes.", + inputSchema: { + project_id: z.string().min(1), investigation_id: z.string().min(1), diagnosis: z.string().trim().min(8).max(12_000), root_cause: z.string().trim().min(8).max(12_000), + proposed_changes: z.array(z.string().trim().min(1).max(2_000)).min(1).max(50), confidence: z.number().int().min(0).max(100), conclusion: z.string().trim().min(8).max(8_000), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, investigation_id, diagnosis, root_cause, proposed_changes, confidence, conclusion }) => { + const investigation = await assertInvestigationProject(project_id, investigation_id); + return withAudit({ actor, tool: "save_finding_investigation", projectId: project_id, repositoryId: investigation.finding.repositoryLinkId ?? undefined }, async () => asCompactResult({ investigation: await saveFindingInvestigationConclusion(investigation_id, { diagnosis, rootCause: root_cause, proposedChanges: proposed_changes, confidence, conclusion }) } as unknown as Record, `Onderzoeksconclusie ${investigation_id} opgeslagen.`)); + }); + + server.registerTool("suppress_repository_finding", { + title: "Suppress repository finding", + description: "Use this only when the user explicitly accepts or defers a finding. A reason is mandatory and an optional expiry lets future scans reactivate it.", + inputSchema: { project_id: z.string().min(1), finding_id: z.string().min(1), reason: z.string().trim().min(3).max(2_000), until: z.string().datetime().optional() }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, finding_id, reason, until }) => { + const finding = await assertFindingProject(project_id, finding_id); + return withAudit({ actor, tool: "suppress_repository_finding", projectId: project_id, repositoryId: finding.repositoryLinkId ?? undefined }, async () => asCompactResult({ finding: await suppressRepositoryFinding(finding_id, reason, until ? new Date(until) : undefined) } as unknown as Record, `Bevinding ${finding_id} is onderdrukt.`)); + }); + + server.registerTool("save_brainstorm", { + title: "Save ChatGPT brainstorm", description: "Persist an idea exploration in ProjectBrain's Brainstorm module. Use this for meaningful alternatives, trade-offs and next actions; use create_feature_delivery only after the user has chosen a direction.", + inputSchema: { + project_id: z.string().min(1), title: z.string().trim().min(3).max(160), prompt: z.string().trim().min(3).max(8000), + context_summary: z.string().trim().min(3).max(8000), ideas: z.array(z.object({ id: z.string().min(1), title: z.string().min(1), description: z.string().min(1), score: z.number().min(0).max(100).optional() })).max(50).default([]), + outcomes: z.array(z.string().min(1).max(2000)).max(50).default([]), selected_idea_ids: z.array(z.string().min(1)).max(50).default([]), next_actions: z.array(z.string().min(1).max(1000)).max(50).default([]), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, title, prompt, context_summary, ideas, outcomes, selected_idea_ids, next_actions }) => withAudit({ actor, tool: "save_brainstorm", projectId: project_id }, async () => asResult(await createBrainstormForProject(project_id, { + title, prompt, scope: "project", mode: "chatgpt", status: selected_idea_ids.length ? "selected" : "exploring", generatedBy: "chatgpt_mcp", contextSummary: context_summary, + ideas, outcomes: outcomes.map((summary, index) => ({ id: `outcome-${index + 1}`, summary })), selectedIdeaIds: selected_idea_ids, nextActions: next_actions, + }), "Brainstorm saved in ProjectBrain"))); + + server.registerTool("save_project_knowledge", { + title: "Save project knowledge", description: "Save a reusable fact, finding or technical explanation to ProjectBrain's Knowledge module. New knowledge remains pending for human review.", + inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(30000), category: z.string().trim().min(1).max(80).default("Technical"), confidence: z.number().int().min(0).max(100).default(75) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, title, content, category, confidence }) => withAudit({ actor, tool: "save_project_knowledge", projectId: project_id }, async () => asResult(await createKnowledgeItem(project_id, { title, content, category, confidence, source: "ChatGPT via ProjectBrain MCP" }), "Pending knowledge saved"))); + + server.registerTool("save_documentation_draft", { + title: "Save documentation draft", description: "Write a documentation draft into ProjectBrain. The draft remains pending until the user reviews it in the Documentation module.", + inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), content: z.string().trim().min(3).max(50000), kind: z.string().trim().min(1).max(80).default("design document") }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, title, content, kind }) => withAudit({ actor, tool: "save_documentation_draft", projectId: project_id }, async () => asResult(await createDocumentationItem(project_id, { title, content, kind, status: "draft", approvalState: "pending" }), "Documentation draft saved"))); + + server.registerTool("save_memory_rule", { + title: "Save project memory rule", description: "Save an enduring instruction or do-not-break rule in ProjectBrain. New rules remain pending for human review.", + inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160), rule: z.string().trim().min(3).max(8000), scope: z.enum(["global", "module", "feature", "prompt_only"]).default("global"), category: z.enum(["UX", "Technical", "Architecture", "Do Not Break", "Naming", "Workflow", "Security"]).default("Technical"), severity: z.enum(["info", "important", "critical", "do_not_break"]).default("important") }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, title, rule, scope, category, severity }) => withAudit({ actor, tool: "save_memory_rule", projectId: project_id }, async () => asResult(await createMemoryRuleItem(project_id, { title, rule, scope, category, severity, includeInContextPacks: true }), "Pending memory rule saved"))); + + server.registerTool("create_project_context_pack", { + title: "Create project context pack", description: "Create a fresh ProjectBrain context pack from the project's approved DNA, features, evidence, memory and architecture before a substantial planning or implementation task.", + inputSchema: { project_id: z.string().min(1), title: z.string().trim().min(3).max(160).optional() }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, title }) => withAudit({ actor, tool: "create_project_context_pack", projectId: project_id }, async () => asResult(await createContextPackForProject(project_id, { sourceType: "workspace", title, includeDNA: true, includeFeatures: true, includeEvidence: true, includeMemoryRules: true, includeRisks: true, includeRegressions: true, includeArchitecture: true }), "Context pack created"))); + + server.registerTool("record_execution_feedback", { + title: "Record execution feedback", + description: "Import the outcome of a completed coding or automation run into ProjectBrain's Review Queue. Feedback stays pending until a human accepts it as project truth.", + inputSchema: { + project_id: z.string().min(1), codex_task_id: z.string().min(1), source_run: z.string().trim().min(1).max(240), + summary: z.string().trim().min(3).max(8000), output_text: z.string().max(100_000).default(""), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, codex_task_id, source_run, summary, output_text }) => withAudit({ actor, tool: "record_execution_feedback", projectId: project_id }, async () => { + const result = await importCodexOutputInDatabase(project_id, codex_task_id, { sourceRun: source_run, summary, outputText: output_text }); + if (!result) throw new Error("PROJECT_NOT_FOUND"); + if (result === "CODEX_TASK_NOT_FOUND") throw new Error(result); + return asResult(result, "Execution feedback imported for human review"); + })); + + server.registerTool("list_feature_deliveries", { + title: "List feature deliveries", description: "Use this to find a delivery id or compact status. Results are paginated summaries; call get_feature_delivery only for the selected delivery's full plan and runtime evidence.", + inputSchema: { project_id: z.string().min(1), cursor: z.string().regex(/^\d+$/).default("0"), page_size: z.number().int().min(1).max(25).default(10) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, cursor, page_size }) => withAudit({ actor, tool: "list_feature_deliveries", projectId: project_id }, async () => { + const deliveries = (await listFeatureDeliveries(project_id)).map(compactDelivery); + const page = pageCollection(deliveries, Number.parseInt(cursor, 10), page_size); + return asCompactResult(page as unknown as Record, `${page.items.length} deliverysamenvatting(en) geladen.${page.nextCursor ? ` Volgende cursor: ${page.nextCursor}.` : ""}`); + })); + + server.registerTool("get_feature_delivery", { + title: "Get feature delivery", description: "Use this before continuing a feature to load its current scope, authorization state, acceptance criteria, implementation plan, branch, progress, validations, and pull request. If approvalState is approved, ProjectBrain policy has already authorized execution; do not ask for redundant approval.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "get_feature_delivery", projectId: project_id }, async () => { + const delivery = await refreshDeliveryFromGitea(client, project_id, delivery_id); + return asResult({ ...delivery, continuation: deliveryContinuation(delivery) }, "Delivery reconciled with Gitea; follow continuation."); + })); + + const featurePlanSchema = { + project_id: z.string().min(1), title: z.string().trim().min(3).max(160), intent: z.string().trim().min(8).max(4000), + priority: z.enum(["critical", "important", "normal", "low"]).default("normal"), problem: z.string().trim().min(8).max(8000), + desired_outcome: z.string().trim().min(8).max(8000), users: z.array(z.string().min(1).max(300)).max(30).default([]), + goals: z.array(z.string().min(1).max(1000)).min(1).max(50), non_goals: z.array(z.string().min(1).max(1000)).max(50).default([]), + acceptance_criteria: z.array(z.string().min(1).max(1000)).min(1).max(100), implementation_plan: z.array(z.string().min(1).max(2000)).min(1).max(100), + risks: z.array(z.string().min(1).max(1000)).max(50).default([]), validation_commands: z.array(z.string().min(1).max(500)).min(1).max(30), + risk_level: z.enum(["low", "medium", "high", "destructive"]).default("medium").describe("Classify execution risk. low/medium reversible work is policy-authorized automatically; high/destructive requires a real user decision before repository writes."), + }; + + server.registerTool("create_delivery_from_finding", { + title: "Create delivery plan from finding investigation", + description: "Use this only after save_finding_investigation. Creates a reviewable ProjectBrain delivery linked to the finding and investigation; it never approves the plan or starts code changes.", + inputSchema: { investigation_id: z.string().min(1), ...featurePlanSchema }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, investigation_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands }) => { + const investigation = await assertInvestigationProject(project_id, investigation_id); + return withAudit({ actor, tool: "create_delivery_from_finding", projectId: project_id, repositoryId: investigation.finding.repositoryLinkId ?? undefined }, async () => { + const result = await createDeliveryFromFindingInvestigation(investigation_id, { + title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, + acceptanceCriteria: acceptance_criteria, implementationPlan: implementation_plan, risks, validationCommands: validation_commands, + }, actor); + return asCompactResult(result as unknown as Record, `Delivery ${result.delivery.id} is aangemaakt en wacht op goedkeuring in ProjectBrain.`); + }); + }); + + server.registerTool("verify_finding_resolution", { + title: "Queue post-merge finding verification", + description: "Use this after the finding-linked pull request is merged. Queues a bounded repository reanalysis; ProjectBrain closes the issue only when the original finding is absent.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "verify_finding_resolution", projectId: project_id, deliveryId: delivery_id }, async () => { + const verification = await queueFindingVerificationForDelivery(env, project_id, delivery_id); + if (!verification) throw new Error("FINDING_VERIFICATION_NOT_AVAILABLE"); + return asCompactResult(verification as unknown as Record, `Gerichte verificatiejob ${(verification.job as Record).id} staat in de wachtrij.`); + })); + + server.registerTool("create_feature_delivery", { + title: "Create feature delivery plan", + description: "Create the bounded execution plan for a user request. Classify risk explicitly. ProjectBrain auto-authorizes low/medium reversible work; high/destructive work remains pending and is surfaced under Jouw aandacht. Creating the plan never writes repository files by itself.", + inputSchema: featurePlanSchema, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands, risk_level }) => withAudit({ actor, tool: "create_feature_delivery", projectId: project_id }, async () => { + const delivery = await createOrResumeAutonomyDelivery(project_id, { + title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, acceptanceCriteria: acceptance_criteria, + implementationPlan: implementation_plan, risks, validationCommands: validation_commands, riskLevel: risk_level, + }, actor); + return asResult(delivery, delivery.approvalState === "approved" + ? "Feature delivery policy-authorized. Continue autonomously with start_feature_delivery; do not ask the user for redundant approval." + : "Feature delivery requires a real user decision. Stop repository writes and surface the decision in ProjectBrain under Jouw aandacht."); + })); + + server.registerTool("update_feature_delivery", { + title: "Update feature delivery plan", + description: "Refine an unstarted feature plan and re-evaluate its risk policy. Low/medium reversible work is policy-authorized; high/destructive work returns to a real user-decision state. Active or completed plans are locked.", + inputSchema: { delivery_id: z.string().min(1), ...featurePlanSchema }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, delivery_id, title, intent, priority, problem, desired_outcome, users, goals, non_goals, acceptance_criteria, implementation_plan, risks, validation_commands, risk_level }) => withAudit({ actor, tool: "update_feature_delivery", projectId: project_id }, async () => { + const delivery = await updateFeatureDelivery(project_id, delivery_id, { + title, intent, priority, problem, desiredOutcome: desired_outcome, users, goals, nonGoals: non_goals, acceptanceCriteria: acceptance_criteria, + implementationPlan: implementation_plan, risks, validationCommands: validation_commands, riskLevel: risk_level, + }, actor); + return asResult(delivery, delivery.approvalState === "approved" + ? "Feature delivery updated and policy-authorized. Continue autonomously without an extra approval round." + : "Feature delivery updated and now requires a real user decision before repository execution."); + })); + + server.registerTool("list_repository_tree", { + title: "List repository tree", description: "List files and directories at a repository path and ref.", + inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), path: z.string().default(""), ref: z.string().optional() }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, repository_id, path, ref }) => withAudit({ actor, tool: "list_repository_tree", projectId: project_id, repositoryId: repository_id, branch: ref }, async (link) => { + const safePath = path ? assertReadablePath(path) : ""; + const resolvedRef = ref ?? link!.defaultBranch; + const contents = await client.getContents(link!.owner, link!.repository, safePath, resolvedRef); + const entries = (Array.isArray(contents) ? contents : [contents]).map(({ content: _content, ...entry }) => entry); + return asResult({ projectId: link!.projectSlug, repository: link!.fullName, path: safePath, ref: resolvedRef, entries }, "Repository tree"); + })); + + server.registerTool("get_file", { + title: "Read repository file", description: "Read a text file from a linked repository at a branch, tag, or commit.", + inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), path: z.string().min(1), ref: z.string().optional() }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, repository_id, path, ref }) => withAudit({ actor, tool: "get_file", projectId: project_id, repositoryId: repository_id, branch: ref }, async (link) => { + const safePath = assertReadablePath(path); const resolvedRef = ref ?? link!.defaultBranch; + const file = await client.getContents(link!.owner, link!.repository, safePath, resolvedRef); + if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE"); + return asResult({ projectId: link!.projectSlug, repository: link!.fullName, path: safePath, ref: resolvedRef, sha: file.sha, size: file.size, content: decodeFile(file), htmlUrl: file.html_url }, "Repository file"); + })); + + server.registerTool("list_branches", { + title: "List repository branches", description: "List branches and protection status for a linked repository.", inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional() }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, repository_id }) => withAudit({ actor, tool: "list_branches", projectId: project_id, repositoryId: repository_id }, async (link) => asResult({ defaultBranch: link!.defaultBranch, branches: await client.listBranches(link!.owner, link!.repository) }, "Repository branches"))); + + server.registerTool("list_pull_requests", { + title: "List pull requests", description: "List pull requests for a linked repository.", inputSchema: { project_id: z.string().min(1), repository_id: z.string().min(1).optional(), state: z.enum(["open", "closed", "all"]).default("open") }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, repository_id, state }) => withAudit({ actor, tool: "list_pull_requests", projectId: project_id, repositoryId: repository_id }, async (link) => asResult({ pullRequests: await client.listPullRequests(link!.owner, link!.repository, state) }, "Pull requests"))); + + server.registerTool("start_feature_delivery", { + title: "Start authorized feature delivery", + description: "Start an isolated non-default delivery branch only when ProjectBrain policy already reports the delivery as approved. Low/medium work can arrive pre-approved automatically; high/destructive work cannot start until its real user decision is recorded. Never ask for a redundant approval when approvalState is already approved.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), repository_id: z.string().min(1).optional(), branch: z.string().min(1).max(300).describe("Unique valid non-default Git branch") }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, delivery_id, repository_id, branch }) => withAudit({ actor, tool: "start_feature_delivery", projectId: project_id, repositoryId: repository_id, deliveryId: delivery_id, branch }, async (link) => { + const delivery = await getFeatureDelivery(project_id, delivery_id); + const safeBranch = assertChatGptBranch(branch, link!.defaultBranch); + if (delivery.spec.branch === safeBranch && ["executing", "validating", "pull_request_open", "merged", "verified", "released"].includes(delivery.spec.status)) return asResult(delivery, "Feature delivery already started"); + if (delivery.approvalState !== "approved" || delivery.spec.status !== "approved") throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED"); + const base = await client.getBranch(link!.owner, link!.repository, link!.defaultBranch); + try { await client.createBranch(link!.owner, link!.repository, safeBranch, link!.defaultBranch); } + catch (error) { + if (!(error instanceof GiteaApiError && error.status === 409)) throw error; + await client.getBranch(link!.owner, link!.repository, safeBranch); + } + const head = await client.getBranch(link!.owner, link!.repository, safeBranch); + return asResult(await startFeatureDelivery(project_id, delivery_id, safeBranch, actor, { repositoryLinkId: link!.id, baseCommitSha: base.commit.id, headSha: head.commit.id }), "Authorized feature delivery started"); + }, true)); + + server.registerTool("write_feature_file", { + title: "Write approved feature file", description: "Use this to create or update one text file for an active approved feature delivery. The delivery must own the supplied ChatGPT/ProjectBrain branch. Pass the SHA returned by get_file when updating an existing file.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(9), path: z.string().min(1), content: z.string(), message: z.string().trim().min(3).max(200), expected_sha: z.string().min(1).max(200).optional(), progress_note: z.string().max(2000).optional() }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, delivery_id, branch, path, content, message, expected_sha, progress_note }) => withAudit({ actor, tool: "write_feature_file", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => { + const delivery = await getFeatureDelivery(project_id, delivery_id); + const safeBranch = assertChatGptBranch(branch, link!.defaultBranch); + await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!); + const safePath = assertWritablePath(path); assertFileSize(content); + let existing: GiteaContent | undefined; + try { const value = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (!Array.isArray(value)) existing = value; } + catch (error) { if (!(error instanceof GiteaApiError && error.status === 404)) throw error; } + assertExpectedFileSha(expected_sha, existing?.sha); + const result = existing ? await client.updateFile(link!.owner, link!.repository, safePath, safeBranch, expected_sha!, content, message) : await client.createFile(link!.owner, link!.repository, safePath, safeBranch, content, message); + const head = await client.getBranch(link!.owner, link!.repository, safeBranch); + let written: GiteaContent | undefined; + try { const value = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (!Array.isArray(value)) written = value; } catch { /* The commit is still tracked by branch head. */ } + const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: [{ path: safePath, operation: existing ? "update" : "create", beforeSha: existing?.sha, afterSha: written?.sha, commitSha: head.commit.id }], note: progress_note, headSha: head.commit.id }, actor); + return asResult({ repository: link!.fullName, branch: safeBranch, path: safePath, action: existing ? "updated" : "created", result, delivery: progress }, "Feature file committed and progress recorded"); + }, true)); + + server.registerTool("delete_feature_file", { + title: "Delete approved feature file", description: "Delete one file from an active approved feature delivery branch and record that change. This is destructive and requires explicit confirmation.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(9), path: z.string().min(1), message: z.string().trim().min(3).max(200), expected_sha: z.string().min(1).max(200), progress_note: z.string().max(2000).optional() }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + }, async ({ project_id, delivery_id, branch, path, message, expected_sha, progress_note }) => withAudit({ actor, tool: "delete_feature_file", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => { + const delivery = await getFeatureDelivery(project_id, delivery_id); + const safeBranch = assertChatGptBranch(branch, link!.defaultBranch); + await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!); + const safePath = assertWritablePath(path); + const file = await client.getContents(link!.owner, link!.repository, safePath, safeBranch); if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE"); + assertExpectedFileSha(expected_sha, file.sha); + const result = await client.deleteFile(link!.owner, link!.repository, safePath, safeBranch, expected_sha, message); + const head = await client.getBranch(link!.owner, link!.repository, safeBranch); + const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: [{ path: safePath, operation: "delete", beforeSha: file.sha, commitSha: head.commit.id }], note: progress_note, headSha: head.commit.id }, actor); + return asResult({ repository: link!.fullName, branch: safeBranch, path: safePath, deleted: true, result, delivery: progress }, "Feature file deleted and progress recorded"); + }, true)); + + server.registerTool("apply_feature_changeset", { + title: "Apply approved feature changeset", + description: "Create, update, and delete up to 25 text files for one approved delivery in a single bounded server-side call. Use this instead of repeated file tools for multi-file work. Every path is preflighted against its expected SHA and the exact branch head before the first write.", + inputSchema: { + project_id: z.string().min(1), delivery_id: z.string().min(1), branch: z.string().min(1), + expected_head_sha: z.string().regex(/^[0-9a-f]{40,64}$/i), message: z.string().trim().min(3).max(200), + operations: z.array(featureChangesetOperationSchema).min(1).max(25), progress_note: z.string().max(2_000).optional(), + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + _meta: { "openai/toolInvocation/invoking": "Gebudgetteerde changeset toepassen…", "openai/toolInvocation/invoked": "Changeset toegepast" }, + }, async ({ project_id, delivery_id, branch, expected_head_sha, message, operations, progress_note }) => withAudit({ actor, tool: "apply_feature_changeset", projectId: project_id, deliveryId: delivery_id, branch }, async (link) => { + const delivery = await getFeatureDelivery(project_id, delivery_id); + const safeBranch = assertChatGptBranch(branch, link!.defaultBranch); + await assertDeliveryWriteAllowed(delivery, safeBranch, client, link!); + const paths = assertFeatureChangesetBudget(operations); + const initialHead = await client.getBranch(link!.owner, link!.repository, safeBranch); + if (initialHead.commit.id !== expected_head_sha) throw new Error("FEATURE_CHANGESET_HEAD_CONFLICT"); + + const preflight = await mapBounded(operations, 4, async (operation, index) => { + let current: GiteaContent | undefined; + try { + const value = await client.getContents(link!.owner, link!.repository, paths[index], safeBranch); + if (Array.isArray(value)) throw new Error("GITEA_CONTENT_NOT_FILE"); + current = value; + } catch (error) { + if (!(error instanceof GiteaApiError && error.status === 404)) throw error; + } + if (operation.operation === "create") assertExpectedFileSha(undefined, current?.sha); + else assertExpectedFileSha(operation.expected_sha, current?.sha); + return current; + }); + const preflightHead = await client.getBranch(link!.owner, link!.repository, safeBranch); + if (preflightHead.commit.id !== expected_head_sha) throw new Error("FEATURE_CHANGESET_HEAD_CONFLICT"); + + const appliedChanges: Array<{ path: string; operation: "create" | "update" | "delete"; beforeSha?: string; afterSha?: string; commitSha?: string }> = []; + try { + for (let index = 0; index < operations.length; index += 1) { + const operation = operations[index]; const path = paths[index]; const before = preflight[index]; + if (operation.operation === "create") await client.createFile(link!.owner, link!.repository, path, safeBranch, operation.content, message); + else if (operation.operation === "update") await client.updateFile(link!.owner, link!.repository, path, safeBranch, operation.expected_sha, operation.content, message); + else await client.deleteFile(link!.owner, link!.repository, path, safeBranch, operation.expected_sha, message); + const head = await client.getBranch(link!.owner, link!.repository, safeBranch); + let afterSha: string | undefined; + if (operation.operation !== "delete") { + const written = await client.getContents(link!.owner, link!.repository, path, safeBranch); + if (!Array.isArray(written)) afterSha = written.sha; + } + appliedChanges.push({ path, operation: operation.operation, beforeSha: before?.sha, afterSha, commitSha: head.commit.id }); + } + } catch { + if (appliedChanges.length) { + const partialHead = await client.getBranch(link!.owner, link!.repository, safeBranch).catch(() => undefined); + await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: appliedChanges, note: `Partial changeset: ${appliedChanges.length}/${operations.length} operations applied before a remote failure.`, headSha: partialHead?.commit.id }, actor).catch(() => undefined); + throw new PartialExternalMutationError("FEATURE_CHANGESET_PARTIAL"); + } + throw new Error("FEATURE_CHANGESET_APPLY_FAILED"); + } + + const finalHead = await client.getBranch(link!.owner, link!.repository, safeBranch); + const progress = await recordFeatureDeliveryProgress(project_id, delivery_id, { changes: appliedChanges, note: progress_note, headSha: finalHead.commit.id }, actor); + return asCompactResult({ repository: link!.fullName, deliveryId: progress.id, branch: safeBranch, previousHeadSha: initialHead.commit.id, headSha: finalHead.commit.id, applied: appliedChanges.map(({ path, operation }) => ({ path, operation })) }, `${appliedChanges.length} bestandsbewerkingen server-side toegepast op ${safeBranch}.`); + }, true)); + + server.registerTool("record_feature_validation", { + title: "Record reported feature validation", description: "Store reported, failed, or not-run checks for an active feature. Reported results are useful progress evidence but cannot authorize a pull request; use sync_feature_validation to verify a successful Gitea Actions run for the current branch head.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), results: z.array(z.object({ command: z.string().min(1).max(500), status: z.enum(["passed", "failed", "not_run"]), summary: z.string().max(2000).optional() })).min(1).max(30), note: z.string().max(4000).optional(), ready_for_review: z.boolean().default(false) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, delivery_id, results, note, ready_for_review }) => withAudit({ actor, tool: "record_feature_validation", projectId: project_id }, async () => asResult(await recordFeatureDeliveryProgress(project_id, delivery_id, { validationResults: results, note, readyForReview: ready_for_review }, actor), "Feature validation recorded"))); + + server.registerTool("get_feature_validation_runs", { + title: "Get feature validation runs", description: "Inspect Gitea Actions runs for the active feature branch and identify whether the current branch head has completed successfully.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "get_feature_validation_runs", projectId: project_id, deliveryId: delivery_id }, async (link) => { + const delivery = await getFeatureDelivery(project_id, delivery_id); + if (!delivery.spec.branch) throw new Error("FEATURE_DELIVERY_NOT_STARTED"); + const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch); + const head = await client.getBranch(link!.owner, link!.repository, branch); + const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 }); + const runs = exactHeadActionRuns(response.workflow_runs, branch, head.commit.id); + let successful = true; + try { assertSuccessfulActionRuns(runs, branch, head.commit.id); } catch { successful = false; } + return asResult({ branch, headSha: head.commit.id, runs, successful }, "Feature validation runs"); + })); + + server.registerTool("sync_feature_validation", { + title: "Verify feature with Gitea Actions", description: "Verify and persist a successful Gitea Actions run for the exact current feature branch head. This authorizes opening a pull request and refreshes evidence after an open pull request branch changes.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1) }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, delivery_id }) => withAudit({ actor, tool: "sync_feature_validation", projectId: project_id, deliveryId: delivery_id }, async (link) => { + const delivery = await getFeatureDelivery(project_id, delivery_id); + if (!delivery.spec.branch || delivery.approvalState !== "approved" || !canSyncFeatureDeliveryValidation(delivery.spec.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN"); + const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch); + const head = await client.getBranch(link!.owner, link!.repository, branch); + const response = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 }); + const verifiedRuns = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id); + const run = verifiedRuns.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest); + const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() }; + return asResult({ run, delivery: await recordFeatureDeliveryActionValidation(project_id, delivery_id, evidence, actor) }, "Gitea Actions validation verified and persisted"); + }, true)); + + server.registerTool("complete_feature_delivery", { + title: "Open feature pull request", description: "Use this after an approved feature delivery is implemented and its validation results are recorded. Opens the pull request and links it back to the originating feature plan in ProjectBrain.", + inputSchema: { project_id: z.string().min(1), delivery_id: z.string().min(1), title: z.string().trim().min(3).max(200), body: z.string().max(20_000).default("") }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, async ({ project_id, delivery_id, title, body }) => withAudit({ actor, tool: "complete_feature_delivery", projectId: project_id, deliveryId: delivery_id }, async (link) => { + const delivery = await getFeatureDelivery(project_id, delivery_id); + if (!delivery.spec.branch || delivery.approvalState !== "approved") throw new Error("FEATURE_DELIVERY_NOT_STARTED"); + if (delivery.spec.pullRequest) return asResult(delivery, "Feature delivery already has a pull request"); + if (delivery.spec.validationResults.some((result) => result.status === "failed")) throw new Error("FEATURE_DELIVERY_VALIDATION_FAILED"); + // Opening review needs verified exact-head Actions evidence below. Planned + // PR checks cannot have run before the PR exists, and a dispatch must never + // manufacture passed results for those commands. Merge still uses Gitea's + // separate protected-check gate; reported failures continue to block review. + const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch); + const head = await client.getBranch(link!.owner, link!.repository, branch); + assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, head.commit.id); + const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: head.commit.id, limit: 30 }); + assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, head.commit.id); + const existing = findReusableDeliveryPullRequest(await client.listPullRequests(link!.owner, link!.repository, "all"), branch, link!.defaultBranch, head.commit.id); + const pullRequest = existing ?? await client.createPullRequest(link!.owner, link!.repository, { title, body, head: branch, base: link!.defaultBranch }); + let updated = await openFeatureDeliveryPullRequest(project_id, delivery_id, { number: pullRequest.number, url: pullRequest.html_url, title: pullRequest.title }, actor); + if (pullRequest.merged) updated = await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: pullRequest.number, state: "closed", merged: true, mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, headSha: pullRequest.head.sha, actor }) ?? updated; + return asResult({ repository: link!.fullName, pullRequest, delivery: updated }, pullRequest.merged ? "Existing merged pull request reconciled with delivery" : "Feature pull request created; delivery is awaiting merge"); + }, true)); + + server.registerTool("merge_feature_delivery", { + title: "Merge validated feature delivery", + description: "Merge an open ProjectBrain delivery pull request after exact-head and Gitea Actions verification, reconcile the durable delivery, queue finding verification, and optionally remove the merged work branch. This never writes directly to the default branch outside the reviewed pull request.", + inputSchema: { + project_id: z.string().min(1), delivery_id: z.string().min(1), expected_head_sha: z.string().regex(/^[0-9a-f]{40,64}$/i), + method: z.enum(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]).default("squash"), + delete_branch: z.boolean().default(true), + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + _meta: { "openai/toolInvocation/invoking": "Gevalideerde delivery mergen…", "openai/toolInvocation/invoked": "Delivery gemerged" }, + }, async ({ project_id, delivery_id, expected_head_sha, method, delete_branch }) => withAudit({ actor, tool: "merge_feature_delivery", projectId: project_id, deliveryId: delivery_id }, async (link) => { + const delivery = await getFeatureDelivery(project_id, delivery_id); + const pullRequestNumber = delivery.spec.pullRequest?.number; + if (!pullRequestNumber || !delivery.spec.branch || delivery.approvalState !== "approved") throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN"); + const branch = assertChatGptBranch(delivery.spec.branch, link!.defaultBranch); + const pullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber); + if (pullRequest.head.ref !== branch || pullRequest.base.ref !== link!.defaultBranch || pullRequest.head.sha !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT"); + const cleanupBranch = () => cleanupDeliveryBranch(client, link!, branch, expected_head_sha, delete_branch); + if (pullRequest.merged) { + if (!["merged", "verified", "released"].includes(delivery.spec.status)) await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: pullRequest.number, state: "closed", merged: true, mergedAt: pullRequest.merged_at, mergeCommitSha: pullRequest.merge_commit_sha, headSha: pullRequest.head.sha, actor }); + await queueFindingVerificationForDelivery(env, project_id, delivery_id).catch(() => undefined); + const cleanup = await cleanupBranch(); + return asCompactResult({ repository: link!.fullName, deliveryId: delivery.id, pullRequest: { number: pullRequest.number, url: pullRequest.html_url, mergeCommitSha: pullRequest.merge_commit_sha }, branch, alreadyMerged: true, ...cleanup }, `PR #${pullRequest.number} was al gemerged.${cleanup.branchDeleted ? " De werkbranch is opgeruimd." : ""}`); + } + if (delivery.spec.status !== "pull_request_open") throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN"); + if (pullRequest.merged || pullRequest.state !== "open" || pullRequest.draft === true || pullRequest.mergeable === false) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_READY"); + const head = await client.getBranch(link!.owner, link!.repository, branch); + if (head.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT"); + assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, expected_head_sha); + const actionRuns = await client.listActionRuns(link!.owner, link!.repository, { branch, headSha: expected_head_sha, limit: 30 }); + assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, expected_head_sha); + + await client.waitForRequiredPullRequestChecks(link!.owner, link!.repository, link!.defaultBranch, expected_head_sha); + const mergeReadyPullRequest = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber); + const mergeReadyHead = await client.getBranch(link!.owner, link!.repository, branch); + if (mergeReadyPullRequest.state !== "open" || mergeReadyPullRequest.merged || mergeReadyPullRequest.head.sha !== expected_head_sha || mergeReadyPullRequest.base.ref !== link!.defaultBranch || mergeReadyHead.commit.id !== expected_head_sha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT"); + + await client.mergePullRequest(link!.owner, link!.repository, pullRequestNumber, { method, headCommitId: expected_head_sha }); + const merged = await client.getPullRequest(link!.owner, link!.repository, pullRequestNumber); + if (!merged.merged) throw new PartialExternalMutationError("FEATURE_DELIVERY_MERGE_UNCONFIRMED"); + const reconciled = await reconcileFeatureDeliveryPullRequest({ owner: link!.owner, repository: link!.repository, number: merged.number, state: "closed", merged: true, mergedAt: merged.merged_at, mergeCommitSha: merged.merge_commit_sha, headSha: merged.head.sha, actor }); + const verification = await queueFindingVerificationForDelivery(env, project_id, delivery_id).catch(() => undefined); + + const { branchDeleted, branchCleanupError } = await cleanupBranch(); + return asCompactResult({ repository: link!.fullName, deliveryId: reconciled?.id ?? delivery_id, pullRequest: { number: merged.number, url: merged.html_url, mergeCommitSha: merged.merge_commit_sha }, mergeMethod: method, branch, branchDeleted, ...(branchCleanupError ? { branchCleanupError } : {}), verificationQueued: Boolean(verification) }, `PR #${merged.number} is via ${method} gemerged.${branchDeleted ? " De werkbranch is opgeruimd." : branchCleanupError ? " De merge slaagde; branchopruiming kan afzonderlijk worden herhaald." : ""}`); + }, true)); + + return server; +}; diff --git a/backend/src/mcp/skills.ts b/backend/src/mcp/skills.ts new file mode 100644 index 0000000..03692e6 --- /dev/null +++ b/backend/src/mcp/skills.ts @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; + +const EXTENSION_ID = "io.modelcontextprotocol/skills"; +const SERVER_SLUG = "projectbrain"; + +type SkillDefinition = { name: string; description: string; body: string }; + +const definitions: SkillDefinition[] = [ + { + name: "projectbrain-operator", + description: "Operate Auto Mode with ProjectBrain as controller and Gitea as the canonical repository execution and evidence surface.", + body: `# ProjectBrain operator\n\n1. Start with projectbrain.project.list and projectbrain.context.get when the ProjectBrain connector is available. Durable ProjectBrain state and repository planning files, not chat history, are the source of truth.\n2. ProjectBrain is the controller: it owns Auto policy, roadmap state, dependencies, recovery and human-attention decisions. Gitea is the only primary repository execution surface. ChatGPT may orchestrate and reason, but code changes must travel through governed Gitea branches, validation, pull requests and merge evidence. No OpenAI API key is required for this operating model.\n3. For implementation work, use the canonical branch returned as work.execution.branch. When working directly from repository planning truth without a ProjectBrain claim call, derive the same branch as projectbrain/auto/: lowercase the durable task id and replace non alphanumeric, dot, underscore or hyphen runs with hyphens. The existence of that branch is the atomic execution claim. If it already exists, resume it; never create a competing branch for the same task.\n4. Load repository context from the current default branch, then resume or create the canonical non-default work branch. Use the Gitea workspace changeset flow for edits, run the narrowest managed validation profile and a full profile before merge when required, open or reuse the PR, confirm exact-head validation evidence, review the diff, and merge the exact validated head. Direct default-branch writes remain forbidden.\n5. An open PR is unfinished. A work item is implementation-complete only when its canonical PR is merged and the exact PR head has successful required Gitea Actions evidence. ProjectBrain then reconciles that evidence and downstream state back into Auto Mode.\n6. heartbeat_auto_executor, claim_auto_work and report_auto_work remain backward-compatible controller coordination tools. They are not the repository correctness authority. A missing or interrupted report_auto_work callback must never invalidate a valid Gitea branch/PR/validation/merge chain. When a compatibility lease is present, reuse it; after Gitea reconciliation a late report is an idempotent replay.\n7. For brainstorm or phase-review work, inspect ProjectBrain context plus repository roadmap/backlog, findings and evidence. Persist actionable implementation work into repository planning truth or ProjectBrain durable tasks, then execute resulting code work through the same canonical Gitea path. Do not invent routine approvals.\n8. Treat transient runner, Gitea, network and provider failures as retryable. Escalate to human attention only for credentials, policy authorization, destructive risk or a genuine product decision. Local models such as Ollama are optional helpers when explicitly permitted; they do not change the Gitea execution contract.\n`, + }, + { + name: "repository-bootstrap", + description: "Create a branch-ready Gitea repository, onboard it into ProjectBrain immediately, and verify its governed workspace without manual catalog changes.", + body: `# Repository bootstrap\n\n1. Call gitea.repository.create with an explicit name, description, private value, autoInit=true and defaultBranch=main. Reuse the returned repositoryRef; never guess one.\n2. Immediately call projectbrain.portfolio.repository.reconcile for only the returned owner/repository with apply=true. Production also auto-onboards future repositories every fifteen minutes, so a retry is safe and idempotent.\n3. Verify the link with projectbrain.portfolio.repository.list or projectbrain.repository.list and load projectbrain.context.get before planning work.\n4. Inspect gitea.branchprotection.get and use the preview/ensure pair when governance is missing.\n5. Continue changes only on an isolated non-default branch and report the repository URL, repositoryRef, ProjectBrain project and write mode.\n`, + }, + { + name: "feature-delivery", + description: "Plan, implement, validate, review, and merge repository work through ProjectBrain's governed feature-delivery loop.", + body: `# Feature delivery\n\n1. Load projectbrain.delivery.get when resuming; durable state, not chat history, is the source of truth.\n2. Create a complete plan. ProjectBrain auto-authorizes owner-controlled MCP delivery plans, so continue directly to start or resume the valid isolated non-default delivery branch instead of asking for routine plan approval.\n3. Apply up to 25 create/update/delete operations atomically with projectbrain.delivery.changeset.apply. Refresh the branch head and file SHAs before every changeset and retry only after re-reading a conflict.\n4. Resume through gitea.workspace.status. Dispatch the narrowest fixed validation profile with gitea.workspace.run and use full before merge when supported. Respect pollAfterMs and poll only while terminal=false; after a transient runner failure, dispatch the same managed profile again instead of using the Gitea UI. Never stream logs into the browser unless diagnosing one selected failed job.\n5. Open or update the pull request, synchronize exact-head validation evidence, and inspect gitea.change.review.\n6. When completion/merge is already part of the user's instruction or an active Auto Mode roadmap, call projectbrain.delivery.merge with the exact head SHA after validation. It is idempotent across retries, rechecks Actions evidence, reconciles ProjectBrain and can clean the merged branch.\n7. Import a compact final execution summary as durable feedback and report changed paths, validation evidence, PR URL and merge state.\n`, + }, + { + name: "portfolio-curator", + description: "Work consistently across the complete ProjectBrain/Gitea portfolio while keeping repository-specific context isolated.", + body: `# Portfolio curator\n\n1. Start with projectbrain.portfolio.get. Reconcile newly discovered repositories immediately when the user wants them managed; automatic onboarding is the fallback.\n2. Run broad inspection as a persistent projectbrain.analysis.start job. Respect status.pollAfterMs and poll only while terminal=false, then page projectbrain.analysis.results instead of loading repository trees or logs into the browser.\n3. Use search and fetch only for focused cross-project discovery, then load the selected project's context.\n4. Keep findings attributed to their project, repository, ref, path and SHA. Prefer bounded batches and summarize partial failures instead of silently dropping them.\n5. Sync actionable findings to Gitea issues, investigate them, and create auto-authorized owner deliveries through the focused closed loop. Store reusable conclusions in the correct ProjectBrain project for review.\n`, + }, +]; + +const resources = new Map(); +const skills = definitions.map(({ name, description, body }) => { + const frontmatter = { name, description }; + const text = `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}`; + const uri = `skill://${SERVER_SLUG}/${name}/SKILL.md`; + const digest = `sha256:${createHash("sha256").update(text).digest("hex")}`; + resources.set(uri, { uri, mimeType: "text/markdown", text, digest }); + return { uri, frontmatter, resources: [{ uri, digest }] }; +}); + +export const projectBrainSkills = skills; + +export const handleProjectBrainSkillMethod = (method: string, params: Record) => { + if (method === "skills/list") { + if (params.cursor !== undefined && params.cursor !== null) throw new Error("MCP_SKILLS_CURSOR_INVALID"); + return { skills }; + } + if (method === "skills/get") { + const skill = skills.find((item) => item.uri === params.uri); + if (!skill) throw new Error("MCP_SKILL_NOT_FOUND"); + return { skill }; + } + if (method === "resources/read" && typeof params.uri === "string" && params.uri.startsWith("skill://")) { + const resource = resources.get(params.uri); + if (!resource) throw new Error("MCP_SKILL_RESOURCE_NOT_FOUND"); + return { contents: [{ uri: resource.uri, mimeType: resource.mimeType, text: resource.text }] }; + } + return undefined; +}; + +export const augmentProjectBrainInitialize = (payload: unknown) => { + if (!payload || typeof payload !== "object") return payload; + const document = payload as { result?: { capabilities?: Record; instructions?: string } }; + if (!document.result || typeof document.result !== "object") return payload; + const capabilities = document.result.capabilities ?? {}; + const extensions = capabilities.extensions && typeof capabilities.extensions === "object" ? capabilities.extensions as Record : {}; + extensions[EXTENSION_ID] ??= {}; + document.result.capabilities = { ...capabilities, extensions }; + const autoInstruction = "ProjectBrain Auto Mode uses Gitea as its only primary repository execution surface. ProjectBrain remains the durable controller for roadmap, dependencies, recovery and human decisions; ChatGPT may orchestrate reasoning, but implementation changes must use governed Gitea branches, exact-head validation, pull requests and merge. For an Auto implementation item, use work.execution.branch when available or the deterministic projectbrain/auto/ branch when working directly from repository planning truth. Branch existence is the atomic claim: resume an existing branch instead of creating a duplicate. Completion is reconciled from the canonical PR, successful exact-head Gitea Actions evidence, its merge commit and downstream ProjectBrain state. heartbeat_auto_executor, claim_auto_work and report_auto_work are backward-compatible coordination/bookkeeping surfaces only; missing report_auto_work must never make a valid Gitea execution appear incomplete. Never write directly to the default branch, bypass validation, or create parallel branches for one durable work id."; + document.result.instructions = document.result.instructions ? `${document.result.instructions}\n\n${autoInstruction}` : autoInstruction; + return document; +}; diff --git a/backend/src/mcp/widget.ts b/backend/src/mcp/widget.ts new file mode 100644 index 0000000..14c6c82 --- /dev/null +++ b/backend/src/mcp/widget.ts @@ -0,0 +1,15 @@ +// The resource URI is the host-side cache key. Bump it whenever the widget +// document or its MCP Apps contract changes so ChatGPT cannot reuse stale HTML. +export const PROJECTBRAIN_WORKSPACE_URI = "ui://projectbrain/workspace-v2.html"; +export const PROJECTBRAIN_WORKSPACE_MIME_TYPE = "text/html;profile=mcp-app"; + +export const PROJECTBRAIN_WORKSPACE_HTML = String.raw` + +ProjectBrain workspace
ProjectBrain laden…
`; diff --git a/backend/src/plugins/authentication.ts b/backend/src/plugins/authentication.ts new file mode 100644 index 0000000..8246cca --- /dev/null +++ b/backend/src/plugins/authentication.ts @@ -0,0 +1,52 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { AppEnv } from "../config/env.js"; +import type { AuthUser } from "../security/authStore.js"; +import { canAccessProject, resolveSession } from "../security/authStore.js"; +import { errorEnvelope } from "../utils/envelope.js"; +import { getRequestId } from "./requestId.js"; + +declare module "fastify" { + interface FastifyRequest { + authUser?: AuthUser; + authCsrfToken?: string; + } +} + +export const SESSION_COOKIE = "projectbrain_session"; +export const sessionCookieOptions = (env: AppEnv) => ({ + // OIDC returns through a cross-site top-level navigation. Lax keeps that + // callback usable while the separate CSRF token still protects mutations. + path: "/api", httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "lax" as const, + signed: true, maxAge: env.PROJECTBRAIN_SESSION_TTL_HOURS * 60 * 60, +}); +const PUBLIC_PATHS = new Set(["/api/health", "/api/system/readiness", "/api/metrics", "/api/auth/login", "/api/auth/entra/config", "/api/auth/entra/start", "/api/auth/entra/callback", "/api/integrations/gitea/webhook", "/mcp", "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/oauth/register", "/oauth/authorize", "/oauth/token"]); +const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +const firstValue = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; + +export const authenticationPlugin = async (app: FastifyInstance, env: AppEnv) => { + app.decorateRequest("authUser", undefined); + app.decorateRequest("authCsrfToken", undefined); + app.addHook("onRequest", async (request, reply) => { + const path = request.url.split("?")[0]; + if (request.method === "OPTIONS" || PUBLIC_PATHS.has(path)) return; + const signedToken = request.cookies[SESSION_COOKIE]; + const unsigned = signedToken ? request.unsignCookie(signedToken) : undefined; + const token = unsigned?.valid ? unsigned.value : undefined; + const session = token ? await resolveSession(env, token) : undefined; + if (!session) { + return reply.status(401).send(errorEnvelope("AUTHENTICATION_REQUIRED", "Authentication required", getRequestId(request))); + } + // Renew both the server-side expiry and browser cookie while the user is active. + reply.setCookie(SESSION_COOKIE, token!, sessionCookieOptions(env)); + request.authUser = session.user; + request.authCsrfToken = session.csrfToken; + if (MUTATING.has(request.method) && firstValue(request.headers["x-csrf-token"]) !== session.csrfToken) { + return reply.status(403).send(errorEnvelope("CSRF_TOKEN_INVALID", "A valid CSRF token is required", getRequestId(request))); + } + const match = path.match(/^\/api\/projects\/([^/]+)/); + if (match && !(await canAccessProject(env, session.user, decodeURIComponent(match[1]), MUTATING.has(request.method)))) { + return reply.status(403).send(errorEnvelope("PROJECT_ACCESS_DENIED", "Project access denied", getRequestId(request))); + } + }); +}; diff --git a/backend/src/plugins/errorHandler.ts b/backend/src/plugins/errorHandler.ts new file mode 100644 index 0000000..fa188a9 --- /dev/null +++ b/backend/src/plugins/errorHandler.ts @@ -0,0 +1,72 @@ +import type { FastifyInstance } from "fastify"; +import { getRequestId } from "./requestId.js"; +import { errorEnvelope } from "../utils/envelope.js"; + +type NormalizedError = { + code?: string; + message: string; + statusCode?: number; +}; + +const normalizeError = (error: unknown): NormalizedError => { + if (error instanceof Error) { + const maybeFastifyError = error as Error & { + code?: string; + statusCode?: number; + }; + + return { + code: maybeFastifyError.code, + message: maybeFastifyError.message, + statusCode: maybeFastifyError.statusCode, + }; + } + + return { + message: "Internal server error", + }; +}; + +export const errorHandlerPlugin = async (app: FastifyInstance) => { + app.setErrorHandler((error, request, reply) => { + const normalizedError = normalizeError(error); + const requestId = getRequestId(request); + const statusCode = + normalizedError.statusCode && + normalizedError.statusCode >= 400 && + normalizedError.statusCode < 600 + ? normalizedError.statusCode + : 500; + + request.log.error({ err: error, requestId }, "Request failed"); + + if (statusCode >= 500) { + reply + .status(statusCode) + .send( + errorEnvelope( + "INTERNAL_SERVER_ERROR", + "Internal server error", + requestId, + ), + ); + return; + } + + reply + .status(statusCode) + .send( + errorEnvelope( + normalizedError.code ?? "REQUEST_ERROR", + normalizedError.message, + requestId, + ), + ); + }); + + app.setNotFoundHandler((request, reply) => { + reply + .status(404) + .send(errorEnvelope("ROUTE_NOT_FOUND", "Route not found", getRequestId(request))); + }); +}; diff --git a/backend/src/plugins/observability.ts b/backend/src/plugins/observability.ts new file mode 100644 index 0000000..e67dc7c --- /dev/null +++ b/backend/src/plugins/observability.ts @@ -0,0 +1,73 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { performance } from "node:perf_hooks"; + +declare module "fastify" { + interface FastifyRequest { + observationStartedAt: number; + operationId?: string; + } +} + +type RouteMetric = { count: number; errors: number; durationMs: number; maxDurationMs: number }; +const routeMetrics = new Map(); + +const safeSegment = (value: unknown): string | undefined => + typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : undefined; + +const routeLabel = (request: FastifyRequest): string => + (request.routeOptions?.url ?? request.url.split("?")[0] ?? "unknown").replaceAll('"', ""); + +export const observabilityPlugin = async (app: FastifyInstance) => { + app.addHook("onRequest", async (request) => { + request.observationStartedAt = performance.now(); + request.operationId = safeSegment(request.headers["x-operation-id"] ?? request.headers["idempotency-key"]); + }); + + app.addHook("onResponse", async (request, reply) => { + if (request.url.startsWith("/api/metrics")) return; + const durationMs = Math.max(0, performance.now() - request.observationStartedAt); + const route = routeLabel(request); + const key = `${request.method} ${route}`; + const current = routeMetrics.get(key) ?? { count: 0, errors: 0, durationMs: 0, maxDurationMs: 0 }; + current.count += 1; + current.errors += reply.statusCode >= 500 ? 1 : 0; + current.durationMs += durationMs; + current.maxDurationMs = Math.max(current.maxDurationMs, durationMs); + routeMetrics.set(key, current); + const params = request.params as { projectId?: unknown } | undefined; + request.log.info({ + event: "http_request_completed", + requestId: request.requestId, + operationId: request.operationId, + projectId: safeSegment(params?.projectId), + method: request.method, + route, + statusCode: reply.statusCode, + durationMs: Number(durationMs.toFixed(2)), + }, "Request completed"); + }); +}; + +export const renderHttpMetrics = (): string => { + const lines = [ + "# HELP projectbrain_http_requests_total Completed HTTP requests.", + "# TYPE projectbrain_http_requests_total counter", + "# HELP projectbrain_http_errors_total Completed HTTP requests with a 5xx response.", + "# TYPE projectbrain_http_errors_total counter", + "# HELP projectbrain_http_request_duration_ms_sum Sum of request duration in milliseconds.", + "# TYPE projectbrain_http_request_duration_ms_sum counter", + "# HELP projectbrain_http_request_duration_ms_max Maximum observed request duration in milliseconds.", + "# TYPE projectbrain_http_request_duration_ms_max gauge", + ]; + for (const [key, metric] of [...routeMetrics.entries()].sort(([left], [right]) => left.localeCompare(right))) { + const separator = key.indexOf(" "); + const method = key.slice(0, separator); + const route = key.slice(separator + 1); + const labels = `method="${method}",route="${route}"`; + lines.push(`projectbrain_http_requests_total{${labels}} ${metric.count}`); + lines.push(`projectbrain_http_errors_total{${labels}} ${metric.errors}`); + lines.push(`projectbrain_http_request_duration_ms_sum{${labels}} ${metric.durationMs.toFixed(3)}`); + lines.push(`projectbrain_http_request_duration_ms_max{${labels}} ${metric.maxDurationMs.toFixed(3)}`); + } + return `${lines.join("\n")}\n`; +}; diff --git a/backend/src/plugins/requestId.ts b/backend/src/plugins/requestId.ts new file mode 100644 index 0000000..3e7b3a8 --- /dev/null +++ b/backend/src/plugins/requestId.ts @@ -0,0 +1,32 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { randomUUID } from "node:crypto"; + +declare module "fastify" { + interface FastifyRequest { + requestId: string; + } +} + +const firstHeaderValue = ( + value: string | string[] | undefined, +): string | undefined => (Array.isArray(value) ? value[0] : value); + +export const createRequestId = ( + incomingRequestId: string | string[] | undefined, +): string => { + const requestId = firstHeaderValue(incomingRequestId); + + return requestId && /^[A-Za-z0-9_-]{1,128}$/.test(requestId) + ? requestId + : `req-${randomUUID()}`; +}; + +export const requestIdPlugin = async (app: FastifyInstance) => { + app.addHook("onRequest", async (request, reply) => { + request.requestId = request.id; + reply.header("x-request-id", request.requestId); + }); +}; + +export const getRequestId = (request: FastifyRequest): string => + request.requestId ?? request.id ?? `req-${randomUUID()}`; diff --git a/backend/src/repositories/analysisJobRepository.ts b/backend/src/repositories/analysisJobRepository.ts new file mode 100644 index 0000000..76a4ada --- /dev/null +++ b/backend/src/repositories/analysisJobRepository.ts @@ -0,0 +1,314 @@ +import { createHash } from "node:crypto"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { getGiteaClient, listLinkedRepositories, type LinkedRepository } from "../integrations/gitea/service.js"; +import type { GiteaRepository } from "../integrations/gitea/types.js"; +import { chooseAnalysisStrategy, type AnalysisStrategy } from "../services/analysisStrategy.js"; + +export type PortfolioAnalysisRequest = { + projectIds?: string[]; + repositoryIds?: string[]; + includeBranches: boolean; + includePullRequests: boolean; + includeActions: boolean; + changedPaths?: string[]; + strategy?: AnalysisStrategy; + strategyReason?: string; +}; + +const TERMINAL_STATUSES = ["succeeded", "partial", "failed", "cancelled"] as const; +const MAX_REPOSITORIES = 200; +export const projectAnalysisItemScope = (projectIdOrSlug: string) => ({ OR: [{ projectId: projectIdOrSlug }, { projectSlug: projectIdOrSlug }] }); + +const canonicalRequest = (input: Partial): PortfolioAnalysisRequest => ({ + projectIds: [...new Set((input.projectIds ?? []).map((value) => value.trim()).filter(Boolean))].sort(), + repositoryIds: [...new Set((input.repositoryIds ?? []).map((value) => value.trim()).filter(Boolean))].sort(), + includeBranches: input.includeBranches ?? true, + includePullRequests: input.includePullRequests ?? true, + includeActions: input.includeActions ?? true, + ...(input.changedPaths?.length ? { changedPaths: [...new Set(input.changedPaths.map((value) => value.trim()).filter(Boolean))].slice(0, 200) } : {}), + ...(input.strategy ? { strategy: input.strategy } : {}), + ...(input.strategyReason ? { strategyReason: input.strategyReason } : {}), +}); + +export type AnalysisTarget = Pick & { + repositoryLinkId?: string; +}; + +const matchesRepository = (target: AnalysisTarget, request: PortfolioAnalysisRequest) => { + return !request.repositoryIds?.length || request.repositoryIds.includes(target.id) || request.repositoryIds.includes(target.fullName); +}; + +export const resolveAnalysisTargets = ( + links: LinkedRepository[], + repositories: GiteaRepository[], + request: PortfolioAnalysisRequest, +): AnalysisTarget[] => { + let targets: AnalysisTarget[]; + if (request.projectIds?.length) { + targets = links + .filter((link) => request.projectIds!.includes(link.projectId) || request.projectIds!.includes(link.projectSlug)) + .map((link) => ({ ...link, repositoryLinkId: link.id })); + } else { + const byFullName = new Map(links.map((link) => [link.fullName.toLowerCase(), link])); + targets = repositories.map((repository) => { + const link = byFullName.get(repository.full_name.toLowerCase()); + return { + id: link?.id ?? `gitea:${repository.full_name}`, + repositoryLinkId: link?.id, + projectId: link?.projectId ?? "", + projectSlug: link?.projectSlug ?? "gitea-all", + projectName: link?.projectName ?? "Gitea portfolio", + owner: repository.owner.login, + repository: repository.name, + fullName: repository.full_name, + defaultBranch: repository.default_branch || "main", + accessMode: link?.accessMode ?? (repository.permissions?.push || repository.permissions?.admin ? "read_write" : "read_only"), + }; + }); + } + return targets.filter((target) => matchesRepository(target, request)).sort((left, right) => left.fullName.localeCompare(right.fullName)); +}; + +const jobSelection = { + id: true, + kind: true, + status: true, + actor: true, + progressCurrent: true, + progressTotal: true, + summaryText: true, + summaryJson: true, + errorCode: true, + cancelRequestedAt: true, + queuedAt: true, + startedAt: true, + heartbeatAt: true, + completedAt: true, + expiresAt: true, + createdAt: true, + updatedAt: true, +} as const; + +export const serializeAnalysisJob = (job: Record) => { + const serialized = Object.fromEntries( + Object.entries(job).map(([key, value]) => [key, value instanceof Date ? value.toISOString() : value]), + ); + const status = typeof serialized.status === "string" ? serialized.status : "unknown"; + const terminal = TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]); + const stateVersion = createHash("sha256").update(JSON.stringify({ + id: serialized.id, + status, + progressCurrent: serialized.progressCurrent, + progressTotal: serialized.progressTotal, + summaryText: serialized.summaryText, + errorCode: serialized.errorCode, + updatedAt: serialized.updatedAt, + })).digest("hex").slice(0, 24); + const nextAction = status === "succeeded" + ? "read-results" + : status === "partial" + ? "read-results-and-inspect-failures" + : status === "failed" + ? "inspect-summary-or-restart" + : status === "cancelled" + ? "restart-if-needed" + : "wait-for-change"; + return { ...serialized, stateVersion, terminal, pollAfterMs: terminal ? 0 : status === "queued" ? 5_000 : 4_000, nextAction }; +}; + +export const createPortfolioAnalysisJob = async ( + env: AppEnv, + actor: string, + input: Partial, +) => { + const request = canonicalRequest(input); + const links = await listLinkedRepositories(); + const repositories = request.projectIds?.length ? [] : await getGiteaClient(env).listRepositories(); + const targets = resolveAnalysisTargets(links, repositories, request); + if (!targets.length) throw new Error("ANALYSIS_REPOSITORIES_NOT_FOUND"); + if (targets.length > MAX_REPOSITORIES) throw new Error("ANALYSIS_REPOSITORY_LIMIT_EXCEEDED"); + + const lastFullScan = await prisma.analysisJob.findFirst({ where: { status: "succeeded", items: { some: { repositoryLinkId: { in: targets.map((target) => target.repositoryLinkId ?? target.id) } } } }, orderBy: { completedAt: "desc" }, select: { completedAt: true } }); + const analysisPlan = chooseAnalysisStrategy({ changedPaths: request.changedPaths, lastFullScanAt: lastFullScan?.completedAt?.toISOString(), budgetItems: targets.length }); + const persistedRequest = { ...request, strategy: request.strategy ?? analysisPlan.strategy, strategyReason: request.strategyReason ?? analysisPlan.reason, repositoryIds: targets.map((target) => target.id) }; + const requestHash = createHash("sha256").update(JSON.stringify(persistedRequest)).digest("hex"); + const activeDedupeKey = createHash("sha256").update(`${actor}:portfolio_health:${requestHash}`).digest("hex"); + const existing = await prisma.analysisJob.findUnique({ where: { activeDedupeKey }, select: jobSelection }); + if (existing) return { job: serializeAnalysisJob(existing), deduplicated: true }; + + try { + const job = await prisma.analysisJob.create({ + data: { + actor, + requestJson: persistedRequest as never, + requestHash, + activeDedupeKey, + progressTotal: targets.length, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + items: { + create: targets.map((target, sequence) => ({ + repositoryLinkId: target.repositoryLinkId, + projectId: target.projectId || undefined, + projectSlug: target.projectSlug, + projectName: target.projectName, + owner: target.owner, + repository: target.repository, + defaultBranch: target.defaultBranch, + accessMode: target.accessMode, + sequence, + })), + }, + }, + select: jobSelection, + }); + return { job: serializeAnalysisJob(job), deduplicated: false }; + } catch (error) { + const raced = await prisma.analysisJob.findUnique({ where: { activeDedupeKey }, select: jobSelection }); + if (raced) return { job: serializeAnalysisJob(raced), deduplicated: true }; + throw error; + } +}; + +export const getAnalysisJob = async (actor: string, jobId: string) => { + const job = await prisma.analysisJob.findFirst({ where: { id: jobId, actor }, select: jobSelection }); + if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND"); + return serializeAnalysisJob(job); +}; + +export const listAnalysisJobs = async (actor: string, limit = 10) => { + const jobs = await prisma.analysisJob.findMany({ + where: { actor }, + orderBy: { createdAt: "desc" }, + take: Math.min(Math.max(limit, 1), 25), + select: jobSelection, + }); + return jobs.map((job) => serializeAnalysisJob(job)); +}; + +export const listProjectAnalysisJobs = async (projectId: string, limit = 10) => { + const jobs = await prisma.analysisJob.findMany({ + where: { items: { some: projectAnalysisItemScope(projectId) } }, + orderBy: { createdAt: "desc" }, + take: Math.min(Math.max(limit, 1), 25), + select: jobSelection, + }); + return jobs.map((job) => serializeAnalysisJob(job)); +}; + +export const getProjectAnalysisJob = async (projectId: string, jobId: string) => { + const job = await prisma.analysisJob.findFirst({ where: { id: jobId, items: { some: projectAnalysisItemScope(projectId) } }, select: jobSelection }); + if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND"); + return serializeAnalysisJob(job); +}; + +export const getPortfolioAnalysisJob = async (jobId: string) => { + const job = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: jobSelection }); + if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND"); + return serializeAnalysisJob(job); +}; + +export const cancelAnalysisJob = async (actor: string, jobId: string) => { + const job = await prisma.analysisJob.findFirst({ where: { id: jobId, actor }, select: { id: true, status: true } }); + if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND"); + if (TERMINAL_STATUSES.includes(job.status as (typeof TERMINAL_STATUSES)[number])) return getAnalysisJob(actor, jobId); + const now = new Date(); + await prisma.$transaction([ + prisma.analysisJob.update({ + where: { id: job.id }, + data: job.status === "queued" + ? { status: "cancelled", cancelRequestedAt: now, completedAt: now, activeDedupeKey: null, summaryText: "Analyse geannuleerd voordat de worker startte." } + : { cancelRequestedAt: now }, + }), + ...(job.status === "queued" ? [prisma.analysisJobItem.updateMany({ where: { jobId: job.id, status: "pending" }, data: { status: "cancelled", completedAt: now } })] : []), + ]); + return getAnalysisJob(actor, jobId); +}; + +export const cancelProjectAnalysisJob = async (projectId: string, jobId: string) => { + const job = await prisma.analysisJob.findFirst({ where: { id: jobId, items: { some: projectAnalysisItemScope(projectId) } }, select: { id: true, actor: true } }); + if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND"); + return cancelAnalysisJob(job.actor, job.id); +}; + +export const cancelPortfolioAnalysisJob = async (jobId: string) => { + const job = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: { actor: true } }); + if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND"); + return cancelAnalysisJob(job.actor, jobId); +}; + +type AnalysisResultScope = { actor?: string; projectId?: string; portfolio?: boolean }; + +const getScopedAnalysisJobResults = async ( + scope: AnalysisResultScope, + jobId: string, + input: { cursor?: string; pageSize?: number; characterBudget?: number } = {}, +) => { + const where = scope.projectId + ? { id: jobId, items: { some: projectAnalysisItemScope(scope.projectId) } } + : scope.portfolio + ? { id: jobId } + : { id: jobId, actor: scope.actor ?? "" }; + const job = await prisma.analysisJob.findFirst({ where, select: jobSelection }); + if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND"); + const cursor = Number.parseInt(input.cursor ?? "0", 10); + if (!Number.isInteger(cursor) || cursor < 0) throw new Error("ANALYSIS_CURSOR_INVALID"); + const pageSize = Math.min(Math.max(input.pageSize ?? 5, 1), 10); + const characterBudget = Math.min(Math.max(input.characterBudget ?? 24_000, 4_000), 32_000); + const rows = await prisma.analysisJobItem.findMany({ + where: { jobId, ...(scope.projectId ? projectAnalysisItemScope(scope.projectId) : {}) }, + orderBy: { sequence: "asc" }, + skip: cursor, + take: pageSize + 1, + }); + + let usedCharacters = 0; + const items: Array> = []; + for (const row of rows.slice(0, pageSize)) { + const detail = row.detailJson && typeof row.detailJson === "object" ? row.detailJson : undefined; + const detailCharacters = detail ? JSON.stringify(detail).length : 0; + const includeDetail = usedCharacters + detailCharacters <= characterBudget; + items.push({ + repositoryId: row.repositoryLinkId ?? `gitea:${row.owner}/${row.repository}`, + projectId: row.projectId, + projectSlug: row.projectSlug, + projectName: row.projectName, + repository: `${row.owner}/${row.repository}`, + status: row.status, + summary: row.summary, + findingCount: row.findingCount, + errorCode: row.errorCode, + ...(includeDetail && detail ? { detail } : { detailTruncated: Boolean(detail) }), + }); + if (includeDetail) usedCharacters += detailCharacters; + } + const consumed = items.length; + const hasMore = rows.length > pageSize; + return { + job: serializeAnalysisJob(job), + items, + cursor: String(cursor), + pageSize, + characterBudget, + usedCharacters, + nextCursor: hasMore ? String(cursor + consumed) : undefined, + }; +}; + +export const getAnalysisJobResults = async ( + actor: string, + jobId: string, + input: { cursor?: string; pageSize?: number; characterBudget?: number } = {}, + projectId?: string, +) => getScopedAnalysisJobResults(projectId ? { projectId } : { actor }, jobId, input); + +export const getProjectAnalysisJobResults = async ( + projectId: string, + jobId: string, + input: { cursor?: string; pageSize?: number; characterBudget?: number } = {}, +) => getAnalysisJobResults("", jobId, input, projectId); + +export const getPortfolioAnalysisJobResults = async ( + jobId: string, + input: { cursor?: string; pageSize?: number; characterBudget?: number } = {}, +) => getScopedAnalysisJobResults({ portfolio: true }, jobId, input); diff --git a/backend/src/repositories/autonomyExecutionEvidence.ts b/backend/src/repositories/autonomyExecutionEvidence.ts new file mode 100644 index 0000000..e98a0f3 --- /dev/null +++ b/backend/src/repositories/autonomyExecutionEvidence.ts @@ -0,0 +1,18 @@ +export type AutonomyExecutionTarget = { + surface: "gitea"; + claimMode: "branch"; + completionMode: "validated_merge"; + repositoryLinkId: string; + owner: string; + repository: string; + fullName: string; + defaultBranch: string; + branch: string; +}; + +export const hasGiteaAutonomyCompletionEvidence = (evidence: string[] | undefined) => { + const values = evidence ?? []; + return values.some((entry) => entry.startsWith("gitea:auto:merge:")) + && values.some((entry) => entry.startsWith("gitea:auto:head:")) + && values.some((entry) => entry.startsWith("gitea:auto:validation:")); +}; diff --git a/backend/src/repositories/autonomyRepository.ts b/backend/src/repositories/autonomyRepository.ts new file mode 100644 index 0000000..c81b075 --- /dev/null +++ b/backend/src/repositories/autonomyRepository.ts @@ -0,0 +1,797 @@ +import { autonomyProfileSettings, type AutonomyProfile } from "@projectbrain/contracts"; +import { randomUUID } from "node:crypto"; +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import { autonomyRetryDelayMinutes, classifyAutonomyBlocker, recoverStoredBlockerKind } from "../services/autonomyRecovery.js"; +import { addDiscoveredTasks, hasOpenAutonomyExecutionItems, isAutonomyItemInExecutionScope, orderAutonomyRepairs, workPrompt } from "../services/autonomyWork.js"; +import { getFeatureDelivery } from "./featureDeliveryRepository.js"; +import { createBrainstormForProject } from "./brainstormRepository.js"; +import { parseRoadmapMarkdown } from "../services/autonomyRoadmap.js"; +import { decideRecovery } from "../services/recoveryPolicy.js"; +import { hasGiteaAutonomyCompletionEvidence, type AutonomyExecutionTarget } from "./autonomyExecutionEvidence.js"; +export { parseRoadmapMarkdown }; +export { autonomyRetryDelayMinutes, classifyAutonomyBlocker }; +export { hasGiteaAutonomyCompletionEvidence }; +export type { AutonomyExecutionTarget }; +export type AutonomyStatus = "off" | "waiting_roadmap" | "running" | "blocked" | "completed"; +export type AutonomyWorkKind = "brainstorm" | "implement" | "validate_phase"; +export type AutonomyWorkOutcome = "success" | "failed" | "blocked"; +export type RoadmapItemStatus = "pending" | "in_progress" | "completed" | "blocked"; +export type RoadmapPhaseStatus = "pending" | "active" | "brainstorming" | "executing" | "validating" | "completed" | "blocked"; + +export type AutonomyRoadmapItem = { + id: string; + title: string; + instruction?: string; + source?: "roadmap" | "user_assignment" | "brainstorm" | "recovery" | "finding" | "delivery" | "release" | "remediation" | "feature" | "bug"; + createdAt?: string; + status: RoadmapItemStatus; + attempts: number; + priority?: number; + scope?: "required" | "committed" | "stretch" | "future"; + risk?: "low" | "normal" | "medium" | "high" | "critical"; + estimatedScope?: number; + releaseBlocking?: boolean; + waitingExternal?: boolean; + touchedPaths?: string[]; + domain?: string; + deliveryId?: string; + dependsOn?: string[]; + lastResult?: string; + lastError?: string; + completionEvidence?: string[]; + supersededBy?: string; +}; + +export type AutonomyRoadmapPhase = { + id: string; + title: string; + objective: string; + status: RoadmapPhaseStatus; + brainstormIterations: number; + brainstormConverged: boolean; + validationFailures: number; + items: AutonomyRoadmapItem[]; +}; + +export type AutonomyRoadmap = { + id: string; + title: string; + sourceMarkdown: string; + status: "active" | "completed" | "blocked"; + currentPhaseIndex: number; + phases: AutonomyRoadmapPhase[]; + importedAt: string; + completedAt?: string; +}; + +export type AutonomyLease = { + token: string; + kind: AutonomyWorkKind; + actor: string; + runId?: string; + roadmapId: string; + phaseId: string; + itemId?: string; + createdAt: string; + expiresAt: string; +}; + +export type AutonomyCompletedLease = { + token: string; + outcome: AutonomyWorkOutcome; + summary: string; + addedTasks: string[]; + completedAt: string; +}; + +export type AutonomyRun = { + id: string; + actor: string; + startedAt: string; + reportedWorkItems: number; +}; + +export type AutonomyEvent = { + id: string; + at: string; + type: string; + actor: string; + summary: string; +}; + +export type AutonomyState = { + version: 1; + profile: AutonomyProfile; + enabled: boolean; + status: AutonomyStatus; + autoBrainstorm: boolean; + maxBrainstormIterations: number; + maxConsecutiveFailures: number; + leaseMinutes: number; + maxWorkItemsPerRun: number; + maxRunMinutes: number; + pauseAfterCurrent: boolean; + schedulingPolicy: "balanced" | "downstream-impact"; + roadmap?: AutonomyRoadmap; + activeLease?: AutonomyLease; + blocker?: { at: string; phaseId?: string; itemId?: string; reason: string; kind: "human" | "retryable"; retryAt?: string; recoveryStage?: "retry" | "cooldown" | "alternate_recovery" | "escalated"; recoveryAttempts?: number; recoveryStartedAt?: string }; + recoveryStartedAt?: string; + recoveryAttempts?: number; + maxRecoveryMinutes?: number; + runs: AutonomyRun[]; + completedLeases: AutonomyCompletedLease[]; + events: AutonomyEvent[]; + updatedAt: string; + updatedBy: string; +}; + +export type AutonomyWork = { + token: string; + kind: AutonomyWorkKind; + projectId: string; + roadmapId: string; + roadmapTitle: string; + phaseId: string; + phaseTitle: string; + phaseObjective: string; + itemId?: string; + itemTitle?: string; + deliveryId?: string; + execution?: AutonomyExecutionTarget; + prompt: string; + expiresAt: string; +}; + +export type AutonomyReportInput = { + token: string; + outcome: AutonomyWorkOutcome; + summary: string; + deliveryId?: string; + discoveredTasks?: string[]; + brainstorm?: { + contextSummary?: string; + ideas?: Array<{ id: string; title: string; description: string; score?: number }>; + outcomes?: string[]; + selectedIdeaIds?: string[]; + nextActions?: string[]; + }; + blockerType?: "human" | "retryable"; + retryAfterMinutes?: number; +}; + +export type EnqueueAutonomyTaskInput = { title?: string; instruction: string }; + +const DEFAULTS = { + profile: "normal" as const, + autoBrainstorm: true, + maxBrainstormIterations: 5, + maxConsecutiveFailures: 3, + leaseMinutes: 30, + maxWorkItemsPerRun: 8, + maxRunMinutes: 45, + maxRecoveryMinutes: 120, +} as const; + +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +const asString = (value: unknown, fallback = "") => typeof value === "string" ? value : fallback; +const asBoolean = (value: unknown, fallback: boolean) => typeof value === "boolean" ? value : fallback; +const asInt = (value: unknown, fallback: number, min: number, max: number) => { + const parsed = typeof value === "number" && Number.isInteger(value) ? value : fallback; + return Math.max(min, Math.min(max, parsed)); +}; +const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const nowIso = () => new Date().toISOString(); + +const event = (type: string, actor: string, summary: string): AutonomyEvent => ({ id: randomUUID(), at: nowIso(), type, actor, summary }); +const appendEvent = (state: AutonomyState, next: AutonomyEvent): AutonomyState => ({ ...state, events: [...state.events, next].slice(-100) }); + +const defaultState = (actor = "system"): AutonomyState => ({ + version: 1, + enabled: false, + status: "off", + profile: DEFAULTS.profile, + autoBrainstorm: DEFAULTS.autoBrainstorm, + maxBrainstormIterations: DEFAULTS.maxBrainstormIterations, + maxConsecutiveFailures: DEFAULTS.maxConsecutiveFailures, + leaseMinutes: DEFAULTS.leaseMinutes, + maxWorkItemsPerRun: DEFAULTS.maxWorkItemsPerRun, + maxRunMinutes: DEFAULTS.maxRunMinutes, + pauseAfterCurrent: false, + schedulingPolicy: "balanced", + maxRecoveryMinutes: DEFAULTS.maxRecoveryMinutes, + recoveryAttempts: 0, + runs: [], + completedLeases: [], + events: [], + updatedAt: nowIso(), + updatedBy: actor, +}); + +const normalizeItem = (value: unknown): AutonomyRoadmapItem | undefined => { + const row = asRecord(value); + if (!asString(row.id) || !asString(row.title)) return undefined; + const status = ["pending", "in_progress", "completed", "blocked"].includes(asString(row.status)) ? asString(row.status) as RoadmapItemStatus : "pending"; + return { + id: asString(row.id), title: asString(row.title), status, attempts: asInt(row.attempts, 0, 0, 100), + ...(asString(row.instruction) ? { instruction: asString(row.instruction) } : {}), + ...(["roadmap", "user_assignment", "brainstorm", "recovery", "finding", "delivery", "release", "remediation", "feature", "bug"].includes(asString(row.source)) ? { source: asString(row.source) as AutonomyRoadmapItem["source"] } : {}), + ...(asString(row.createdAt) ? { createdAt: asString(row.createdAt) } : {}), + ...(asString(row.deliveryId) ? { deliveryId: asString(row.deliveryId) } : {}), + ...(typeof row.priority === "number" ? { priority: asInt(row.priority, 50, 0, 100) } : {}), + ...(["required", "committed", "stretch", "future"].includes(asString(row.scope)) ? { scope: asString(row.scope) as AutonomyRoadmapItem["scope"] } : {}), + ...(["low", "normal", "medium", "high", "critical"].includes(asString(row.risk)) ? { risk: asString(row.risk) as AutonomyRoadmapItem["risk"] } : {}), + ...(typeof row.estimatedScope === "number" ? { estimatedScope: asInt(row.estimatedScope, 0, 0, 10) } : {}), + ...(typeof row.releaseBlocking === "boolean" ? { releaseBlocking: row.releaseBlocking } : {}), + ...(typeof row.waitingExternal === "boolean" ? { waitingExternal: row.waitingExternal } : {}), + ...(Array.isArray(row.touchedPaths) ? { touchedPaths: row.touchedPaths.filter((path): path is string => typeof path === "string").slice(0, 100) } : {}), + ...(asString(row.domain) ? { domain: asString(row.domain) } : {}), + ...(Array.isArray(row.dependsOn) ? { dependsOn: row.dependsOn.filter((id): id is string => typeof id === "string") } : {}), + ...(asString(row.lastResult) ? { lastResult: asString(row.lastResult) } : {}), + ...(asString(row.lastError) ? { lastError: asString(row.lastError) } : {}), + ...(Array.isArray(row.completionEvidence) ? { completionEvidence: row.completionEvidence.filter((value): value is string => typeof value === "string").slice(0, 50) } : {}), + ...(asString(row.supersededBy) ? { supersededBy: asString(row.supersededBy) } : {}), + }; +}; + +const normalizePhase = (value: unknown): AutonomyRoadmapPhase | undefined => { + const row = asRecord(value); + if (!asString(row.id) || !asString(row.title)) return undefined; + const statuses = ["pending", "active", "brainstorming", "executing", "validating", "completed", "blocked"]; + return { + id: asString(row.id), + title: asString(row.title), + objective: asString(row.objective, asString(row.title)), + status: statuses.includes(asString(row.status)) ? asString(row.status) as RoadmapPhaseStatus : "pending", + brainstormIterations: asInt(row.brainstormIterations, 0, 0, 100), + brainstormConverged: asBoolean(row.brainstormConverged, false), + validationFailures: asInt(row.validationFailures, 0, 0, 100), + items: Array.isArray(row.items) ? row.items.map(normalizeItem).filter((item): item is AutonomyRoadmapItem => Boolean(item)) : [], + }; +}; + +const normalizeCompletedLease = (value: unknown): AutonomyCompletedLease | undefined => { + const row = asRecord(value); + const outcome = asString(row.outcome); + if (!asString(row.token) || !["success", "failed", "blocked"].includes(outcome)) return undefined; + return { + token: asString(row.token), + outcome: outcome as AutonomyWorkOutcome, + summary: asString(row.summary), + addedTasks: Array.isArray(row.addedTasks) ? row.addedTasks.filter((item): item is string => typeof item === "string") : [], + completedAt: asString(row.completedAt, nowIso()), + }; +}; + +const normalizeRun = (value: unknown): AutonomyRun | undefined => { + const row = asRecord(value); + if (!asString(row.id) || !asString(row.actor) || !asString(row.startedAt)) return undefined; + return { + id: asString(row.id), + actor: asString(row.actor), + startedAt: asString(row.startedAt), + reportedWorkItems: asInt(row.reportedWorkItems, 0, 0, 10_000), + }; +}; + +export const normalizeAutonomyState = (raw: unknown, actor = "system"): AutonomyState => { + const row = asRecord(raw); + if (!Object.keys(row).length) return defaultState(actor); + const roadmapRow = asRecord(row.roadmap); + const phases = Array.isArray(roadmapRow.phases) ? roadmapRow.phases.map(normalizePhase).filter((phase): phase is AutonomyRoadmapPhase => Boolean(phase)) : []; + const roadmap: AutonomyRoadmap | undefined = asString(roadmapRow.id) && phases.length ? { + id: asString(roadmapRow.id), title: asString(roadmapRow.title, "Roadmap"), sourceMarkdown: asString(roadmapRow.sourceMarkdown), + status: ["active", "completed", "blocked"].includes(asString(roadmapRow.status)) ? asString(roadmapRow.status) as AutonomyRoadmap["status"] : "active", + currentPhaseIndex: asInt(roadmapRow.currentPhaseIndex, 0, 0, Math.max(0, phases.length - 1)), phases, + importedAt: asString(roadmapRow.importedAt, nowIso()), ...(asString(roadmapRow.completedAt) ? { completedAt: asString(roadmapRow.completedAt) } : {}), + } : undefined; + const leaseRow = asRecord(row.activeLease); + const leaseKind = asString(leaseRow.kind); + const activeLease: AutonomyLease | undefined = asString(leaseRow.token) && asString(leaseRow.phaseId) && ["brainstorm", "implement", "validate_phase"].includes(leaseKind) ? { + token: asString(leaseRow.token), kind: leaseKind as AutonomyWorkKind, actor: asString(leaseRow.actor), + ...(asString(leaseRow.runId) ? { runId: asString(leaseRow.runId) } : {}), + roadmapId: asString(leaseRow.roadmapId), phaseId: asString(leaseRow.phaseId), ...(asString(leaseRow.itemId) ? { itemId: asString(leaseRow.itemId) } : {}), + createdAt: asString(leaseRow.createdAt), expiresAt: asString(leaseRow.expiresAt), + } : undefined; + const blockerRow = asRecord(row.blocker); + const enabled = asBoolean(row.enabled, false); + const blockerKind = recoverStoredBlockerKind(asString(blockerRow.reason), asString(blockerRow.kind)); + const blockedAt = Date.parse(asString(blockerRow.at)); + const recoveryStage = ["retry", "cooldown", "alternate_recovery", "escalated"].includes(asString(blockerRow.recoveryStage)) ? asString(blockerRow.recoveryStage) as NonNullable["recoveryStage"] : undefined; + const blocker = asString(blockerRow.reason) ? { + at: asString(blockerRow.at, nowIso()), + reason: asString(blockerRow.reason), + kind: blockerKind, + ...(blockerKind === "retryable" ? { retryAt: asString(blockerRow.retryAt) || new Date((Number.isFinite(blockedAt) ? blockedAt : Date.now()) + 15 * 60_000).toISOString() } : {}), + ...(asString(blockerRow.phaseId) ? { phaseId: asString(blockerRow.phaseId) } : {}), + ...(asString(blockerRow.itemId) ? { itemId: asString(blockerRow.itemId) } : {}), + ...(recoveryStage ? { recoveryStage } : {}), + ...(typeof blockerRow.recoveryAttempts === "number" ? { recoveryAttempts: asInt(blockerRow.recoveryAttempts, 0, 0, 100) } : {}), + ...(asString(blockerRow.recoveryStartedAt) ? { recoveryStartedAt: asString(blockerRow.recoveryStartedAt) } : {}), + } : undefined; + const status: AutonomyStatus = !enabled + ? "off" + : !roadmap + ? "waiting_roadmap" + : blocker || roadmap.status === "blocked" + ? "blocked" + : roadmap.status === "completed" + ? "completed" + : "running"; + const leaseMatchesRoadmap = Boolean(enabled && activeLease && roadmap && activeLease.roadmapId === roadmap.id && roadmap.phases.some((phase) => phase.id === activeLease.phaseId && (!activeLease.itemId || phase.items.some((item) => item.id === activeLease.itemId)))); + return { + version: 1, + enabled, status, + profile: ["conservative", "normal", "autonomous", "custom"].includes(asString(row.profile)) ? asString(row.profile) as AutonomyProfile : DEFAULTS.profile, + autoBrainstorm: asBoolean(row.autoBrainstorm, DEFAULTS.autoBrainstorm), + maxBrainstormIterations: asInt(row.maxBrainstormIterations, DEFAULTS.maxBrainstormIterations, 1, 10), + maxConsecutiveFailures: asInt(row.maxConsecutiveFailures, DEFAULTS.maxConsecutiveFailures, 1, 10), + leaseMinutes: asInt(row.leaseMinutes, DEFAULTS.leaseMinutes, 5, 120), + maxWorkItemsPerRun: asInt(row.maxWorkItemsPerRun, DEFAULTS.maxWorkItemsPerRun, 1, 20), + maxRunMinutes: asInt(row.maxRunMinutes, DEFAULTS.maxRunMinutes, 5, 55), + pauseAfterCurrent: asBoolean(row.pauseAfterCurrent, false), + schedulingPolicy: row.schedulingPolicy === "downstream-impact" ? "downstream-impact" : "balanced", + maxRecoveryMinutes: asInt(row.maxRecoveryMinutes, DEFAULTS.maxRecoveryMinutes, 15, 1_440), + recoveryAttempts: asInt(row.recoveryAttempts, 0, 0, 100), + ...(roadmap ? { roadmap } : {}), ...(leaseMatchesRoadmap && activeLease ? { activeLease } : {}), + ...(blocker ? { blocker } : {}), + runs: Array.isArray(row.runs) ? row.runs.map(normalizeRun).filter((entry): entry is AutonomyRun => Boolean(entry)).slice(-50) : [], + completedLeases: Array.isArray(row.completedLeases) ? row.completedLeases.map(normalizeCompletedLease).filter((entry): entry is AutonomyCompletedLease => Boolean(entry)).slice(-50) : [], + events: Array.isArray(row.events) ? row.events.map((entry) => { + const e = asRecord(entry); return { id: asString(e.id, randomUUID()), at: asString(e.at, nowIso()), type: asString(e.type, "event"), actor: asString(e.actor, "system"), summary: asString(e.summary) }; + }).slice(-100) : [], + updatedAt: asString(row.updatedAt, nowIso()), updatedBy: asString(row.updatedBy, actor), + }; +}; + +const getProjectRow = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, include: { workspaceState: true } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + return project; +}; + +export type AutonomyProjectRow = Awaited>; +export const getAutonomyProject = getProjectRow; + +const readState = (project: Awaited>, actor = "system") => { + const notes = asRecord(project.workspaceState?.notesJson); + return normalizeAutonomyState(notes.autonomy, actor); +}; +export const readAutonomyState = readState; + +export const releaseActiveAutonomyLease = (state: AutonomyState, actor: string) => { + if (!state.activeLease || !state.roadmap) return state; + const next = structuredClone(state) as AutonomyState; + const lease = next.activeLease!; + const phase = next.roadmap!.phases.find((entry) => entry.id === lease.phaseId); + const item = lease.itemId ? phase?.items.find((entry) => entry.id === lease.itemId) : undefined; + if (item?.status === "in_progress") item.status = "pending"; + if (phase && !["completed", "blocked"].includes(phase.status)) phase.status = "active"; + delete next.activeLease; + return appendEvent(next, event("autonomy.lease.released", actor, `Released ${lease.kind} lease because Auto Mode was disabled.`)); +}; + +// Update only autonomy with a monotonic CAS version; preserve unrelated notes. +const saveState = async (project: Awaited>, state: AutonomyState, actor: string) => { + const storedAutonomy = asRecord(asRecord(project.workspaceState?.notesJson).autonomy); + const storedUpdatedAt = asString(storedAutonomy.updatedAt); + const priorTimestamp = Date.parse(storedUpdatedAt); + const savedAt = new Date(Math.max(Date.now(), Number.isFinite(priorTimestamp) ? priorTimestamp + 1 : 0)); + const next: AutonomyState = { ...state, updatedAt: savedAt.toISOString(), updatedBy: actor }; + const serialized = JSON.stringify(next); + + if (project.workspaceState) { + const updated = storedUpdatedAt + ? await prisma.$executeRaw` + UPDATE "WorkspaceState" + SET + "notesJson" = jsonb_set(COALESCE("notesJson"::jsonb, '{}'::jsonb), '{autonomy}', ${serialized}::jsonb, true), + "updatedBy" = ${actor}, + "sourceType" = 'autonomy', + "updatedAt" = ${savedAt} + WHERE "projectId" = ${project.id} + AND "notesJson"->'autonomy'->>'updatedAt' = ${storedUpdatedAt} + ` + : await prisma.$executeRaw` + UPDATE "WorkspaceState" + SET + "notesJson" = jsonb_set(COALESCE("notesJson"::jsonb, '{}'::jsonb), '{autonomy}', ${serialized}::jsonb, true), + "updatedBy" = ${actor}, + "sourceType" = 'autonomy', + "updatedAt" = ${savedAt} + WHERE "projectId" = ${project.id} + AND COALESCE("notesJson"->'autonomy'->>'updatedAt', '') = '' + `; + if (updated !== 1) throw new Error("AUTO_MODE_CONFLICT"); + } else { + try { + await prisma.workspaceState.create({ + data: { projectId: project.id, primaryObjective: project.description, activeSection: "workspace", notesJson: json({ autonomy: next }), createdBy: actor, updatedBy: actor, sourceType: "autonomy", updatedAt: savedAt }, + }); + } catch (error) { + if (asString(asRecord(error).code) === "P2002") throw new Error("AUTO_MODE_CONFLICT", { cause: error }); + throw error; + } + } + return next; +}; +export const saveAutonomyState = saveState; +export const appendAutonomyEvent = appendEvent; +export const createAutonomyEvent = event; + +const assignmentTitle = (input: EnqueueAutonomyTaskInput) => { + const explicit = input.title?.trim(); + if (explicit) return explicit.slice(0, 200); + const firstLine = input.instruction.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "Nieuwe ProjectBrain-opdracht"; + return firstLine.slice(0, 160); +}; + +export const enqueueAutonomyTaskInState = (current: AutonomyState, input: EnqueueAutonomyTaskInput, actor = "owner") => { + const next = structuredClone(current) as AutonomyState; + const createdAt = nowIso(); + const task: AutonomyRoadmapItem = { + id: `assignment-${randomUUID()}`, + title: assignmentTitle(input), + instruction: input.instruction.trim(), + source: "user_assignment", + createdAt, + status: "pending", + attempts: 0, + }; + const createAssignmentPhase = (): AutonomyRoadmapPhase => ({ + id: `projectbrain-opdrachten-${randomUUID()}`, + title: "ProjectBrain-opdrachten", + objective: "Werk de opdrachten uit de interne ProjectBrain-takenlijst veilig, controleerbaar en in volgorde af.", + status: "active", + brainstormIterations: 0, + brainstormConverged: false, + validationFailures: 0, + items: [task], + }); + + if (!next.roadmap || next.roadmap.status === "completed") { + next.roadmap = { + id: `roadmap-${randomUUID()}`, + title: "ProjectBrain-opdrachten", + sourceMarkdown: `# ProjectBrain-opdrachten\n\n## Opdrachten\n- [ ] ${task.title}`, + status: "active", + currentPhaseIndex: 0, + phases: [createAssignmentPhase()], + importedAt: createdAt, + }; + delete next.blocker; + delete next.activeLease; + } else { + const currentPhase = next.roadmap.phases[next.roadmap.currentPhaseIndex]; + if (currentPhase && currentPhase.status !== "completed") { + currentPhase.items.unshift(task); + if (!["blocked", "executing", "brainstorming", "validating"].includes(currentPhase.status)) currentPhase.status = "active"; + } else { + const phase = createAssignmentPhase(); + next.roadmap.phases.splice(Math.min(next.roadmap.currentPhaseIndex + 1, next.roadmap.phases.length), 0, phase); + } + next.roadmap.sourceMarkdown = `${next.roadmap.sourceMarkdown.trim()}\n- [ ] ${task.title}`.trim(); + } + + if (next.enabled && next.roadmap.status !== "blocked" && !next.blocker) next.status = "running"; + else if (!next.enabled) next.status = "off"; + const state = appendEvent(next, event("autonomy.task.enqueued", actor, `Queued ProjectBrain assignment '${task.title}' with user priority.`)); + return { state, task }; +}; + +const expireLeaseIfNeeded = (state: AutonomyState) => { + if (!state.activeLease || new Date(state.activeLease.expiresAt).getTime() > Date.now()) return state; + const next = structuredClone(state) as AutonomyState; + const lease = next.activeLease!; + const phase = next.roadmap?.phases.find((entry) => entry.id === lease.phaseId); + const item = lease.itemId ? phase?.items.find((entry) => entry.id === lease.itemId) : undefined; + if (item?.status === "in_progress") item.status = "pending"; + if (phase && !["completed", "blocked"].includes(phase.status)) phase.status = "active"; + delete next.activeLease; + return appendEvent(next, event("autonomy.lease.expired", "system", `Expired ${lease.kind} lease for ${phase?.title ?? lease.phaseId}.`)); +}; + +export const retryAutonomyBlockerIfDue = (state: AutonomyState, now = Date.now()) => { + if (!state.blocker || state.blocker.kind !== "retryable" || state.blocker.recoveryStage === "escalated" || !state.blocker.retryAt || Date.parse(state.blocker.retryAt) > now || !state.roadmap) return state; + const next = structuredClone(state) as AutonomyState; + const blocker = next.blocker!; + const recoveryStartedAt = Date.parse(blocker.recoveryStartedAt ?? next.recoveryStartedAt ?? blocker.at); + const recoveryLimit = Math.max(15, next.maxRecoveryMinutes ?? DEFAULTS.maxRecoveryMinutes) * 60_000; + if (Number.isFinite(recoveryStartedAt) && now - recoveryStartedAt >= recoveryLimit) { + const phase = blocker.phaseId ? next.roadmap!.phases.find((entry) => entry.id === blocker.phaseId) : undefined; + if (phase) phase.status = "blocked"; + next.roadmap!.status = "blocked"; + next.status = "blocked"; + next.blocker = { + ...blocker, + recoveryStage: "escalated", + recoveryAttempts: Math.max(blocker.recoveryAttempts ?? 0, next.recoveryAttempts ?? 0), + recoveryStartedAt: new Date(recoveryStartedAt).toISOString(), + reason: `${blocker.reason} Automatic recovery exceeded its wall-clock limit and needs escalation.`, + }; + delete next.blocker.retryAt; + return appendEvent(next, event("autonomy.recovery.escalated", "system", "Automatic recovery exceeded its wall-clock limit.")); + } + const phase = blocker.phaseId + ? next.roadmap!.phases.find((entry) => entry.id === blocker.phaseId) + : next.roadmap!.phases[next.roadmap!.currentPhaseIndex]; + const item = blocker.itemId ? phase?.items.find((entry) => entry.id === blocker.itemId) : undefined; + if (item?.status === "blocked") item.status = "pending"; + if (phase?.status === "blocked") phase.status = "active"; + next.roadmap!.status = "active"; + next.status = next.enabled ? "running" : "off"; + delete next.blocker; + delete next.activeLease; + return appendEvent(next, event("autonomy.blocker.retry_ready", "system", "Retryable blocker cooldown elapsed; work is claimable again.")); +}; + +export const evaluateAutonomyRunBudget = (state: AutonomyState, actor: string, runId: string, now = Date.now()) => { + const run = state.runs.find((entry) => entry.id === runId); + if (run && run.actor !== actor) return { allowed: false, reason: "owner_mismatch" as const, run }; + if (!run) return { allowed: true, reason: undefined, run: { id: runId, actor, startedAt: new Date(now).toISOString(), reportedWorkItems: 0 } satisfies AutonomyRun }; + if (run.reportedWorkItems >= state.maxWorkItemsPerRun) return { allowed: false, reason: "item_limit" as const, run }; + if (Date.parse(run.startedAt) + state.maxRunMinutes * 60_000 <= now) return { allowed: false, reason: "time_limit" as const, run }; + return { allowed: true, reason: undefined, run }; +}; + +export const getAutonomyState = async (projectIdOrSlug: string) => { + const project = await getProjectRow(projectIdOrSlug); + const original = readState(project); + const state = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(original)); + if (original.activeLease?.token === state.activeLease?.token && original.blocker?.retryAt === state.blocker?.retryAt) return state; + return saveState(project, state, "system"); +}; + +export const configureAutonomy = async (projectIdOrSlug: string, patch: Partial>, actor = "owner") => { + const project = await getProjectRow(projectIdOrSlug); + let state = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(readState(project, actor))); + const requestedProfile = patch.profile && Object.hasOwn(autonomyProfileSettings, patch.profile) ? patch.profile : undefined; + const selectedProfileSettings = requestedProfile ? autonomyProfileSettings[requestedProfile] : undefined; + state = { + ...state, + ...(requestedProfile ? { profile: requestedProfile } : {}), + ...(selectedProfileSettings ?? {}), + ...(typeof patch.autoBrainstorm === "boolean" ? { autoBrainstorm: patch.autoBrainstorm } : {}), + ...(typeof patch.maxBrainstormIterations === "number" ? { maxBrainstormIterations: Math.max(1, Math.min(10, Math.trunc(patch.maxBrainstormIterations))) } : {}), + ...(typeof patch.maxConsecutiveFailures === "number" ? { maxConsecutiveFailures: Math.max(1, Math.min(10, Math.trunc(patch.maxConsecutiveFailures))) } : {}), + ...(typeof patch.leaseMinutes === "number" ? { leaseMinutes: Math.max(5, Math.min(120, Math.trunc(patch.leaseMinutes))) } : {}), + ...(typeof patch.maxWorkItemsPerRun === "number" ? { maxWorkItemsPerRun: Math.max(1, Math.min(20, Math.trunc(patch.maxWorkItemsPerRun))) } : {}), + ...(typeof patch.maxRunMinutes === "number" ? { maxRunMinutes: Math.max(5, Math.min(55, Math.trunc(patch.maxRunMinutes))) } : {}), + ...(typeof patch.maxRecoveryMinutes === "number" ? { maxRecoveryMinutes: Math.max(15, Math.min(1_440, Math.trunc(patch.maxRecoveryMinutes))) } : {}), + ...(typeof patch.pauseAfterCurrent === "boolean" ? { pauseAfterCurrent: patch.pauseAfterCurrent } : {}), + ...(patch.schedulingPolicy === "balanced" || patch.schedulingPolicy === "downstream-impact" ? { schedulingPolicy: patch.schedulingPolicy } : {}), + }; + if (typeof patch.enabled === "boolean") { + if (!patch.enabled && state.activeLease && state.roadmap) state = releaseActiveAutonomyLease(state, actor); + state.enabled = patch.enabled; + if (!patch.enabled) state.status = "off"; + else if (state.roadmap?.status === "completed") state.status = "completed"; + else if (state.blocker || state.roadmap?.status === "blocked") state.status = "blocked"; + else state.status = state.roadmap ? "running" : "waiting_roadmap"; + state = appendEvent(state, event(patch.enabled ? "autonomy.enabled" : "autonomy.disabled", actor, patch.enabled ? "Auto Mode enabled." : "Auto Mode disabled.")); + } + return saveState(project, state, actor); +}; + +export const importAutonomyRoadmap = async (projectIdOrSlug: string, input: { title?: string; markdown: string }, actor = "owner") => { + const project = await getProjectRow(projectIdOrSlug); + let state = expireLeaseIfNeeded(readState(project, actor)); + if (state.roadmap?.sourceMarkdown.trim() === input.markdown.trim()) { + if (input.title?.trim() && input.title.trim() !== state.roadmap.title) { + state.roadmap.title = input.title.trim(); + return saveState(project, state, actor); + } + return state; + } + if (state.activeLease) throw new Error("AUTO_MODE_WORK_ACTIVE"); + const roadmap = parseRoadmapMarkdown(input.markdown, input.title); + state = { ...state, roadmap, activeLease: undefined, blocker: undefined, completedLeases: [], status: state.enabled ? "running" : "off" }; + state = appendEvent(state, event("autonomy.roadmap.imported", actor, `Imported '${roadmap.title}' with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`)); + return saveState(project, state, actor); +}; + +export const resumeAutonomy = async (projectIdOrSlug: string, actor = "owner") => { + const project = await getProjectRow(projectIdOrSlug); + let state = expireLeaseIfNeeded(readState(project, actor)); + if (!state.roadmap || state.roadmap.status === "completed") return state; + if (state.status !== "blocked" && state.roadmap.status !== "blocked" && !state.blocker) return state; + const blockedPhase = state.blocker?.phaseId ? state.roadmap.phases.find((phase) => phase.id === state.blocker?.phaseId) : state.roadmap.phases[state.roadmap.currentPhaseIndex]; + if (blockedPhase) { + if (blockedPhase.status === "blocked") blockedPhase.status = "active"; + blockedPhase.validationFailures = 0; + if (state.blocker?.itemId) { + const blockedItem = blockedPhase.items.find((item) => item.id === state.blocker?.itemId); + if (blockedItem?.status === "blocked") { blockedItem.status = "pending"; delete blockedItem.lastError; } + } + } + state.roadmap.status = "active"; + delete state.blocker; + delete state.activeLease; + state.recoveryAttempts = 0; + delete state.recoveryStartedAt; + state.status = state.enabled ? "running" : "off"; + state = appendEvent(state, event("autonomy.resumed", actor, "Auto Mode blocker cleared; roadmap is resumable.")); + return saveState(project, state, actor); +}; + +export const canResumeAutonomyLease = (lease: AutonomyLease, actor: string, runId?: string) => { + if (lease.actor !== actor) return false; + if (lease.runId || runId) return Boolean(lease.runId && runId && lease.runId === runId); + return true; +}; + +export const isAutonomyLeaseOwner = (lease: AutonomyLease, actor: string) => lease.actor === actor; + +const nextLease = (state: AutonomyState, actor: string, kind: AutonomyWorkKind, phase: AutonomyRoadmapPhase, item?: AutonomyRoadmapItem, runId?: string): AutonomyLease => { + const createdAt = new Date(); + return { token: randomUUID(), kind, actor, ...(runId ? { runId } : {}), roadmapId: state.roadmap!.id, phaseId: phase.id, ...(item ? { itemId: item.id } : {}), createdAt: createdAt.toISOString(), expiresAt: new Date(createdAt.getTime() + state.leaseMinutes * 60_000).toISOString() }; +}; +export const createAutonomyLease = nextLease; + +const applyRetryableBlocker = (state: AutonomyState, phase: AutonomyRoadmapPhase, summary: string, failureCount: number, item?: AutonomyRoadmapItem, requestedDelay?: number) => { + const blockedAt = Date.now(); + const recoveryStartedAt = state.recoveryStartedAt ?? new Date(blockedAt).toISOString(); + const recoveryAttempts = (state.recoveryAttempts ?? 0) + 1; + const decision = decideRecovery({ + failureCount, + recoveryAttempts, + recoveryStartedAt, + maxRecoveryMinutes: state.maxRecoveryMinutes ?? DEFAULTS.maxRecoveryMinutes, + now: blockedAt, + }); + state.recoveryStartedAt = recoveryStartedAt; + state.recoveryAttempts = recoveryAttempts; + if (decision.stage === "alternate_recovery") { + if (item) { item.status = "pending"; item.lastError = `${summary} Alternate recovery is being attempted.`; } + phase.status = "active"; + state.roadmap!.status = "active"; + state.status = state.enabled ? "running" : "off"; + delete state.blocker; + return decision; + } + if (item) { item.status = "blocked"; item.lastError = summary; } + phase.status = "blocked"; + state.roadmap!.status = "blocked"; + state.status = "blocked"; + const retryAfterMinutes = decision.exhausted + ? undefined + : Math.max(5, Math.min(1_440, requestedDelay ?? decision.retryAfterMinutes ?? autonomyRetryDelayMinutes(failureCount))); + state.blocker = { + at: new Date(blockedAt).toISOString(), phaseId: phase.id, ...(item ? { itemId: item.id } : {}), reason: summary, + kind: "retryable", ...(retryAfterMinutes ? { retryAt: new Date(blockedAt + retryAfterMinutes * 60_000).toISOString() } : {}), + recoveryStage: decision.stage, recoveryAttempts, recoveryStartedAt, + }; + return decision; +}; + +const applyHumanBlocker = (state: AutonomyState, phase: AutonomyRoadmapPhase, summary: string, item?: AutonomyRoadmapItem) => { + if (item) { item.status = "blocked"; item.lastError = summary; } + phase.status = "blocked"; + state.roadmap!.status = "blocked"; + state.status = "blocked"; + state.blocker = { at: nowIso(), phaseId: phase.id, ...(item ? { itemId: item.id } : {}), reason: summary, kind: "human" }; +}; + +export const reportAutonomyWork = async (projectIdOrSlug: string, input: AutonomyReportInput, actor = "chatgpt") => { + const project = await getProjectRow(projectIdOrSlug); + let state = readState(project, actor); + const replay = state.completedLeases.find((entry) => entry.token === input.token); + if (replay) return { state, addedTasks: replay.addedTasks, replayed: true, runId: undefined }; + const lease = state.activeLease; + if (!lease || lease.token !== input.token) throw new Error("AUTO_MODE_LEASE_STALE"); + if (!isAutonomyLeaseOwner(lease, actor)) throw new Error("AUTO_MODE_LEASE_OWNER_MISMATCH"); + if (!state.roadmap || lease.roadmapId !== state.roadmap.id) throw new Error("AUTO_MODE_ROADMAP_CHANGED"); + const phase = state.roadmap.phases.find((entry) => entry.id === lease.phaseId); + if (!phase) throw new Error("AUTO_MODE_PHASE_NOT_FOUND"); + const item = lease.itemId ? phase.items.find((entry) => entry.id === lease.itemId) : undefined; + const deliveryId = input.deliveryId ?? item?.deliveryId; + const reconciledGiteaCompletion = item ? hasGiteaAutonomyCompletionEvidence(item.completionEvidence) : false; + if (lease.kind === "implement" && input.outcome === "success" && !deliveryId && !reconciledGiteaCompletion) throw new Error("AUTO_MODE_DELIVERY_REQUIRED"); + if (deliveryId) { + const delivery = await getFeatureDelivery(project.slug, deliveryId); + if (item?.deliveryId && item.deliveryId !== delivery.id) throw new Error("AUTO_MODE_DELIVERY_ALREADY_LINKED"); + if (input.outcome === "success" && lease.kind === "implement" && (!['merged', 'verified', 'released'].includes(delivery.spec.status) || !delivery.spec.pullRequest?.mergeCommitSha || !delivery.spec.validationEvidence)) throw new Error("AUTO_MODE_DELIVERY_NOT_MERGED"); + if (item) item.deliveryId = delivery.id; + } + if (lease.kind === "validate_phase" && input.outcome === "success") { + for (const completed of phase.items.filter((entry) => entry.deliveryId && isAutonomyItemInExecutionScope(entry))) { + const delivery = await getFeatureDelivery(project.slug, completed.deliveryId!); + if (!["merged", "verified", "released"].includes(delivery.spec.status) || !delivery.spec.validationEvidence || !delivery.spec.pullRequest?.mergeCommitSha) throw new Error("AUTO_MODE_DELIVERY_NOT_MERGED"); + } + } + const repairTasks = input.outcome === "success" ? [] : input.discoveredTasks ?? []; + const brainstormTasks = input.brainstorm?.nextActions ?? []; + const newTasks = [ + ...addDiscoveredTasks(phase, repairTasks, { scope: "committed", source: "recovery" }), + ...addDiscoveredTasks(phase, brainstormTasks), + ]; + const hasRepairs = item && repairTasks.length > 0 && input.outcome !== "success" && classifyAutonomyBlocker(input.summary, input.blockerType) !== "human" && orderAutonomyRepairs(phase, item, repairTasks); + + if (hasRepairs) { + item.status = "pending"; item.lastError = input.summary; + phase.status = "active"; state.status = "running"; state.roadmap.status = "active"; + delete state.blocker; + } else if (input.outcome === "blocked") { + if (!item) phase.validationFailures += 1; + const failureCount = Math.max(1, item ? item.attempts : phase.validationFailures); + const blockerKind = classifyAutonomyBlocker(input.summary, input.blockerType); + if (blockerKind === "human") applyHumanBlocker(state, phase, input.summary, item); + else applyRetryableBlocker(state, phase, input.summary, failureCount, item, input.retryAfterMinutes); + } else if (lease.kind === "brainstorm") { + phase.brainstormIterations += 1; + if (input.outcome === "failed") { + phase.validationFailures += 1; + if (phase.validationFailures >= state.maxConsecutiveFailures) applyRetryableBlocker(state, phase, input.summary, phase.validationFailures, undefined, input.retryAfterMinutes); + else phase.status = "active"; + } else if (newTasks.length && hasOpenAutonomyExecutionItems(phase)) { + phase.validationFailures = 0; + phase.brainstormConverged = false; + phase.status = "executing"; + } else { + phase.validationFailures = 0; + phase.brainstormConverged = true; + phase.status = "validating"; + } + await createBrainstormForProject(project.slug, { + title: `Auto brainstorm · ${phase.title} · round ${phase.brainstormIterations}`, + prompt: workPrompt("brainstorm", phase), scope: "roadmap_phase", mode: "autonomy_loop", + status: input.outcome === "failed" ? "failed" : hasOpenAutonomyExecutionItems(phase) ? "selected" : "converged", targetEntityId: phase.id, targetEntityType: "roadmap_phase", generatedBy: actor, + contextSummary: input.brainstorm?.contextSummary?.trim() || input.summary, + ideas: input.brainstorm?.ideas ?? [], clusters: [], + outcomes: (input.brainstorm?.outcomes ?? [input.summary]).map((summary, index) => ({ id: `outcome-${index + 1}`, summary })), + selectedIdeaIds: input.brainstorm?.selectedIdeaIds ?? [], nextActions: newTasks, + }); + } else if (lease.kind === "implement") { + if (!item) throw new Error("AUTO_MODE_ITEM_NOT_FOUND"); + if (input.outcome === "success") { + phase.validationFailures = 0; + item.status = "completed"; + item.lastResult = input.summary; + delete item.lastError; + if (input.deliveryId) item.deliveryId = input.deliveryId; + phase.status = "active"; + } else { + item.lastError = input.summary; + if (item.attempts >= state.maxConsecutiveFailures) applyRetryableBlocker(state, phase, input.summary, item.attempts, item, input.retryAfterMinutes); + else { item.status = "pending"; phase.status = "active"; } + } + } else { + if (input.outcome === "success" && !hasOpenAutonomyExecutionItems(phase)) { + phase.status = "completed"; + phase.brainstormConverged = true; + phase.validationFailures = 0; + if (state.roadmap.currentPhaseIndex < state.roadmap.phases.length - 1) { + state.roadmap.currentPhaseIndex += 1; + const next = state.roadmap.phases[state.roadmap.currentPhaseIndex]; + if (next.status !== "completed") next.status = "active"; + } else { + state.roadmap.status = "completed"; + state.roadmap.completedAt = nowIso(); + state.status = "completed"; + } + } else { + phase.validationFailures += 1; + phase.brainstormConverged = !hasOpenAutonomyExecutionItems(phase); + if (phase.validationFailures >= state.maxConsecutiveFailures && !hasOpenAutonomyExecutionItems(phase)) applyRetryableBlocker(state, phase, input.summary, phase.validationFailures, undefined, input.retryAfterMinutes); + else phase.status = hasOpenAutonomyExecutionItems(phase) ? "executing" : "active"; + } + } + + if (input.outcome === "success") { + state.recoveryAttempts = 0; + delete state.recoveryStartedAt; + } + delete state.activeLease; + if (lease.runId) { + const run = state.runs.find((entry) => entry.id === lease.runId); + if (run) run.reportedWorkItems += 1; + else state.runs = [...state.runs, { id: lease.runId, actor: lease.actor, startedAt: lease.createdAt, reportedWorkItems: 1 }].slice(-50); + } + state.completedLeases = [...state.completedLeases, { token: lease.token, outcome: input.outcome, summary: input.summary, addedTasks: newTasks, completedAt: nowIso() }].slice(-50); + state = appendEvent(state, event(`autonomy.work.${input.outcome}`, actor, `${lease.kind} · ${phase.title}: ${input.summary}${newTasks.length ? ` Added ${newTasks.length} new task(s).` : ""}`)); + const completedRoadmap = state.roadmap; + if (completedRoadmap?.status === "completed") state = appendEvent(state, event("autonomy.roadmap.completed", actor, `Roadmap '${completedRoadmap.title}' completed.`)); + state = await saveState(project, state, actor); + return { state, addedTasks: newTasks, replayed: false, runId: lease.runId }; +}; diff --git a/backend/src/repositories/blueprintsRepository.ts b/backend/src/repositories/blueprintsRepository.ts new file mode 100644 index 0000000..5f38108 --- /dev/null +++ b/backend/src/repositories/blueprintsRepository.ts @@ -0,0 +1,625 @@ +import type { Prisma } from "@prisma/client"; +import { ApprovalState, EvidenceStrength, ReviewStatus, VerificationState } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import { listContextPacksForProject } from "./contextPacksRepository.js"; +import { getProjectBySlugOrId } from "./projectsRepository.js"; +import { + createBlueprintImpactReportDto, + createBlueprintNodeRunPreviewDto, + createBlueprintPlanDto, + createBlueprintRepoExportBundleDto, + type BlueprintDepthDto, + type BlueprintNodeDto, + type BlueprintPlanDto, +} from "../services/blueprintEngine.js"; + +const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; +const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + + + +type BlueprintDelegate = { + create: (args: unknown) => Promise; + findFirst: (args: unknown) => Promise; + update: (args: unknown) => Promise; + updateMany: (args: unknown) => Promise; +}; + +type BlueprintPrisma = { + blueprintGraph: BlueprintDelegate; + blueprintNode: BlueprintDelegate; + blueprintNodeRun: BlueprintDelegate; +}; + +const blueprintDb = prisma as unknown as BlueprintPrisma; + +const findProjectRecord = (projectIdOrSlug: string) => prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, + select: { id: true, slug: true, activeContextPackId: true }, +}); + +type BlueprintGraphRecord = { + id: string; + slug: string | null; + readableId: string | null; + projectId: string; + project?: { slug: string }; + archetypeId: string; + archetypeLabel: string; + depth: string; + status: string; + documentRoot: string; + intakeSummary: string; + assumptionsJson: Prisma.JsonValue; + exportManifestJson: Prisma.JsonValue; + recommendedNextActionsJson: Prisma.JsonValue; + metadataJson: Prisma.JsonValue | null; + createdAt: Date; + updatedAt: Date; + nodes?: BlueprintNodeRecord[]; +}; + +type BlueprintNodeRecord = { + id: string; + templateId: string; + kind: string; + title: string; + cluster: string; + status: string; + generationOrder: number; + requiredDepth: string; + dependsOnJson: Prisma.JsonValue; + dependencyTitlesJson: Prisma.JsonValue; + blockedByJson: Prisma.JsonValue; + outputPath: string; + purpose: string; + requiredContextJson: Prisma.JsonValue; + qualityChecksJson: Prisma.JsonValue; + producesJson: Prisma.JsonValue; + promptPreview: string; + contextSelectionJson: Prisma.JsonValue; + documentationItemId: string | null; + reviewItemId: string | null; + latestRunId: string | null; + staleReason: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type BlueprintNodeRunRecord = { + id: string; + projectId: string; + graphId: string; + nodeId: string; + status: string; + generatedBy: string; + inputSnapshotJson: Prisma.JsonValue; + prompt: string; + draftMarkdown: string; + qualityReportJson: Prisma.JsonValue; + documentationItemId: string | null; + reviewItemId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +const asContextSelection = (value: Prisma.JsonValue) => isRecord(value) ? value : { selectedContextPackIds: [], candidates: [], decisionSummary: "No context selection metadata stored." }; + +const recordToNodeDto = (record: BlueprintNodeRecord, projectSlug: string): BlueprintPlanDto["nodes"][number] => ({ + id: record.templateId, + title: record.title, + cluster: record.cluster as BlueprintPlanDto["nodes"][number]["cluster"], + requiredDepth: record.requiredDepth as BlueprintDepthDto, + dependsOn: toStringArray(record.dependsOnJson), + outputPath: record.outputPath, + purpose: record.purpose, + requiredContext: toStringArray(record.requiredContextJson), + qualityChecks: toStringArray(record.qualityChecksJson), + produces: toStringArray(record.producesJson), + projectId: projectSlug, + dependencyTitles: toStringArray(record.dependencyTitlesJson), + blockedBy: toStringArray(record.blockedByJson), + generationOrder: record.generationOrder, + status: record.status, + contextSelection: asContextSelection(record.contextSelectionJson) as BlueprintNodeDto["contextSelection"], + promptPreview: record.promptPreview, + exportReady: record.status !== "blocked", + documentationItemId: record.documentationItemId ?? undefined, + reviewItemId: record.reviewItemId ?? undefined, + latestRunId: record.latestRunId ?? undefined, + staleReason: record.staleReason ?? undefined, +} as BlueprintPlanDto["nodes"][number]); + +const recordToPlanDto = (record: BlueprintGraphRecord): BlueprintPlanDto => { + const projectSlug = record.project?.slug ?? record.projectId; + const metadata = isRecord(record.metadataJson) ? record.metadataJson : {}; + const nodes = (record.nodes ?? []).sort((left, right) => left.generationOrder - right.generationOrder).map((node) => recordToNodeDto(node, projectSlug)); + const clusterIds = [...new Set(nodes.map((node) => node.cluster))]; + const clusters = clusterIds.map((clusterId) => { + const clusterNodes = nodes.filter((node) => node.cluster === clusterId); + return { + id: clusterId, + label: String(clusterId).split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "), + planned: clusterNodes.length, + ready: clusterNodes.filter((node) => node.status !== "blocked").length, + blocked: clusterNodes.filter((node) => node.status === "blocked").length, + exportPaths: clusterNodes.map((node) => node.outputPath), + }; + }); + return { + id: record.slug ?? record.readableId ?? record.id, + projectId: projectSlug, + archetype: { + id: record.archetypeId as BlueprintPlanDto["archetype"]["id"], + label: record.archetypeLabel, + summary: typeof metadata.archetypeSummary === "string" ? metadata.archetypeSummary : "Persisted blueprint archetype.", + signals: toStringArray(metadata.archetypeSignals), + recommendedDepth: (typeof metadata.recommendedDepth === "string" ? metadata.recommendedDepth : record.depth) as BlueprintDepthDto, + clusters: toStringArray(metadata.archetypeClusters) as BlueprintPlanDto["archetype"]["clusters"], + }, + depth: record.depth as BlueprintDepthDto, + status: record.status as BlueprintPlanDto["status"], + generatedAt: record.createdAt.toISOString(), + intakeSummary: record.intakeSummary, + assumptions: toStringArray(record.assumptionsJson), + documentRoot: record.documentRoot, + totalNodes: nodes.length, + readyNodes: nodes.filter((node) => node.status !== "blocked").length, + blockedNodes: nodes.filter((node) => node.status === "blocked").length, + clusters, + nodes, + exportManifest: toStringArray(record.exportManifestJson), + recommendedNextActions: toStringArray(record.recommendedNextActionsJson), + }; +}; + +export const createAndPersistBlueprintPlan = async (projectIdOrSlug: string, depth?: BlueprintDepthDto): Promise => { + const projectRecord = await findProjectRecord(projectIdOrSlug); + if (!projectRecord) return undefined; + const project = await getProjectBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + const packs = await listContextPacksForProject(projectIdOrSlug) ?? []; + const plan = createBlueprintPlanDto(project, packs, depth); + const now = Date.now().toString(36); + const slug = `bp-${projectRecord.slug}-${plan.archetype.id}-${plan.depth}-${now}`; + + const graph = await blueprintDb.blueprintGraph.create({ + data: { + projectId: projectRecord.id, + slug, + readableId: slug, + archetypeId: plan.archetype.id, + archetypeLabel: plan.archetype.label, + depth: plan.depth, + status: plan.status, + documentRoot: plan.documentRoot, + intakeSummary: plan.intakeSummary, + assumptionsJson: toJson(plan.assumptions), + exportManifestJson: toJson(plan.exportManifest), + recommendedNextActionsJson: toJson(plan.recommendedNextActions), + metadataJson: toJson({ archetypeSummary: plan.archetype.summary, archetypeSignals: plan.archetype.signals, archetypeClusters: plan.archetype.clusters, recommendedDepth: plan.archetype.recommendedDepth, sourcePlanId: plan.id }), + createdBy: "api", + updatedBy: "api", + sourceType: "blueprint_engine_v52", + nodes: { + create: plan.nodes.map((node) => ({ + projectId: projectRecord.id, + templateId: node.id, + kind: node.id, + title: node.title, + cluster: node.cluster, + status: node.status, + generationOrder: node.generationOrder, + requiredDepth: node.requiredDepth, + dependsOnJson: toJson(node.dependsOn), + dependencyTitlesJson: toJson(node.dependencyTitles), + blockedByJson: toJson(node.blockedBy), + outputPath: node.outputPath, + purpose: node.purpose, + requiredContextJson: toJson(node.requiredContext), + qualityChecksJson: toJson(node.qualityChecks), + producesJson: toJson(node.produces), + promptPreview: node.promptPreview, + contextSelectionJson: toJson(node.contextSelection), + createdBy: "api", + updatedBy: "api", + sourceType: "blueprint_engine_v52", + })), + }, + }, + include: { project: { select: { slug: true } }, nodes: true }, + }) as BlueprintGraphRecord; + + await prisma.operatingLogEntry.create({ + data: { + projectId: projectRecord.id, + slug: `log-${projectRecord.slug}-blueprint-plan-${Date.now().toString(36)}`, + readableId: `log-${projectRecord.slug}-blueprint-plan-${Date.now().toString(36)}`, + type: "blueprint_plan_created", + title: "Blueprint plan persisted", + summary: `${plan.totalNodes} documentation nodes planned for ${plan.archetype.label} at ${plan.depth} depth.`, + linkedEntityIdsJson: toJson([graph.slug ?? graph.id]), + source: "blueprint_engine_v52", + importance: "normal", + payloadJson: toJson({ graphId: graph.slug ?? graph.id, archetypeId: plan.archetype.id, depth: plan.depth, nodes: plan.totalNodes }), + }, + }); + + return recordToPlanDto(graph); +}; + +export const getLatestBlueprintPlan = async (projectIdOrSlug: string): Promise => { + const projectRecord = await findProjectRecord(projectIdOrSlug); + if (!projectRecord) return undefined; + const graph = await blueprintDb.blueprintGraph.findFirst({ + where: { projectId: projectRecord.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + include: { project: { select: { slug: true } }, nodes: true }, + }) as BlueprintGraphRecord | null; + return graph ? recordToPlanDto(graph) : undefined; +}; + +export const getBlueprintPlanById = async (projectIdOrSlug: string, graphId: string): Promise => { + const projectRecord = await findProjectRecord(projectIdOrSlug); + if (!projectRecord) return undefined; + const graph = await blueprintDb.blueprintGraph.findFirst({ + where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] }, + include: { project: { select: { slug: true } }, nodes: true }, + }) as BlueprintGraphRecord | null; + return graph ? recordToPlanDto(graph) : undefined; +}; + +export const runBlueprintNode = async (projectIdOrSlug: string, graphId: string, nodeId: string, options: { createDocumentation?: boolean; sendToReview?: boolean } = {}) => { + const projectRecord = await findProjectRecord(projectIdOrSlug); + if (!projectRecord) return undefined; + const graphRecord = await blueprintDb.blueprintGraph.findFirst({ + where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] }, + include: { project: { select: { slug: true } }, nodes: true }, + }) as BlueprintGraphRecord | null; + if (!graphRecord) return undefined; + const plan = recordToPlanDto(graphRecord); + const node = plan.nodes.find((candidate) => candidate.id === nodeId || candidate.id === nodeId); + if (!node || node.status === "blocked") return undefined; + const preview = createBlueprintNodeRunPreviewDto(plan, node); + + let documentationItemId: string | undefined; + let reviewItemId: string | undefined; + + if (options.createDocumentation) { + const slug = `doc-${projectRecord.slug}-${node.id}-${Date.now().toString(36)}`; + const doc = await prisma.documentationItem.create({ + data: { + projectId: projectRecord.id, + slug, + readableId: slug, + title: node.title, + kind: "blueprint_document", + status: options.sendToReview ? "draft_review" : "draft", + approvalState: ApprovalState.pending, + content: preview.draftMarkdown, + metadataJson: toJson({ + blueprintGraphId: graphRecord.slug ?? graphRecord.id, + blueprintNodeId: node.id, + outputPath: node.outputPath, + linkedFeatureIds: [], + primaryContextPackId: node.contextSelection.primaryContextPackId, + selectedContextPackIds: node.contextSelection.selectedContextPackIds, + contextSelectionSummary: node.contextSelection.decisionSummary, + lastUpdated: new Date().toISOString(), + }), + trustJson: toJson({ source: "blueprint node run", confidence: preview.qualityReport.score, evidence: [], lastChecked: new Date().toISOString(), approvalState: "pending" }), + provenanceJson: toJson({ createdFrom: "blueprint_node_run", graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id }), + createdBy: "api", + updatedBy: "api", + sourceType: "blueprint_engine_v52", + }, + }); + documentationItemId = doc.slug ?? doc.id; + } + + if (options.sendToReview) { + const slug = `rq-${projectRecord.slug}-blueprint-${node.id}-${Date.now().toString(36)}`; + const review = await prisma.reviewQueueItem.create({ + data: { + projectId: projectRecord.id, + slug, + readableId: slug, + type: "documentation_update", + title: `Review blueprint draft: ${node.title}`, + description: preview.draftMarkdown.slice(0, 4000), + priority: "normal", + status: ReviewStatus.pending, + source: "blueprint_engine_v52", + confidence: preview.qualityReport.score, + approvalState: ApprovalState.pending, + evidenceStrength: EvidenceStrength.medium, + verificationState: VerificationState.needs_evidence, + suggestedAction: "Review, edit and accept this generated blueprint document before exporting or treating it as project truth.", + targetEntityType: "documentation_item", + targetEntityId: documentationItemId, + payloadJson: toJson({ graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id, outputPath: node.outputPath, draftMarkdown: preview.draftMarkdown, qualityReport: preview.qualityReport, contextSelection: node.contextSelection }), + provenanceJson: toJson({ createdFrom: "blueprint_node_run", graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id }), + createdBy: "api", + updatedBy: "api", + sourceType: "blueprint_engine_v52", + }, + }); + reviewItemId = review.slug ?? review.id; + } + + const nodeRecord = (graphRecord.nodes ?? []).find((candidate) => candidate.templateId === node.id); + if (!nodeRecord) return undefined; + + const run = await blueprintDb.blueprintNodeRun.create({ + data: { + projectId: projectRecord.id, + graphId: graphRecord.id, + nodeId: nodeRecord.id, + status: "needs_review", + generatedBy: "deterministic", + inputSnapshotJson: toJson(preview.inputSnapshot), + prompt: preview.prompt, + draftMarkdown: preview.draftMarkdown, + qualityReportJson: toJson(preview.qualityReport), + documentationItemId, + reviewItemId, + metadataJson: toJson({ outputPath: node.outputPath, contextSelection: node.contextSelection }), + createdBy: "api", + updatedBy: "api", + sourceType: "blueprint_engine_v52", + }, + }) as BlueprintNodeRunRecord; + + await blueprintDb.blueprintNode.updateMany({ + where: { graphId: graphRecord.id, templateId: node.id }, + data: { status: options.sendToReview ? "needs_review" : "pending", latestRunId: run.id, documentationItemId, reviewItemId, updatedBy: "api" }, + }); + + await prisma.operatingLogEntry.create({ + data: { + projectId: projectRecord.id, + slug: `log-${projectRecord.slug}-blueprint-run-${Date.now().toString(36)}`, + readableId: `log-${projectRecord.slug}-blueprint-run-${Date.now().toString(36)}`, + type: "blueprint_node_run_created", + title: `Blueprint draft generated: ${node.title}`, + summary: `Generated a review-gated blueprint draft for ${node.outputPath}.`, + linkedEntityIdsJson: toJson([graphRecord.slug ?? graphRecord.id, node.id, documentationItemId, reviewItemId].filter(Boolean)), + source: "blueprint_engine_v52", + importance: "normal", + payloadJson: toJson({ graphId: graphRecord.slug ?? graphRecord.id, nodeId: node.id, documentationItemId, reviewItemId, qualityScore: preview.qualityReport.score }), + }, + }); + + return { + ...preview, + id: run.id, + graphId: graphRecord.slug ?? graphRecord.id, + documentationItemId, + reviewItemId, + generatedAt: run.createdAt.toISOString(), + }; +}; + + +export const getBlueprintRepoExportBundle = async (projectIdOrSlug: string, graphId: string, options: { includeDrafts?: boolean } = {}) => { + const projectRecord = await findProjectRecord(projectIdOrSlug); + if (!projectRecord) return undefined; + const graph = await blueprintDb.blueprintGraph.findFirst({ + where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] }, + include: { project: { select: { slug: true } }, nodes: true }, + }) as BlueprintGraphRecord | null; + if (!graph) return undefined; + const plan = recordToPlanDto(graph); + const docIds = (graph.nodes ?? []).map((node) => node.documentationItemId).filter((item): item is string => Boolean(item)); + const docs = docIds.length + ? await prisma.documentationItem.findMany({ + where: { projectId: projectRecord.id, deletedAt: null, OR: docIds.flatMap((id) => [{ id }, { slug: id }, { readableId: id }]) }, + }) + : []; + const draftByNodeId: Record = {}; + for (const node of graph.nodes ?? []) { + const doc = docs.find((candidate) => candidate.id === node.documentationItemId || candidate.slug === node.documentationItemId || candidate.readableId === node.documentationItemId); + if (doc?.content) draftByNodeId[node.templateId] = doc.content; + } + const bundle = createBlueprintRepoExportBundleDto(plan, { includeDrafts: options.includeDrafts, draftByNodeId }); + await prisma.operatingLogEntry.create({ + data: { + projectId: projectRecord.id, + slug: `log-${projectRecord.slug}-blueprint-export-${Date.now().toString(36)}`, + readableId: `log-${projectRecord.slug}-blueprint-export-${Date.now().toString(36)}`, + type: "blueprint_export_prepared", + title: "Blueprint repo export prepared", + summary: `${bundle.files.length} blueprint export file(s) prepared; ${bundle.skippedCount} node(s) skipped.`, + linkedEntityIdsJson: toJson([graph.slug ?? graph.id, ...bundle.files.map((file) => file.nodeId)]), + source: "blueprint_engine_v53", + importance: bundle.skippedCount ? "important" : "normal", + payloadJson: toJson({ graphId: graph.slug ?? graph.id, includeDrafts: bundle.includeDrafts, files: bundle.files.map((file) => file.path), warnings: bundle.warnings }), + }, + }); + return bundle; +}; + +export const getBlueprintImpactReport = async (projectIdOrSlug: string, graphId: string, nodeId: string) => { + const plan = await getBlueprintPlanById(projectIdOrSlug, graphId); + if (!plan) return undefined; + if (!plan.nodes.some((node) => node.id === nodeId)) return undefined; + return createBlueprintImpactReportDto(plan, nodeId); +}; + +export const syncBlueprintReviewAction = async (projectIdOrSlug: string, reviewItemId: string, action: "accept" | "reject") => { + const projectRecord = await findProjectRecord(projectIdOrSlug); + if (!projectRecord) return undefined; + const review = await prisma.reviewQueueItem.findFirst({ + where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }] }, + }); + if (!review || review.source !== "blueprint_engine_v52") return undefined; + const payload = isRecord(review.payloadJson) ? review.payloadJson : {}; + const graphId = typeof payload.graphId === "string" ? payload.graphId : undefined; + const nodeId = typeof payload.nodeId === "string" ? payload.nodeId : undefined; + if (!graphId || !nodeId) return undefined; + + const graph = await blueprintDb.blueprintGraph.findFirst({ + where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] }, + include: { nodes: true }, + }) as BlueprintGraphRecord | null; + if (!graph) return undefined; + const node = (graph.nodes ?? []).find((candidate) => candidate.templateId === nodeId || candidate.id === nodeId); + if (!node) return undefined; + + const nextStatus = action === "accept" ? "accepted" : "pending"; + await blueprintDb.blueprintNode.update({ + where: { id: node.id }, + data: { status: nextStatus, staleReason: action === "reject" ? "Latest generated draft was rejected in the review queue." : null, updatedBy: "review_queue_sync_v53" }, + }); + await blueprintDb.blueprintNodeRun.updateMany({ + where: { projectId: projectRecord.id, nodeId: node.id, reviewItemId: review.slug ?? review.id, deletedAt: null }, + data: { status: action === "accept" ? "accepted" : "failed", updatedBy: "review_queue_sync_v53" }, + }); + if (node.documentationItemId) { + await prisma.documentationItem.updateMany({ + where: { projectId: projectRecord.id, OR: [{ id: node.documentationItemId }, { slug: node.documentationItemId }, { readableId: node.documentationItemId }] }, + data: { status: action === "accept" ? "accepted" : "draft_rejected", approvalState: action === "accept" ? ApprovalState.approved : ApprovalState.rejected, updatedBy: "review_queue_sync_v53" }, + }); + } + + let staleNodeIds: string[] = []; + if (action === "accept") { + const plan = recordToPlanDto(graph); + const impact = createBlueprintImpactReportDto(plan, node.templateId); + staleNodeIds = impact.downstreamNodeIds; + if (staleNodeIds.length) { + await blueprintDb.blueprintNode.updateMany({ + where: { graphId: graph.id, templateId: { in: staleNodeIds }, status: { in: ["accepted", "needs_review", "pending", "ready"] } }, + data: { status: "stale", staleReason: `${node.title} was accepted and may affect this downstream document.`, updatedBy: "staleness_engine_v53" }, + }); + await blueprintDb.blueprintGraph.update({ where: { id: graph.id }, data: { status: "partially_stale", updatedBy: "staleness_engine_v53" } }); + } + } + + await prisma.operatingLogEntry.create({ + data: { + projectId: projectRecord.id, + slug: `log-${projectRecord.slug}-blueprint-review-sync-${Date.now().toString(36)}`, + readableId: `log-${projectRecord.slug}-blueprint-review-sync-${Date.now().toString(36)}`, + type: "blueprint_review_sync", + title: action === "accept" ? `Blueprint document accepted: ${node.title}` : `Blueprint draft rejected: ${node.title}`, + summary: action === "accept" ? `Accepted review item updated the blueprint node and marked ${staleNodeIds.length} downstream node(s) stale.` : "Rejected review item returned the blueprint node to pending.", + linkedEntityIdsJson: toJson([graph.slug ?? graph.id, node.templateId, review.slug ?? review.id, ...staleNodeIds]), + source: "blueprint_engine_v53", + importance: staleNodeIds.length ? "important" : "normal", + payloadJson: toJson({ graphId: graph.slug ?? graph.id, nodeId: node.templateId, reviewItemId: review.slug ?? review.id, action, staleNodeIds }), + }, + }); + + return { graphId: graph.slug ?? graph.id, nodeId: node.templateId, action, staleNodeIds }; +}; + + +export const regenerateBlueprintNode = async ( + projectIdOrSlug: string, + graphId: string, + nodeId: string, + options: { createDocumentation?: boolean; sendToReview?: boolean } = {}, +) => { + const projectRecord = await findProjectRecord(projectIdOrSlug); + if (!projectRecord) return undefined; + const graph = await blueprintDb.blueprintGraph.findFirst({ + where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] }, + include: { project: { select: { slug: true } }, nodes: true }, + }) as BlueprintGraphRecord | null; + if (!graph) return undefined; + + const node = (graph.nodes ?? []).find((candidate) => candidate.templateId === nodeId || candidate.id === nodeId); + if (!node) return undefined; + const run = await runBlueprintNode(projectIdOrSlug, graph.slug ?? graph.id, node.templateId, { + createDocumentation: options.createDocumentation ?? true, + sendToReview: options.sendToReview ?? true, + }); + if (!run) return undefined; + + await prisma.operatingLogEntry.create({ + data: { + projectId: projectRecord.id, + slug: `log-${projectRecord.slug}-blueprint-node-regenerate-${Date.now().toString(36)}`, + readableId: `log-${projectRecord.slug}-blueprint-node-regenerate-${Date.now().toString(36)}`, + type: "blueprint_node_regenerated", + title: `Blueprint node regenerated: ${node.title}`, + summary: `Regenerated ${node.title} into a review-gated draft for ${node.outputPath}.`, + linkedEntityIdsJson: toJson([graph.slug ?? graph.id, node.templateId, run.reviewItemId, run.documentationItemId].filter(Boolean)), + source: "blueprint_engine_v55", + importance: node.status === "stale" ? "important" : "normal", + payloadJson: toJson({ graphId: graph.slug ?? graph.id, nodeId: node.templateId, previousStatus: node.status, runId: run.id, reviewItemId: run.reviewItemId, documentationItemId: run.documentationItemId }), + }, + }); + + return { + graphId: graph.slug ?? graph.id, + nodeId: node.templateId, + previousStatus: node.status, + run, + message: `Blueprint node '${node.title}' regenerated and sent through review gates.`, + }; +}; + + +export const regenerateStaleBlueprintNodes = async ( + projectIdOrSlug: string, + graphId: string, + options: { limit?: number; createDocumentation?: boolean; sendToReview?: boolean } = {}, +) => { + const projectRecord = await findProjectRecord(projectIdOrSlug); + if (!projectRecord) return undefined; + const graph = await blueprintDb.blueprintGraph.findFirst({ + where: { projectId: projectRecord.id, deletedAt: null, OR: [{ id: graphId }, { slug: graphId }, { readableId: graphId }] }, + include: { project: { select: { slug: true } }, nodes: true }, + }) as BlueprintGraphRecord | null; + if (!graph) return undefined; + + const staleNodes = (graph.nodes ?? []) + .filter((node) => node.status === "stale") + .sort((left, right) => left.generationOrder - right.generationOrder) + .slice(0, Math.max(1, options.limit ?? 10)); + + const runs = []; + for (const node of staleNodes) { + const run = await runBlueprintNode(projectIdOrSlug, graph.slug ?? graph.id, node.templateId, { + createDocumentation: options.createDocumentation ?? true, + sendToReview: options.sendToReview ?? true, + }); + if (run) runs.push(run); + } + + if (runs.length) { + await blueprintDb.blueprintGraph.update({ + where: { id: graph.id }, + data: { status: "running", updatedBy: "api" }, + }); + await prisma.operatingLogEntry.create({ + data: { + projectId: projectRecord.id, + slug: `log-${projectRecord.slug}-blueprint-regenerate-${Date.now().toString(36)}`, + readableId: `log-${projectRecord.slug}-blueprint-regenerate-${Date.now().toString(36)}`, + type: "blueprint_stale_nodes_regenerated", + title: "Blueprint stale nodes regenerated", + summary: `${runs.length} stale blueprint node(s) were regenerated into review-gated drafts.`, + linkedEntityIdsJson: toJson([graph.slug ?? graph.id, ...runs.map((run) => run.nodeId)]), + source: "blueprint_engine_v54", + importance: "important", + payloadJson: toJson({ graphId: graph.slug ?? graph.id, regeneratedNodeIds: runs.map((run) => run.nodeId), remainingStaleCount: Math.max(0, staleNodes.length - runs.length) }), + }, + }); + } + + return { + graphId: graph.slug ?? graph.id, + regeneratedCount: runs.length, + regeneratedNodeIds: runs.map((run) => run.nodeId), + createdReviewItemIds: runs.map((run) => run.reviewItemId).filter(Boolean), + createdDocumentationItemIds: runs.map((run) => run.documentationItemId).filter(Boolean), + skippedCount: Math.max(0, staleNodes.length - runs.length), + message: runs.length + ? `${runs.length} stale blueprint node(s) regenerated and sent through review gates.` + : "No stale blueprint nodes were available for regeneration.", + }; +}; diff --git a/backend/src/repositories/brainstormRepository.ts b/backend/src/repositories/brainstormRepository.ts new file mode 100644 index 0000000..48945b5 --- /dev/null +++ b/backend/src/repositories/brainstormRepository.ts @@ -0,0 +1,518 @@ +import type { Prisma } from "@prisma/client"; +import { ApprovalState, EntityType, EvidenceStrength, FeatureLifecycle, FeatureStatus, ReviewStatus, VerificationState } from "@prisma/client"; +import { markInitiativePlannedFromReview } from "./initiativesRepository.js"; +import { prisma } from "../db/prisma.js"; + +export type BrainstormSessionDto = { + id: string; + projectId: string; + title: string; + prompt: string; + scope: string; + mode: string; + status: string; + targetEntityId?: string; + targetEntityType?: string; + generatedBy: string; + contextSummary: string; + ideas: unknown[]; + clusters: unknown[]; + outcomes: unknown[]; + selectedIdeaIds: string[]; + nextActions: string[]; + createdAt: string; + updatedAt: string; +}; + +export type BrainstormConversionTarget = "feature" | "documentation" | "context_pack" | "blueprint_node" | "initiative"; + +export type BrainstormConversionResultDto = { + success: true; + message: string; + target: BrainstormConversionTarget; + brainstorm: BrainstormSessionDto; + createdEntityId?: string; + reviewItemId?: string; + targetRoute?: string; + affectedEntities: string[]; +}; + +export type BrainstormReviewSyncResultDto = { + applied: boolean; + action: "accept" | "reject"; + reviewItemId: string; + brainstormId?: string; + acceptedTruthId?: string; + affectedEntities: string[]; + message: string; +}; + +const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const toArray = (value: Prisma.JsonValue | null | undefined): unknown[] => Array.isArray(value) ? value : []; +const toStringArray = (value: Prisma.JsonValue | null | undefined): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +const slugify = (value: string) => value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "brainstorm"; + +const getProject = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }}); + if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`); + return project; +}; + +type BrainstormRecord = Prisma.BrainstormSessionGetPayload<{ include: { project: { select: { slug: true } } } }>; + +const toDto = (session: BrainstormRecord): BrainstormSessionDto => ({ + id: session.id, + projectId: session.project.slug, + title: session.title, + prompt: session.prompt, + scope: session.scope, + mode: session.mode, + status: session.status, + targetEntityId: session.targetEntityId ?? undefined, + targetEntityType: session.targetEntityType ?? undefined, + generatedBy: session.generatedBy, + contextSummary: session.contextSummary, + ideas: toArray(session.ideasJson), + clusters: toArray(session.clustersJson), + outcomes: toArray(session.outcomesJson), + selectedIdeaIds: toStringArray(session.selectedIdeaIdsJson), + nextActions: toStringArray(session.nextActionsJson), + createdAt: session.createdAt.toISOString(), + updatedAt: session.updatedAt.toISOString(), +}); + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; +const getString = (value: unknown, fallback = ""): string => typeof value === "string" ? value : fallback; +const getNumber = (value: unknown, fallback = 72): number => typeof value === "number" && Number.isFinite(value) ? value : fallback; + +const getSelectedIdeas = (session: BrainstormSessionDto) => { + const selected = new Set(session.selectedIdeaIds); + const ideas = session.ideas.filter(isRecord); + const selectedIdeas = ideas.filter((idea) => selected.has(getString(idea.id))); + return selectedIdeas.length ? selectedIdeas : ideas.slice(0, 3); +}; + +const getTopIdea = (session: BrainstormSessionDto): Record | undefined => + [...getSelectedIdeas(session)].sort((left, right) => getNumber(right.score) - getNumber(left.score))[0]; + +const averageScore = (session: BrainstormSessionDto): number => { + const ideas = session.ideas.filter(isRecord); + if (!ideas.length) return 72; + return Math.round(ideas.reduce((total, idea) => total + getNumber(idea.score), 0) / ideas.length); +}; + +const toSelectedIdeaMarkdown = (session: BrainstormSessionDto): string => { + const selected = getSelectedIdeas(session); + if (!selected.length) return "- No selected ideas yet."; + return selected.map((idea) => `- **${getString(idea.title, "Idea")}** (${getNumber(idea.score)}/100): ${getString(idea.description, "No description.")}`).join("\n"); +}; + +const markOutcomeConverted = (session: BrainstormSessionDto, target: BrainstormConversionTarget, targetRoute?: string) => { + const outcomeTypeByTarget: Record = { + feature: ["feature_candidate", "implementation_slice"], + documentation: ["documentation_candidate"], + context_pack: ["context_pack_seed"], + blueprint_node: ["documentation_candidate", "implementation_slice"], + initiative: ["initiative_candidate", "implementation_slice"], + }; + const targetTypes = outcomeTypeByTarget[target]; + return session.outcomes.map((outcome) => { + if (!isRecord(outcome)) return outcome; + const type = getString(outcome.type); + if (!targetTypes.includes(type)) return outcome; + return { ...outcome, conversionStatus: "converted", targetRoute }; + }); +}; + +const findSession = async (projectId: string, brainstormId: string) => + prisma.brainstormSession.findFirst({ + where: { projectId, deletedAt: null, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }] }, + include: { project: { select: { slug: true } } }, + }); + +export const listBrainstormsForProject = async (projectIdOrSlug: string): Promise => { + const project = await getProject(projectIdOrSlug); + const sessions = await prisma.brainstormSession.findMany({ + where: { projectId: project.id, deletedAt: null }, + include: { project: { select: { slug: true }}}, + orderBy: { updatedAt: "desc" }, + }); + return sessions.map(toDto); +}; + +export const createBrainstormForProject = async (projectIdOrSlug: string, input: { + title: string; + prompt: string; + scope: string; + mode: string; + status?: string; + targetEntityId?: string; + targetEntityType?: string; + generatedBy?: string; + contextSummary?: string; + ideas?: unknown[]; + clusters?: unknown[]; + outcomes?: unknown[]; + selectedIdeaIds?: string[]; + nextActions?: string[]; +}): Promise => { + const project = await getProject(projectIdOrSlug); + const slugBase = slugify(input.title); + const session = await prisma.brainstormSession.create({ + data: { + projectId: project.id, + slug: `${slugBase}-${Date.now().toString(36)}`, + title: input.title, + prompt: input.prompt, + scope: input.scope, + mode: input.mode, + status: input.status ?? "exploring", + targetEntityId: input.targetEntityId, + targetEntityType: input.targetEntityType, + generatedBy: input.generatedBy ?? "api", + contextSummary: input.contextSummary ?? `${project.name} • ${input.scope}/${input.mode}`, + ideasJson: toJson(input.ideas ?? []), + clustersJson: toJson(input.clusters ?? []), + outcomesJson: toJson(input.outcomes ?? []), + selectedIdeaIdsJson: toJson(input.selectedIdeaIds ?? []), + nextActionsJson: toJson(input.nextActions ?? []), + metadataJson: toJson({ reviewBeforeTruth: true }), + }, + include: { project: { select: { slug: true }}}, + }); + return toDto(session); +}; + +export const updateBrainstormForProject = async (projectIdOrSlug: string, brainstormId: string, input: Partial<{ + title: string; + prompt: string; + scope: string; + mode: string; + status: string; + targetEntityId: string; + targetEntityType: string; + ideas: unknown[]; + clusters: unknown[]; + outcomes: unknown[]; + selectedIdeaIds: string[]; + nextActions: string[]; +}>): Promise => { + const project = await getProject(projectIdOrSlug); + const existing = await prisma.brainstormSession.findFirst({ where: { projectId: project.id, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }], deletedAt: null }}); + if (!existing) return undefined; + const session = await prisma.brainstormSession.update({ + where: { id: existing.id }, + data: { + title: input.title, + prompt: input.prompt, + scope: input.scope, + mode: input.mode, + status: input.status, + targetEntityId: input.targetEntityId, + targetEntityType: input.targetEntityType, + ideasJson: input.ideas ? toJson(input.ideas) : undefined, + clustersJson: input.clusters ? toJson(input.clusters) : undefined, + outcomesJson: input.outcomes ? toJson(input.outcomes) : undefined, + selectedIdeaIdsJson: input.selectedIdeaIds ? toJson(input.selectedIdeaIds) : undefined, + nextActionsJson: input.nextActions ? toJson(input.nextActions) : undefined, + }, + include: { project: { select: { slug: true }}}, + }); + return toDto(session); +}; + +const createReviewForConversion = async (projectId: string, projectSlug: string, session: BrainstormSessionDto, target: BrainstormConversionTarget, createdEntityId?: string, targetRoute?: string) => { + const now = new Date(); + const readableTarget = target.replace("_", " "); + const slug = `rq-brainstorm-${slugify(session.title)}-${target}-${now.getTime()}`; + const record = await prisma.reviewQueueItem.create({ + data: { + projectId, + slug, + readableId: slug, + type: target === "feature" ? "feature" : target === "documentation" ? "documentation_update" : target === "context_pack" ? "knowledge" : target === "initiative" ? "initiative" : "documentation_update", + title: `Review brainstorm conversion: ${session.title}`, + description: `Brainstorm CPU proposes converting '${session.title}' into a ${readableTarget}.\n\nSelected ideas:\n${toSelectedIdeaMarkdown(session)}`, + priority: target === "feature" ? "important" : "normal", + status: ReviewStatus.pending, + source: "brainstorm_cpu", + confidence: averageScore(session), + approvalState: ApprovalState.pending, + evidenceStrength: EvidenceStrength.medium, + verificationState: VerificationState.needs_evidence, + suggestedAction: `Review the brainstorm output before accepting the ${readableTarget} as project truth.`, + createdBy: "api", + updatedBy: "api", + sourceType: "brainstorm_cpu_conversion", + createdEntityType: target === "feature" ? EntityType.feature : target === "documentation" ? EntityType.documentation_item : target === "context_pack" ? EntityType.context_pack : target === "initiative" ? EntityType.initiative : undefined, + createdEntityId, + targetEntityType: undefined, + targetEntityId: session.id, + payloadJson: toJson({ brainstormId: session.id, conversionTarget: target, createdEntityId, targetRoute, selectedIdeaIds: session.selectedIdeaIds }), + provenanceJson: toJson({ createdFrom: "Brainstorm CPU conversion", projectSlug }), + }, + }); + return record.slug ?? record.id; +}; + +export const convertBrainstormForProject = async ( + projectIdOrSlug: string, + brainstormId: string, + target: BrainstormConversionTarget, +): Promise => { + const project = await getProject(projectIdOrSlug); + const record = await findSession(project.id, brainstormId); + if (!record) return undefined; + const session = toDto(record); + const topIdea = getTopIdea(session); + const score = averageScore(session); + const suffix = `${slugify(session.title)}-${Date.now().toString(36)}`; + let createdEntityId: string | undefined; + let targetRoute: string | undefined; + + if (target === "feature") { + const group = await prisma.featureGroup.upsert({ + where: { projectId_slug: { projectId: project.id, slug: "brainstorm-lab" } }, + create: { projectId: project.id, slug: "brainstorm-lab", readableId: "brainstorm-lab", name: "Brainstorm Lab", description: "Feature candidates converted from Brainstorm CPU sessions.", focusArea: "Exploration" }, + update: {}, + }); + const feature = await prisma.feature.create({ + data: { + projectId: project.id, + featureGroupId: group.id, + slug: `brainstorm-feature-${suffix}`, + readableId: `brainstorm-feature-${suffix}`, + name: session.title, + description: getString(topIdea?.description, session.prompt), + status: FeatureStatus.todo, + lifecycle: FeatureLifecycle.feature_candidate, + intent: `Converted from Brainstorm CPU session ${session.id}. Review before implementation.`, + confidence: score, + health: 70, + approvalState: ApprovalState.pending, + evidenceStrength: EvidenceStrength.medium, + verificationState: VerificationState.needs_evidence, + trustJson: toJson({ source: "brainstorm_cpu", confidence: score, brainstormId: session.id }), + provenanceJson: toJson({ sourceBrainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds }), + sourceType: "brainstorm_cpu_conversion", + }, + }); + createdEntityId = feature.slug ?? feature.id; + targetRoute = `/projects/${project.slug}/features/${feature.slug ?? feature.id}`; + } + + if (target === "documentation") { + const doc = await prisma.documentationItem.create({ + data: { + projectId: project.id, + slug: `brainstorm-doc-${suffix}`, + readableId: `brainstorm-doc-${suffix}`, + title: `Brainstorm documentation seed: ${session.title}`, + kind: "brainstorm_seed", + status: "draft", + approvalState: ApprovalState.pending, + content: `# ${session.title}\n\n${session.prompt}\n\n## Selected ideas\n${toSelectedIdeaMarkdown(session)}\n`, + metadataJson: toJson({ brainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds }), + provenanceJson: toJson({ source: "brainstorm_cpu_conversion" }), + sourceType: "brainstorm_cpu_conversion", + }, + }); + createdEntityId = doc.slug ?? doc.id; + targetRoute = `/projects/${project.slug}/documentation`; + } + + if (target === "context_pack") { + const pack = await prisma.contextPack.create({ + data: { + projectId: project.id, + slug: `brainstorm-context-${suffix}`, + readableId: `brainstorm-context-${suffix}`, + title: `Brainstorm context: ${session.title}`, + category: "brainstorm", + summary: `Reusable context generated from Brainstorm CPU session '${session.title}'.`, + generatedBy: "brainstorm_cpu", + confidence: score, + approvalState: ApprovalState.pending, + qualityJson: toJson({ coverage: score, reviewBeforeTruth: true }), + trustJson: toJson({ source: "brainstorm_cpu", brainstormId: session.id }), + provenanceJson: toJson({ selectedIdeaIds: session.selectedIdeaIds }), + sourceType: "brainstorm_cpu_conversion", + sections: { + create: [ + { projectId: project.id, slug: "selected-ideas", title: "Selected ideas", sectionType: "signals", orderIndex: 1, contentJson: toJson(getSelectedIdeas(session)) }, + { projectId: project.id, slug: "outcomes", title: "Outcome candidates", sectionType: "outcomes", orderIndex: 2, contentJson: toJson(session.outcomes) }, + { projectId: project.id, slug: "next-actions", title: "Next actions", sectionType: "actions", orderIndex: 3, contentJson: toJson(session.nextActions) }, + ], + }, + }, + }); + createdEntityId = pack.slug ?? pack.id; + targetRoute = `/projects/${project.slug}/context-packs`; + } + + if (target === "initiative") { + const initiative = await prisma.initiative.create({ + data: { + projectId: project.id, + slug: `brainstorm-initiative-${suffix}`, + readableId: `brainstorm-initiative-${suffix}`, + title: `Brainstorm initiative: ${session.title}`, + intent: `${session.prompt}\n\nSelected ideas:\n${toSelectedIdeaMarkdown(session)}`, + status: "proposed", + priority: getSelectedIdeas(session).some((idea) => getString(idea.impact) === "high") ? "important" : "normal", + confidence: score, + sourceBrainstormId: session.id, + featureIdsJson: toJson([]), + approvalState: ApprovalState.pending, + metadataJson: toJson({ sourceBrainstormId: session.id, selectedIdeaIds: session.selectedIdeaIds, reviewBeforeTruth: true }), + provenanceJson: toJson({ source: "brainstorm_cpu_conversion" }), + }, + }); + createdEntityId = initiative.slug ?? initiative.id; + targetRoute = `/projects/${project.slug}/initiatives`; + } + + if (target === "blueprint_node") { + createdEntityId = `blueprint-seed-${suffix}`; + targetRoute = `/projects/${project.slug}/blueprint?brainstormId=${session.id}`; + } + + const reviewItemId = await createReviewForConversion(project.id, project.slug, session, target, createdEntityId, targetRoute); + const updatedSession = await prisma.brainstormSession.update({ + where: { id: record.id }, + data: { + status: "converted", + outcomesJson: toJson(markOutcomeConverted(session, target, targetRoute)), + metadataJson: toJson({ reviewBeforeTruth: true, lastConversionTarget: target, lastConvertedEntityId: createdEntityId, lastReviewItemId: reviewItemId }), + }, + include: { project: { select: { slug: true }}}, + }); + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: `log-brainstorm-conversion-${suffix}`, + readableId: `log-brainstorm-conversion-${suffix}`, + type: "brainstorm conversion", + title: `Brainstorm converted: ${session.title}`, + summary: `Brainstorm CPU converted selected ideas into ${target.replace("_", " ")} and queued review before truth.`, + linkedEntityIdsJson: toJson([session.id, reviewItemId, createdEntityId].filter(Boolean)), + source: "brainstorm_cpu", + importance: target === "feature" ? "important" : "normal", + payloadJson: toJson({ target, targetRoute }), + provenanceJson: toJson({ sourceBrainstormId: session.id }), + }, + }); + + return { + success: true, + message: `Brainstorm '${session.title}' converted to ${target.replace("_", " ")} candidate and queued for review.`, + target, + brainstorm: toDto(updatedSession), + createdEntityId, + reviewItemId, + targetRoute, + affectedEntities: [session.id, reviewItemId, createdEntityId].filter((item): item is string => Boolean(item)), + }; +}; + +export const syncBrainstormReviewAction = async ( + projectIdOrSlug: string, + reviewItemId: string, + action: "accept" | "reject", +): Promise => { + const project = await getProject(projectIdOrSlug); + const review = await prisma.reviewQueueItem.findFirst({ + where: { projectId: project.id, deletedAt: null, OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }] }, + }); + if (!review) return undefined; + if (review.source !== "brainstorm_cpu" && review.sourceType !== "brainstorm_cpu_conversion") return undefined; + const payload = isRecord(review.payloadJson) ? review.payloadJson : {}; + const brainstormId = getString(payload.brainstormId, review.targetEntityType === EntityType.brainstorm_session ? review.targetEntityId ?? "" : ""); + const affectedEntities = [review.slug ?? review.id]; + + if (brainstormId) { + await prisma.brainstormSession.updateMany({ + where: { projectId: project.id, OR: [{ id: brainstormId }, { slug: brainstormId }, { readableId: brainstormId }] }, + data: { status: action === "accept" ? "converted" : "shortlisted" }, + }); + affectedEntities.push(brainstormId); + } + + if (action === "accept" && getString(payload.conversionTarget) === "initiative" && review.createdEntityId) { + const plannedInitiativeId = await markInitiativePlannedFromReview(project.id, review.createdEntityId, review.slug ?? review.id); + if (plannedInitiativeId) affectedEntities.push(plannedInitiativeId); + } + + let acceptedTruthId: string | undefined; + if (action === "accept") { + const existing = await prisma.acceptedTruthItem.findFirst({ where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null } }); + if (existing) acceptedTruthId = existing.slug ?? existing.id; + else { + const now = new Date(); + const slug = `truth-brainstorm-${slugify(review.title)}-${now.getTime()}`; + const truth = await prisma.acceptedTruthItem.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + sourceReviewItemId: review.id, + kind: review.type === "documentation_update" ? "documentation" : review.type, + title: review.title, + summary: review.description, + status: "accepted", + source: "brainstorm_cpu", + confidence: review.confidence, + approvalState: ApprovalState.approved, + evidenceStrength: review.evidenceStrength, + verificationState: VerificationState.verified, + payloadJson: toJson({ + reviewItemId: review.id, + brainstormId, + conversionTarget: payload.conversionTarget, + createdEntityId: payload.createdEntityId, + targetRoute: payload.targetRoute, + candidateSummary: review.description, + nextAction: action === "accept" ? "Use this accepted brainstorm truth in context packs, roadmap, blueprint docs, or implementation planning." : undefined, + }), + provenanceJson: toJson({ + createdFrom: "Brainstorm CPU review acceptance", + reviewSource: review.source, + reviewSourceType: review.sourceType, + reviewTargetEntityType: review.targetEntityType, + reviewTargetEntityId: review.targetEntityId, + }), + verifiedAt: now, + verifiedBy: "api", + }, + }); + acceptedTruthId = truth.slug ?? truth.id; + } + if (acceptedTruthId) affectedEntities.push(acceptedTruthId); + } + + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: `log-brainstorm-review-${slugify(review.title)}-${Date.now()}`, + readableId: `log-brainstorm-review-${slugify(review.title)}-${Date.now()}`, + type: "brainstorm review sync", + title: `Brainstorm review ${action === "accept" ? "accepted" : "rejected"}`, + summary: action === "accept" ? "Accepted brainstorm conversion was materialized as accepted truth." : "Rejected brainstorm conversion was returned to shortlist state.", + linkedEntityIdsJson: toJson(affectedEntities), + source: "brainstorm_cpu", + importance: action === "accept" ? "important" : "normal", + payloadJson: toJson({ action, brainstormId, acceptedTruthId }), + provenanceJson: toJson({ reviewItemId: review.id }), + }, + }); + + return { + applied: true, + action, + reviewItemId: review.slug ?? review.id, + brainstormId, + acceptedTruthId, + affectedEntities, + message: action === "accept" ? "Brainstorm review accepted and materialized as accepted truth." : "Brainstorm review rejected and brainstorm returned to shortlist state.", + }; +}; diff --git a/backend/src/repositories/captureRepository.ts b/backend/src/repositories/captureRepository.ts new file mode 100644 index 0000000..7b2f0ee --- /dev/null +++ b/backend/src/repositories/captureRepository.ts @@ -0,0 +1,235 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { + CaptureClassificationDto, + CaptureClassificationType, + CaptureItemDto, + CaptureItemListFilters, + CaptureItemStatus, + CaptureItemType, + CreateCaptureItemRequestDto, +} from "../types/api.js"; + +type CaptureRecord = Prisma.CaptureItemGetPayload>; + +type ProjectRecord = { id: string; slug: string }; + +const toIso = (value: Date): string => value.toISOString(); + +const findProjectRecord = (projectIdOrSlug: string): Promise => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + select: { id: true, slug: true }, + }); + +const toCaptureDto = ( + record: CaptureRecord, + projectSlug: string, +): CaptureItemDto => ({ + id: record.slug ?? record.readableId ?? record.id, + projectId: projectSlug, + type: record.type as CaptureItemType, + title: record.title, + content: record.content, + source: record.source, + status: record.status as CaptureItemStatus, + ...(record.classificationType + ? { classificationType: record.classificationType as CaptureClassificationType } + : {}), + ...(record.confidence !== null ? { confidence: record.confidence } : {}), + ...(record.evidenceStrength ? { evidenceStrength: record.evidenceStrength } : {}), + ...(record.suggestedAction ? { suggestedAction: record.suggestedAction } : {}), + createdAt: toIso(record.createdAt), + updatedAt: toIso(record.updatedAt), + ...(record.linkedReviewItemId ? { linkedReviewItemId: record.linkedReviewItemId } : {}), +}); + +const splitSearch = (value: string | undefined): string | undefined => + value?.trim() || undefined; + +const toWhere = ( + projectId: string, + filters: CaptureItemListFilters = {}, +): Prisma.CaptureItemWhereInput => ({ + projectId, + deletedAt: null, + ...(filters.status?.length ? { status: { in: filters.status } } : {}), + ...(filters.type?.length ? { type: { in: filters.type } } : {}), + ...(filters.source?.length ? { source: { in: filters.source } } : {}), + ...(filters.confidence !== undefined ? { confidence: filters.confidence } : {}), + ...(filters.classificationType?.length + ? { classificationType: { in: filters.classificationType } } + : {}), +}); + +const classifyContent = ( + item: CaptureItemDto, +): Omit => { + const text = `${item.type} ${item.content}`.toLowerCase(); + if (text.includes("bug") || text.includes("error")) { + return { + classificationType: "bug", + confidence: 86, + evidenceStrength: "medium", + suggestedAction: "Send to review as bug candidate.", + }; + } + if (text.includes("task") || text.includes("todo")) { + return { + classificationType: "task", + confidence: 80, + evidenceStrength: "medium", + suggestedAction: "Send to review as task candidate.", + }; + } + if (text.includes("decision")) { + return { + classificationType: "decision", + confidence: 82, + evidenceStrength: "medium", + suggestedAction: "Send to review as decision candidate.", + }; + } + if (text.includes("rule") || text.includes("must") || text.includes("do not")) { + return { + classificationType: "memory_rule", + confidence: 90, + evidenceStrength: "strong", + suggestedAction: "Send to review as memory rule.", + }; + } + if (text.includes("docs") || text.includes("documentation")) { + return { + classificationType: "documentation_update", + confidence: 78, + evidenceStrength: "weak", + suggestedAction: "Send to review as documentation update.", + }; + } + + return { + classificationType: "knowledge", + confidence: 72, + evidenceStrength: "weak", + suggestedAction: "Send to review as knowledge item.", + }; +}; + +export const listCaptureItemsFromDatabase = async ( + projectIdOrSlug: string, + filters: CaptureItemListFilters, + options: { page: number; pageSize: number; cursor?: string; sort?: string }, +) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + + const where = toWhere(project.id, filters); + const total = await prisma.captureItem.count({ where }); + const records = await prisma.captureItem.findMany({ + where, + orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" }, + skip: (options.page - 1) * options.pageSize, + take: options.pageSize, + }); + + const pageCount = Math.ceil(total / options.pageSize); + return { + items: records.map((record) => toCaptureDto(record, project.slug)), + total, + page: options.page, + pageSize: options.pageSize, + hasNextPage: options.page < pageCount, + hasPreviousPage: options.page > 1, + ...(options.sort ? { sort: options.sort } : {}), + ...(options.cursor ? { nextCursor: undefined, cursor: options.cursor } : {}), + filters, + }; +}; + +export const getCaptureItemFromDatabase = async ( + projectIdOrSlug: string, + captureItemId: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + + const record = await prisma.captureItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: captureItemId }, { slug: captureItemId }, { readableId: captureItemId }], + }, + }); + return record ? toCaptureDto(record, project.slug) : undefined; +}; + +export const createCaptureItemInDatabase = async ( + projectIdOrSlug: string, + body: CreateCaptureItemRequestDto, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + + const createdAt = new Date(); + const title = body.content.trim().slice(0, 64) || "Captured item"; + const slug = `cap-${project.slug}-${createdAt.getTime()}`; + const record = await prisma.captureItem.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + type: body.type, + title, + content: body.content.trim(), + source: body.source?.trim() || "manual", + status: "captured", + sourceType: "runtime_api", + createdBy: "api", + updatedBy: "api", + createdAt, + metadataJson: {}, + provenanceJson: { createdFrom: "P5 capture API" }, + }, + }); + + return toCaptureDto(record, project.slug); +}; + +export const classifyCaptureItemInDatabase = async ( + projectIdOrSlug: string, + captureItemId: string, +): Promise => { + const item = await getCaptureItemFromDatabase(projectIdOrSlug, captureItemId); + if (!item) return undefined; + if (item.status === "in_review") return "CAPTURE_ALREADY_IN_REVIEW"; + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + + const classification = classifyContent(item); + const update = await prisma.captureItem.updateMany({ + where: { + projectId: project.id, + deletedAt: null, + status: { in: ["captured", "classified"] }, + linkedReviewItemId: null, + OR: [{ id: captureItemId }, { slug: captureItemId }, { readableId: captureItemId }], + }, + data: { + status: "classified", + classificationType: classification.classificationType, + confidence: classification.confidence, + evidenceStrength: classification.evidenceStrength, + suggestedAction: classification.suggestedAction, + updatedBy: "api", + }, + }); + if (update.count !== 1) return "CAPTURE_ALREADY_IN_REVIEW"; + + return { + captureItemId, + ...classification, + }; +}; diff --git a/backend/src/repositories/contextPacksRepository.ts b/backend/src/repositories/contextPacksRepository.ts new file mode 100644 index 0000000..4216a8c --- /dev/null +++ b/backend/src/repositories/contextPacksRepository.ts @@ -0,0 +1,367 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { + ContextPackDto, + ContextPackSummaryDto, + CreateContextPackRequestDto, + EvidenceRefDto, + GeneratePromptRequestDto, + GeneratedPromptDto, + ProjectDna, +} from "../types/api.js"; + +const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) => + value.slug ?? value.readableId ?? value.id; + +const toJson = (value: unknown): Prisma.InputJsonValue => + JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const asStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + +const asEvidenceRefs = (value: unknown): EvidenceRefDto[] => + Array.isArray(value) ? (value as EvidenceRefDto[]) : []; + +type ContextPackMetadata = { + dna?: ProjectDna; + assumptions?: string[]; + constraints?: string[]; + evidence?: EvidenceRefDto[]; + signals?: string[]; + featureIds?: string[]; + bugIds?: string[]; + taskIds?: string[]; + decisionIds?: string[]; + memoryRuleIds?: string[]; + architectureNotes?: string[]; + regressions?: string[]; + risks?: string[]; + currentFocus?: ContextPackDto["currentFocus"]; +}; + +const toMetadata = (value: Prisma.JsonValue | null): ContextPackMetadata => + isRecord(value) ? (value as ContextPackMetadata) : {}; + +const findProjectRecordBySlugOrId = (projectIdOrSlug: string) => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + include: { + dna: true, + }, + }); + +const toProjectDna = (project: NonNullable>>): ProjectDna => ({ + vision: project.dna?.vision ?? "", + goals: asStringArray(project.dna?.goalsJson), + nonGoals: asStringArray(project.dna?.nonGoalsJson), + targetAudience: project.dna?.targetAudience ?? "", + principles: asStringArray(project.dna?.principlesJson), + successMetrics: Array.isArray(project.dna?.metricsJson) ? (project.dna?.metricsJson as ProjectDna["successMetrics"]) : [], + maturityStage: (project.dna?.maturityStage as ProjectDna["maturityStage"]) ?? "Discovery", + revision: project.dna?.revision ?? 1, + updatedAt: project.dna?.updatedAt.toISOString() ?? project.updatedAt.toISOString(), +}); + +type DbContextPackRecord = Awaited> & { + sections?: Array<{ + id: string; + slug: string | null; + title: string; + sectionType: string; + orderIndex: number; + contentJson: Prisma.JsonValue; + metadataJson: Prisma.JsonValue | null; + }>; +}; + +const toContextPackDto = async ( + contextPack: NonNullable, +): Promise => { + const project = await findProjectRecordBySlugOrId(contextPack.projectId); + const metadata = toMetadata(contextPack.metadataJson); + const quality = isRecord(contextPack.qualityJson) ? (contextPack.qualityJson as ContextPackDto["quality"]) : undefined; + const trust = isRecord(contextPack.trustJson) + ? (contextPack.trustJson as ContextPackDto["trust"]) + : { + source: "database context pack", + confidence: contextPack.confidence, + evidence: [], + lastChecked: contextPack.updatedAt.toISOString(), + approvalState: contextPack.approvalState, + }; + + return { + id: toPublicId(contextPack), + projectId: project?.slug ?? contextPack.projectId, + title: contextPack.title, + category: contextPack.category as ContextPackDto["category"], + dna: metadata.dna ?? (project ? toProjectDna(project) : { + vision: "", + goals: [], + nonGoals: [], + targetAudience: "", + principles: [], + successMetrics: [], + maturityStage: "Discovery", + revision: 0, + updatedAt: contextPack.updatedAt.toISOString(), + }), + summary: contextPack.summary, + assumptions: metadata.assumptions ?? [], + constraints: metadata.constraints ?? [], + evidence: metadata.evidence ?? [], + signals: metadata.signals ?? [], + featureIds: metadata.featureIds, + bugIds: metadata.bugIds, + taskIds: metadata.taskIds, + decisionIds: metadata.decisionIds, + memoryRuleIds: metadata.memoryRuleIds, + architectureNotes: metadata.architectureNotes, + regressions: metadata.regressions, + risks: metadata.risks, + quality, + currentFocus: metadata.currentFocus, + trust, + generatedBy: contextPack.generatedBy as ContextPackDto["generatedBy"], + createdAt: contextPack.createdAt.toISOString(), + updatedAt: contextPack.updatedAt.toISOString(), + }; +}; + +export const projectExists = async (projectIdOrSlug: string): Promise => + Boolean(await findProjectRecordBySlugOrId(projectIdOrSlug)); + +export const listContextPacksForProject = async ( + projectIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + const contextPacks = await prisma.contextPack.findMany({ + where: { projectId: project.id, deletedAt: null }, + orderBy: [{ updatedAt: "desc" }], + include: { sections: { orderBy: { orderIndex: "asc" } } }, + }); + + return Promise.all(contextPacks.map(toContextPackDto)); +}; + +export const getContextPackBySlugOrId = async ( + projectIdOrSlug: string, + contextPackIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + const contextPack = await prisma.contextPack.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [ + { id: contextPackIdOrSlug }, + { slug: contextPackIdOrSlug }, + { readableId: contextPackIdOrSlug }, + ], + }, + include: { sections: { orderBy: { orderIndex: "asc" } } }, + }); + + return contextPack ? toContextPackDto(contextPack) : undefined; +}; + +export const getContextPackSummaryBySlugOrId = async ( + projectIdOrSlug: string, + contextPackIdOrSlug: string, +): Promise => { + const contextPack = await getContextPackBySlugOrId(projectIdOrSlug, contextPackIdOrSlug); + if (!contextPack) return undefined; + + return { + projectId: contextPack.projectId, + contextPackId: contextPack.id, + title: contextPack.title, + category: contextPack.category, + summary: contextPack.summary, + counts: { + assumptions: contextPack.assumptions.length, + constraints: contextPack.constraints.length, + evidence: contextPack.evidence.length, + features: contextPack.featureIds?.length ?? 0, + bugs: contextPack.bugIds?.length ?? 0, + tasks: contextPack.taskIds?.length ?? 0, + decisions: contextPack.decisionIds?.length ?? 0, + memoryRules: contextPack.memoryRuleIds?.length ?? 0, + risks: contextPack.risks?.length ?? 0, + }, + qualityScore: contextPack.quality?.score, + createdAt: contextPack.createdAt, + updatedAt: contextPack.updatedAt, + }; +}; + +const toGeneratedBy = (sourceType: CreateContextPackRequestDto["sourceType"]): ContextPackDto["generatedBy"] => { + if (sourceType === "check") return "check-run"; + if (sourceType === "manual") return "manual"; + return "user"; +}; + +export const createContextPackForProject = async ( + projectIdOrSlug: string, + request: CreateContextPackRequestDto, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + const dna = toProjectDna(project); + const features = await prisma.feature.findMany({ + where: { projectId: project.id, deletedAt: null }, + orderBy: { name: "asc" }, + }); + const evidence = await prisma.evidence.findMany({ + where: { projectId: project.id, deletedAt: null }, + orderBy: { lastSeen: "desc" }, + }); + const featureIds = + request.includeFeatures === false + ? [] + : request.sourceType === "feature" && request.sourceId + ? [request.sourceId] + : features.map((feature) => feature.slug ?? feature.readableId ?? feature.id); + const evidenceRefs: EvidenceRefDto[] = + request.includeEvidence === false + ? [] + : evidence.slice(0, 10).map((item) => ({ + id: `er-${item.slug ?? item.readableId ?? item.id}`, + type: "analysis", + refId: item.slug ?? item.readableId ?? item.id, + label: item.reason, + })); + const createdAt = new Date(); + const slug = `cp-${project.slug}-${request.sourceType}-${createdAt.getTime().toString(36)}`; + const title = request.title?.trim() || `${project.name} ${request.sourceType.replace("_", " ")} context pack`; + const metadata: ContextPackMetadata = { + dna, + assumptions: request.sourceType === "manual" ? ["Manual context source requires human review."] : ["Database-generated context should be verified before use."], + constraints: ["Context pack generated from database-backed project data."], + evidence: evidenceRefs, + signals: ["database-generated", request.sourceType], + featureIds, + memoryRuleIds: [], + architectureNotes: request.includeArchitecture === false ? [] : [`Database-backed architecture context for ${project.name}.`], + risks: request.includeRisks === false ? [] : ["Database-generated context still needs review before implementation."], + regressions: request.includeRegressions === false ? [] : [], + }; + const quality = { + dnaIncluded: true, + constitutionIncluded: false, + constraintsIncluded: true, + featuresIncluded: request.includeFeatures !== false, + evidenceIncluded: request.includeEvidence !== false, + risksIncluded: request.includeRisks !== false, + memoryIncluded: request.includeMemoryRules !== false, + doNotBreakIncluded: true, + verificationIncluded: false, + score: request.includeEvidence === false ? 58 : 74, + }; + const trust = { + source: "database context-pack create", + confidence: request.includeEvidence === false ? 64 : 82, + evidence: evidenceRefs, + lastChecked: createdAt.toISOString(), + approvalState: "pending" as const, + }; + + const contextPack = await prisma.contextPack.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + title, + category: request.sourceType === "feature" ? "feature" : "quality", + summary: `Created from database project ${project.slug} via ${request.sourceType} context source. DNA is always included.`, + generatedBy: toGeneratedBy(request.sourceType), + confidence: trust.confidence, + approvalState: "pending", + qualityJson: toJson(quality), + trustJson: toJson(trust), + metadataJson: toJson(metadata), + createdBy: "runtime", + updatedBy: "runtime", + sourceType: "database_runtime", + sections: { + create: [ + { + projectId: project.id, + slug: "dna", + title: "Project DNA", + sectionType: "dna", + orderIndex: 1, + contentJson: toJson(dna), + }, + { + projectId: project.id, + slug: "features", + title: "Relevant features", + sectionType: "features", + orderIndex: 2, + contentJson: toJson(featureIds), + }, + { + projectId: project.id, + slug: "evidence", + title: "Evidence references", + sectionType: "evidence", + orderIndex: 3, + contentJson: toJson(evidenceRefs), + }, + ], + }, + }, + include: { sections: { orderBy: { orderIndex: "asc" } } }, + }); + + return toContextPackDto(contextPack); +}; + +export const generatePromptFromContextPack = async ( + projectIdOrSlug: string, + request: GeneratePromptRequestDto, +): Promise => { + const contextPack = await getContextPackBySlugOrId(projectIdOrSlug, request.contextPackId); + if (!contextPack) return undefined; + + const createdAt = new Date().toISOString(); + const promptId = `prompt-${contextPack.projectId}-${Date.now().toString(36)}`; + const expectedOutput = request.expectedOutput?.trim() || "Clear implementation notes and validation steps."; + const generatedText = [ + `Generated from context pack ${contextPack.id} (${contextPack.title}).`, + `Target tool: ${request.targetTool}.`, + `Persona: ${request.persona}.`, + `Objective: ${request.objective.trim()}.`, + `Expected output: ${expectedOutput}`, + `Context summary: ${contextPack.summary}`, + `Project vision: ${contextPack.dna.vision}`, + `Relevant features: ${(contextPack.featureIds ?? []).join(", ") || "none"}.`, + `Risks: ${(contextPack.risks ?? []).join(" ") || "none listed"}.`, + "Use only the supplied context pack facts and mark uncertainty explicitly.", + ].join("\n"); + + return { + id: promptId, + projectId: contextPack.projectId, + contextPackId: contextPack.id, + targetTool: request.targetTool, + persona: request.persona, + objective: request.objective.trim(), + expectedOutput, + generatedText, + generatedFrom: "context-pack", + createdAt, + }; +}; diff --git a/backend/src/repositories/evidenceRepository.ts b/backend/src/repositories/evidenceRepository.ts new file mode 100644 index 0000000..1123e46 --- /dev/null +++ b/backend/src/repositories/evidenceRepository.ts @@ -0,0 +1,119 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { ApprovalState, EvidenceDto, EvidenceType } from "../types/api.js"; + +const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) => + value.slug ?? value.readableId ?? value.id; + +type EvidenceMetadata = { + linkedEntity?: EvidenceDto["linkedEntity"]; + featureIds?: string[]; + route?: string; +}; + +type DbEvidenceRecord = { + id: string; + projectId: string; + slug: string | null; + readableId: string | null; + type: string; + source: string; + confidence: number; + approvalState: string; + reason: string; + metadataJson: Prisma.JsonValue | null; + lastSeen: Date; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const toEvidenceMetadata = (value: Prisma.JsonValue | null): EvidenceMetadata => { + if (!isRecord(value)) return {}; + return value as EvidenceMetadata; +}; + +const findProjectRecordBySlugOrId = (projectIdOrSlug: string) => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + }); + +const toEvidenceDto = ( + evidence: DbEvidenceRecord, + projectSlug: string, +): EvidenceDto => { + const metadata = toEvidenceMetadata(evidence.metadataJson); + + return { + id: toPublicId(evidence), + projectId: projectSlug, + type: evidence.type as EvidenceType, + linkedEntity: metadata.linkedEntity ?? { + type: "feature", + id: "unknown", + label: "Unknown linked entity", + }, + featureIds: metadata.featureIds ?? [], + source: evidence.source, + confidence: evidence.confidence, + reason: evidence.reason, + lastSeen: evidence.lastSeen.toISOString(), + approvalState: evidence.approvalState as ApprovalState, + route: metadata.route, + }; +}; + +export const listEvidenceForProject = async ( + projectIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + const evidence = await prisma.evidence.findMany({ + where: { + projectId: project.id, + deletedAt: null, + }, + orderBy: [{ lastSeen: "desc" }], + }); + + return evidence.map((item) => toEvidenceDto(item, project.slug)); +}; + +export const getEvidenceBySlugOrId = async ( + projectIdOrSlug: string, + evidenceIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + const evidence = await prisma.evidence.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: evidenceIdOrSlug }, { slug: evidenceIdOrSlug }, { readableId: evidenceIdOrSlug }], + }, + }); + + if (!evidence) return undefined; + + return toEvidenceDto(evidence, project.slug); +}; + +export const listEvidenceForFeature = async ( + projectIdOrSlug: string, + featureIdOrSlug: string, +): Promise => { + const evidence = await listEvidenceForProject(projectIdOrSlug); + if (!evidence) return undefined; + + return evidence.filter((item) => item.featureIds.includes(featureIdOrSlug)); +}; + +export const projectExists = async (projectIdOrSlug: string): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + return Boolean(project); +}; diff --git a/backend/src/repositories/featureDeliveryRepository.ts b/backend/src/repositories/featureDeliveryRepository.ts new file mode 100644 index 0000000..894364e --- /dev/null +++ b/backend/src/repositories/featureDeliveryRepository.ts @@ -0,0 +1,360 @@ +import { + ApprovalState, + DeliveryChangeOperation, + DeliveryStatus, + DeliveryValidationStatus, + type Prisma, +} from "@prisma/client"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../db/prisma.js"; +import { createInitiativeForProject } from "./initiativesRepository.js"; + +export type FeatureDeliveryStatus = + | "draft" | "awaiting_approval" | "approved" | "executing" | "validating" + | "pull_request_open" | "merged" | "verified" | "released" | "blocked" + | "failed" | "cancelled" | "superseded"; +export type FeatureDeliveryRiskLevel = "low" | "medium" | "high" | "destructive"; +export type FeatureValidationResult = { command: string; status: "passed" | "failed" | "not_run"; summary?: string }; +export type FeatureValidationEvidence = { + provider: "gitea_actions"; runId: number; runNumber: number; url: string; workflowPath: string; + headSha: string; conclusion: "success"; verifiedAt: string; +}; +export type FeatureDeliverySpec = { + version: number; problem: string; desiredOutcome: string; users: string[]; goals: string[]; nonGoals: string[]; + acceptanceCriteria: string[]; implementationPlan: string[]; risks: string[]; validationCommands: string[]; + status: FeatureDeliveryStatus; branch?: string; repositoryId?: string; changedFiles: string[]; progressNotes: string[]; + reviewFeedback?: string; + validationResults: FeatureValidationResult[]; validationEvidence?: FeatureValidationEvidence; + pullRequest?: { number: number; url: string; title: string; state: string; mergedAt?: string; mergeCommitSha?: string }; + updatedBy: string; updatedAt: string; +}; +export type FeatureDeliveryDto = { + id: string; projectId: string; title: string; intent: string; priority: "critical" | "important" | "normal" | "low"; + approvalState: string; spec: FeatureDeliverySpec; createdAt: string; updatedAt: string; +}; +export type FeatureDeliveryInput = { + title: string; intent: string; priority?: FeatureDeliveryDto["priority"]; problem: string; desiredOutcome: string; + users?: string[]; goals: string[]; nonGoals?: string[]; acceptanceCriteria: string[]; implementationPlan: string[]; + risks?: string[]; validationCommands: string[]; riskLevel?: FeatureDeliveryRiskLevel; +}; +export type DeliveryChangeInput = { + path: string; operation: "create" | "update" | "delete" | "rename"; previousPath?: string; + beforeSha?: string; afterSha?: string; commitSha?: string; +}; + +const deliveryTransitions: Record = { + draft: ["awaiting_approval", "approved", "cancelled"], + awaiting_approval: ["awaiting_approval", "approved", "cancelled"], + approved: ["approved", "executing", "awaiting_approval", "cancelled"], + executing: ["executing", "validating", "blocked", "failed", "cancelled"], + validating: ["validating", "pull_request_open", "blocked", "failed", "cancelled"], + pull_request_open: ["pull_request_open", "merged", "failed", "blocked", "cancelled"], + merged: ["merged", "verified", "superseded"], + verified: ["verified", "released", "superseded"], + released: ["released", "superseded"], + blocked: ["blocked", "approved", "cancelled"], + failed: ["failed", "approved", "cancelled"], + cancelled: ["cancelled"], + superseded: ["superseded"], +}; + +export const canTransitionFeatureDeliveryStatus = (from: FeatureDeliveryStatus, to: FeatureDeliveryStatus) => deliveryTransitions[from]?.includes(to) ?? false; +export const assertFeatureDeliveryTransition = (from: FeatureDeliveryStatus, to: FeatureDeliveryStatus) => { + if (!canTransitionFeatureDeliveryStatus(from, to)) throw new Error("FEATURE_DELIVERY_INVALID_TRANSITION"); +}; + +const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +const publicId = (value: { id: string; slug?: string | null }) => value.slug ?? value.id; +const toPriority = (value: string): FeatureDeliveryDto["priority"] => + (["critical", "important", "normal", "low"].includes(value) ? value : "normal") as FeatureDeliveryDto["priority"]; +const validationStatus = (status: DeliveryValidationStatus): FeatureValidationResult["status"] => + status === DeliveryValidationStatus.passed ? "passed" : status === DeliveryValidationStatus.failed ? "failed" : "not_run"; +export const actionValidationCommand = (workflowPath: string) => `Gitea Actions: ${workflowPath}`; +export const reportedValidationResult = (entry: { command: string; status: DeliveryValidationStatus; provider: string | null; summary: string | null }): FeatureValidationResult => { + // Old syncs copied one workflow result onto every planned command, including + // native PR and live-provider checks that the workflow had never executed. + const inferredLegacyResult = entry.provider === "gitea_actions" && entry.status === DeliveryValidationStatus.passed && entry.command !== actionValidationCommand(entry.summary ?? ""); + return { command: entry.command, status: inferredLegacyResult ? "not_run" : validationStatus(entry.status), + ...(inferredLegacyResult ? { summary: `Legacy inferred result; verify this check independently. Observed workflow: ${entry.summary ?? "unknown"}` } : entry.summary ? { summary: entry.summary } : {}) }; +}; +const hasStatus = (status: DeliveryStatus, allowed: DeliveryStatus[]) => allowed.includes(status); +const normalizedRiskLevel = (value: FeatureDeliveryRiskLevel | undefined): FeatureDeliveryRiskLevel => value ?? "medium"; +const requiresHumanDecision = (riskLevel: FeatureDeliveryRiskLevel) => riskLevel === "high" || riskLevel === "destructive"; +const authorizationMode = (riskLevel: FeatureDeliveryRiskLevel) => riskLevel === "low" ? "auto_allowed" : "auto_allowed_with_audit"; + +const getProject = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + return project; +}; +const planInclude = { + initiative: true, + runs: { orderBy: { createdAt: "desc" as const }, include: { + repositoryLink: true, + changes: { orderBy: { createdAt: "asc" as const } }, + validations: { orderBy: { createdAt: "asc" as const } }, + } }, +} satisfies Prisma.DeliveryPlanInclude; +type PlanWithRuntime = Prisma.DeliveryPlanGetPayload<{ include: typeof planInclude }>; + +const toDto = (projectSlug: string, plan: PlanWithRuntime): FeatureDeliveryDto => { + const run = plan.runs[0]; + const actionValidation = run?.validations.find((entry) => entry.provider === "gitea_actions" && entry.status === DeliveryValidationStatus.passed && entry.headSha); + const validationEvidence = actionValidation?.externalRunId && actionValidation.externalUrl && actionValidation.headSha && actionValidation.verifiedAt + ? { provider: "gitea_actions" as const, runId: Number(actionValidation.externalRunId), runNumber: Number(actionValidation.checkName?.replace(/^run-/, "") ?? actionValidation.externalRunId), url: actionValidation.externalUrl, workflowPath: actionValidation.summary ?? "gitea-actions", headSha: actionValidation.headSha, conclusion: "success" as const, verifiedAt: actionValidation.verifiedAt.toISOString() } + : undefined; + const pullRequest = run?.pullRequestNumber && run.pullRequestUrl && run.pullRequestTitle + ? { number: run.pullRequestNumber, url: run.pullRequestUrl, title: run.pullRequestTitle, state: run.pullRequestState ?? "open", ...(run.mergedAt ? { mergedAt: run.mergedAt.toISOString() } : {}), ...(run.mergeCommitSha ? { mergeCommitSha: run.mergeCommitSha } : {}) } + : undefined; + return { + id: publicId(plan), projectId: projectSlug, title: plan.title, intent: plan.intent, priority: toPriority(plan.priority), approvalState: plan.approvalState, + spec: { + version: plan.version, problem: plan.problem, desiredOutcome: plan.desiredOutcome, users: strings(plan.usersJson), goals: strings(plan.goalsJson), + nonGoals: strings(plan.nonGoalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson), + risks: strings(plan.risksJson), validationCommands: strings(plan.validationCommandsJson), status: plan.status, branch: run?.branch, + repositoryId: run?.repositoryLinkId, changedFiles: Array.from(new Set(run?.changes.map((change) => change.path) ?? [])), + progressNotes: run?.changes.map((change) => `${change.operation}: ${change.previousPath ? `${change.previousPath} -> ` : ""}${change.path}`) ?? [], + validationResults: run?.validations.map(reportedValidationResult) ?? [], + ...(plan.reviewFeedback ? { reviewFeedback: plan.reviewFeedback } : {}), + validationEvidence, pullRequest, updatedBy: plan.updatedBy ?? plan.createdBy, updatedAt: plan.updatedAt.toISOString(), + }, + createdAt: plan.createdAt.toISOString(), updatedAt: plan.updatedAt.toISOString(), + }; +}; +const getDeliveryRow = async (projectIdOrSlug: string, deliveryId: string) => { + const project = await getProject(projectIdOrSlug); + const plan = await prisma.deliveryPlan.findFirst({ where: { projectId: project.id, OR: [{ id: deliveryId }, { slug: deliveryId }] }, include: planInclude }); + if (!plan) throw new Error("FEATURE_DELIVERY_NOT_FOUND"); + return { project, plan }; +}; +const emit = async (tx: Prisma.TransactionClient, projectId: string, aggregateId: string, eventType: string, payload: unknown) => { + await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId, aggregateType: "delivery", aggregateId, eventType, payloadJson: toJson(payload) } }); +}; + +export const assertFeatureDeliveryPlanComplete = (spec: Pick) => { + if (!spec.problem.trim() || !spec.desiredOutcome.trim() || !spec.goals.length || !spec.acceptanceCriteria.length || !spec.implementationPlan.length || !spec.validationCommands.length) throw new Error("FEATURE_DELIVERY_PLAN_INCOMPLETE"); +}; +export const assertFeatureDeliveryActionEvidence = (evidence: FeatureValidationEvidence | undefined, headSha: string) => { + if (!evidence || evidence.provider !== "gitea_actions" || evidence.conclusion !== "success" || evidence.headSha !== headSha) throw new Error("FEATURE_DELIVERY_CI_EVIDENCE_REQUIRED"); +}; +export const canSyncFeatureDeliveryValidation = (status: FeatureDeliveryStatus): boolean => + ["executing", "validating", "pull_request_open"].includes(status); +export const statusAfterFeatureDeliveryValidationSync = (status: FeatureDeliveryStatus): "validating" | "pull_request_open" => + status === "pull_request_open" ? "pull_request_open" : "validating"; + +export const createFeatureDelivery = async (projectIdOrSlug: string, input: FeatureDeliveryInput, actor = "chatgpt") => { + assertFeatureDeliveryPlanComplete({ problem: input.problem, desiredOutcome: input.desiredOutcome, goals: input.goals, acceptanceCriteria: input.acceptanceCriteria, implementationPlan: input.implementationPlan, validationCommands: input.validationCommands }); + const riskLevel = normalizedRiskLevel(input.riskLevel); + const humanDecisionRequired = requiresHumanDecision(riskLevel); + const initiative = await createInitiativeForProject(projectIdOrSlug, { title: input.title, intent: input.intent, priority: input.priority, sourceType: "chatgpt_feature_delivery" }); + const project = await getProject(projectIdOrSlug); + const initiativeRow = await prisma.initiative.findFirstOrThrow({ where: { projectId: project.id, OR: [{ id: initiative.id }, { slug: initiative.id }] } }); + const now = new Date(); + const plan = await prisma.$transaction(async (tx) => { + const created = await tx.deliveryPlan.create({ data: { + projectId: project.id, initiativeId: initiativeRow.id, slug: publicId(initiativeRow), title: input.title, intent: input.intent, priority: input.priority ?? "normal", + problem: input.problem, desiredOutcome: input.desiredOutcome, usersJson: toJson(input.users ?? []), goalsJson: toJson(input.goals), nonGoalsJson: toJson(input.nonGoals ?? []), + acceptanceCriteriaJson: toJson(input.acceptanceCriteria), implementationPlanJson: toJson(input.implementationPlan), risksJson: toJson(input.risks ?? []), validationCommandsJson: toJson(input.validationCommands), + status: humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved, + approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved, + approvedAt: humanDecisionRequired ? null : now, + approvedBy: humanDecisionRequired ? null : actor, + createdBy: actor, updatedBy: actor, + }, include: planInclude }); + await tx.initiative.update({ where: { id: initiativeRow.id }, data: { + status: humanDecisionRequired ? "proposed" : "planned", + approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved, + updatedBy: actor, + } }); + await emit( + tx, + project.id, + created.id, + humanDecisionRequired ? "delivery.plan.user_decision_required" : "delivery.plan.auto_authorized", + humanDecisionRequired + ? { actor, slug: created.slug, riskLevel, policyDecision: "USER_DECISION_REQUIRED" } + : { actor, slug: created.slug, riskLevel, policyDecision: riskLevel === "low" ? "AUTO_ALLOWED" : "AUTO_ALLOWED_WITH_AUDIT", mode: authorizationMode(riskLevel) }, + ); + return created; + }); + return toDto(project.slug, plan); +}; +export const listFeatureDeliveries = async (projectIdOrSlug: string) => { + const project = await getProject(projectIdOrSlug); + const plans = await prisma.deliveryPlan.findMany({ where: { projectId: project.id }, orderBy: { updatedAt: "desc" }, include: planInclude }); + return plans.map((plan) => toDto(project.slug, plan)); +}; +export const getFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string) => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + return toDto(project.slug, plan); +}; +export const updateFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, patch: Partial, actor = "chatgpt") => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval, DeliveryStatus.approved])) throw new Error("FEATURE_DELIVERY_PLAN_LOCKED"); + if (plan.approvalState === ApprovalState.blocked) throw new Error("FEATURE_DELIVERY_BLOCKED"); + const riskLevel = normalizedRiskLevel(patch.riskLevel ?? (plan.approvalState === ApprovalState.pending ? "high" : "medium")); + const humanDecisionRequired = requiresHumanDecision(riskLevel); + assertFeatureDeliveryTransition(plan.status, humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved); + const now = new Date(); + const updated = await prisma.$transaction(async (tx) => { + const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { + title: patch.title, intent: patch.intent, priority: patch.priority, problem: patch.problem, desiredOutcome: patch.desiredOutcome, + usersJson: patch.users ? toJson(patch.users) : undefined, goalsJson: patch.goals ? toJson(patch.goals) : undefined, nonGoalsJson: patch.nonGoals ? toJson(patch.nonGoals) : undefined, + acceptanceCriteriaJson: patch.acceptanceCriteria ? toJson(patch.acceptanceCriteria) : undefined, implementationPlanJson: patch.implementationPlan ? toJson(patch.implementationPlan) : undefined, + risksJson: patch.risks ? toJson(patch.risks) : undefined, validationCommandsJson: patch.validationCommands ? toJson(patch.validationCommands) : undefined, + status: humanDecisionRequired ? DeliveryStatus.awaiting_approval : DeliveryStatus.approved, + approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved, + approvedAt: humanDecisionRequired ? null : now, + approvedBy: humanDecisionRequired ? null : actor, + reviewFeedback: null, version: { increment: 1 }, updatedBy: actor, + }, include: planInclude }); + if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { + status: humanDecisionRequired ? "proposed" : "planned", + approvalState: humanDecisionRequired ? ApprovalState.pending : ApprovalState.approved, + updatedBy: actor, + } }); + await emit( + tx, + project.id, + plan.id, + humanDecisionRequired ? "delivery.plan.user_decision_required" : "delivery.plan.auto_authorized", + humanDecisionRequired + ? { actor, version: row.version, riskLevel, policyDecision: "USER_DECISION_REQUIRED" } + : { actor, version: row.version, riskLevel, policyDecision: riskLevel === "low" ? "AUTO_ALLOWED" : "AUTO_ALLOWED_WITH_AUDIT", mode: authorizationMode(riskLevel) }, + ); + return row; + }); + return toDto(project.slug, updated); +}; +export const approveFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, actor = "owner") => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + assertFeatureDeliveryPlanComplete({ problem: plan.problem, desiredOutcome: plan.desiredOutcome, goals: strings(plan.goalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson), validationCommands: strings(plan.validationCommandsJson) }); + if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval, DeliveryStatus.approved])) throw new Error("FEATURE_DELIVERY_APPROVAL_LOCKED"); + assertFeatureDeliveryTransition(plan.status, DeliveryStatus.approved); + const updated = await prisma.$transaction(async (tx) => { + const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.approved, approvalState: ApprovalState.approved, approvedAt: new Date(), approvedBy: actor, reviewFeedback: null, updatedBy: actor }, include: planInclude }); + if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { status: "planned", approvalState: ApprovalState.approved, updatedBy: actor } }); + await emit(tx, project.id, plan.id, "delivery.plan.approved", { actor, version: row.version }); + return row; + }); + return toDto(project.slug, updated); +}; + +export const rejectFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, actor = "owner", reason?: string) => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + if (!hasStatus(plan.status, [DeliveryStatus.draft, DeliveryStatus.awaiting_approval])) throw new Error("FEATURE_DELIVERY_REJECTION_LOCKED"); + assertFeatureDeliveryTransition(plan.status, DeliveryStatus.awaiting_approval); + return prisma.$transaction(async (tx) => { + const feedback = reason?.trim() || "Herwerk dit plan voor een nieuwe uitvoeringspoging."; + const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.awaiting_approval, approvalState: ApprovalState.rejected, reviewFeedback: feedback, updatedBy: actor }, include: planInclude }); + if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { approvalState: ApprovalState.rejected, updatedBy: actor } }); + await emit(tx, project.id, plan.id, "delivery.plan.revision_requested", { actor, version: row.version, reason: feedback }); + return toDto(project.slug, row); + }); +}; +export const startFeatureDelivery = async (projectIdOrSlug: string, deliveryId: string, branch: string, actor = "chatgpt", input: { repositoryLinkId?: string; baseCommitSha?: string; headSha?: string } = {}) => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + assertFeatureDeliveryPlanComplete({ problem: plan.problem, desiredOutcome: plan.desiredOutcome, goals: strings(plan.goalsJson), acceptanceCriteria: strings(plan.acceptanceCriteriaJson), implementationPlan: strings(plan.implementationPlanJson), validationCommands: strings(plan.validationCommandsJson) }); + if (plan.approvalState !== ApprovalState.approved || plan.status !== DeliveryStatus.approved) throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED"); + assertFeatureDeliveryTransition(plan.status, DeliveryStatus.executing); + const repository = input.repositoryLinkId + ? await prisma.giteaRepositoryLink.findFirst({ where: { id: input.repositoryLinkId, projectId: project.id } }) + : await prisma.giteaRepositoryLink.findFirst({ where: { projectId: project.id }, orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }] }); + if (!repository) throw new Error("GITEA_LINK_NOT_FOUND"); + const updated = await prisma.$transaction(async (tx) => { + const run = await tx.deliveryRun.create({ data: { deliveryPlanId: plan.id, repositoryLinkId: repository.id, branch, baseBranch: repository.defaultBranch, baseCommitSha: input.baseCommitSha, headSha: input.headSha, status: DeliveryStatus.executing, createdBy: actor, updatedBy: actor } }); + const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.executing, approvalState: ApprovalState.approved, approvedAt: plan.approvedAt ?? new Date(), approvedBy: plan.approvedBy ?? actor, updatedBy: actor }, include: planInclude }); + const investigation = await tx.findingInvestigation.findUnique({ where: { deliveryPlanId: plan.id } }); + if (investigation) { + await tx.findingInvestigation.update({ where: { id: investigation.id }, data: { status: "fixing" } }); + await tx.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: "fixing" } }); + } + if (plan.initiativeId) await tx.initiative.update({ where: { id: plan.initiativeId }, data: { status: "active", approvalState: ApprovalState.approved, updatedBy: actor } }); + await emit(tx, project.id, plan.id, "delivery.run.started", { actor, runId: run.id, repositoryLinkId: repository.id, branch, authorization: "projectbrain_policy" }); + return row; + }); + return toDto(project.slug, updated); +}; +export const recordFeatureDeliveryProgress = async (projectIdOrSlug: string, deliveryId: string, input: { changedFiles?: string[]; changes?: DeliveryChangeInput[]; note?: string; validationResults?: FeatureValidationResult[]; readyForReview?: boolean; headSha?: string }, actor = "chatgpt") => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + const run = plan.runs[0]; + if (!run || plan.approvalState !== ApprovalState.approved || !hasStatus(plan.status, [DeliveryStatus.executing, DeliveryStatus.validating, DeliveryStatus.pull_request_open])) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN"); + const changes: DeliveryChangeInput[] = input.changes ?? (input.changedFiles ?? []).map((path) => ({ path, operation: "update" })); + const updated = await prisma.$transaction(async (tx) => { + for (const change of changes) await tx.deliveryFileChange.create({ data: { deliveryRunId: run.id, path: change.path, previousPath: change.previousPath, operation: change.operation as DeliveryChangeOperation, beforeSha: change.beforeSha, afterSha: change.afterSha, commitSha: change.commitSha, actor } }); + if (changes.length || (input.headSha && input.headSha !== run.headSha)) await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id } }); + if (input.validationResults) { + await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id, provider: null } }); + for (const result of input.validationResults) await tx.deliveryValidation.create({ data: { deliveryRunId: run.id, command: result.command, status: result.status === "passed" ? DeliveryValidationStatus.passed : result.status === "failed" ? DeliveryValidationStatus.failed : DeliveryValidationStatus.skipped, summary: result.summary } }); + } + const status = plan.status === DeliveryStatus.pull_request_open ? plan.status : input.readyForReview ? DeliveryStatus.validating : plan.status; + assertFeatureDeliveryTransition(plan.status, status); + await tx.deliveryRun.update({ where: { id: run.id }, data: { status, headSha: input.headSha, updatedBy: actor } }); + const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status, updatedBy: actor }, include: planInclude }); + await emit(tx, project.id, plan.id, "delivery.progress.recorded", { actor, runId: run.id, note: input.note, changeCount: changes.length, status }); + return row; + }); + return toDto(project.slug, updated); +}; +export const recordFeatureDeliveryActionValidation = async (projectIdOrSlug: string, deliveryId: string, evidence: FeatureValidationEvidence, actor = "chatgpt") => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + const run = plan.runs[0]; + if (!run || plan.approvalState !== ApprovalState.approved || !canSyncFeatureDeliveryValidation(plan.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN"); + const nextStatus = statusAfterFeatureDeliveryValidationSync(plan.status) === "pull_request_open" + ? DeliveryStatus.pull_request_open + : DeliveryStatus.validating; + assertFeatureDeliveryTransition(plan.status, nextStatus); + const command = actionValidationCommand(evidence.workflowPath); + const updated = await prisma.$transaction(async (tx) => { + await tx.deliveryValidation.deleteMany({ where: { deliveryRunId: run.id, provider: "gitea_actions" } }); + await tx.deliveryValidation.create({ data: { deliveryRunId: run.id, command, status: DeliveryValidationStatus.passed, provider: "gitea_actions", externalRunId: String(evidence.runId), externalUrl: evidence.url, checkName: `run-${evidence.runNumber}`, headSha: evidence.headSha, summary: evidence.workflowPath, verifiedAt: new Date(evidence.verifiedAt) } }); + await tx.deliveryRun.update({ where: { id: run.id }, data: { status: nextStatus, headSha: evidence.headSha, updatedBy: actor } }); + const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: nextStatus, updatedBy: actor }, include: planInclude }); + await emit(tx, project.id, plan.id, "delivery.validation.passed", { actor, runId: run.id, headSha: evidence.headSha, externalRunId: evidence.runId }); + return row; + }); + return toDto(project.slug, updated); +}; +export const openFeatureDeliveryPullRequest = async (projectIdOrSlug: string, deliveryId: string, pullRequest: { number: number; url: string; title: string }, actor = "chatgpt") => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + const run = plan.runs[0]; + if (!run || plan.approvalState !== ApprovalState.approved || plan.status !== DeliveryStatus.validating) throw new Error("FEATURE_DELIVERY_COMPLETION_FORBIDDEN"); + assertFeatureDeliveryTransition(plan.status, DeliveryStatus.pull_request_open); + const updated = await prisma.$transaction(async (tx) => { + await tx.deliveryRun.update({ where: { id: run.id }, data: { status: DeliveryStatus.pull_request_open, pullRequestNumber: pullRequest.number, pullRequestUrl: pullRequest.url, pullRequestTitle: pullRequest.title, pullRequestState: "open", pullRequestOpenedAt: new Date(), updatedBy: actor } }); + const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.pull_request_open, updatedBy: actor }, include: planInclude }); + await emit(tx, project.id, plan.id, "delivery.pull_request.opened", { actor, runId: run.id, pullRequest }); + return row; + }); + return toDto(project.slug, updated); +}; +/** Compatibility export: opening a PR no longer marks the delivery completed. */ +export const completeFeatureDelivery = openFeatureDeliveryPullRequest; +export const reconcileFeatureDeliveryPullRequest = async (input: { owner: string; repository: string; number: number; state: "open" | "closed"; merged: boolean; mergeCommitSha?: string; headSha?: string; mergedAt?: string; actor?: string }) => { + const run = await prisma.deliveryRun.findFirst({ where: { pullRequestNumber: input.number, repositoryLink: { owner: input.owner, repository: input.repository } }, include: { deliveryPlan: true } }); + if (!run) return undefined; + const actor = input.actor ?? "gitea-webhook"; + const status = input.merged ? DeliveryStatus.merged : input.state === "closed" ? DeliveryStatus.failed : DeliveryStatus.pull_request_open; + assertFeatureDeliveryTransition(run.deliveryPlan.status, status); + await prisma.$transaction(async (tx) => { + await tx.deliveryRun.update({ where: { id: run.id }, data: { status, pullRequestState: input.merged ? "merged" : input.state, mergeCommitSha: input.mergeCommitSha, headSha: input.headSha, mergedAt: input.merged ? new Date(input.mergedAt ?? Date.now()) : undefined, completedAt: input.merged ? new Date(input.mergedAt ?? Date.now()) : input.state === "closed" ? new Date() : undefined, updatedBy: actor } }); + await tx.deliveryPlan.update({ where: { id: run.deliveryPlanId }, data: { status, updatedBy: actor } }); + if (run.deliveryPlan.initiativeId) await tx.initiative.update({ where: { id: run.deliveryPlan.initiativeId }, data: { status: input.merged ? "complete" : input.state === "closed" ? "paused" : "active", updatedBy: actor } }); + await emit(tx, run.deliveryPlan.projectId, run.deliveryPlanId, input.merged ? "delivery.pull_request.merged" : input.state === "closed" ? "delivery.pull_request.closed" : "delivery.pull_request.updated", { actor, runId: run.id, ...input }); + }); + return getFeatureDelivery(run.deliveryPlan.projectId, run.deliveryPlanId); +}; +export const markFeatureDeliveryVerified = async (projectIdOrSlug: string, deliveryId: string, actor = "system") => { + const { project, plan } = await getDeliveryRow(projectIdOrSlug, deliveryId); + const run = plan.runs[0]; + if (!run || plan.status !== DeliveryStatus.merged) throw new Error("FEATURE_DELIVERY_NOT_MERGED"); + assertFeatureDeliveryTransition(plan.status, DeliveryStatus.verified); + const updated = await prisma.$transaction(async (tx) => { + await tx.deliveryRun.update({ where: { id: run.id }, data: { status: DeliveryStatus.verified, verifiedAt: new Date(), completedAt: new Date(), updatedBy: actor } }); + const row = await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: DeliveryStatus.verified, updatedBy: actor }, include: planInclude }); + await emit(tx, project.id, plan.id, "delivery.verified", { actor, runId: run.id, mergeCommitSha: run.mergeCommitSha }); + return row; + }); + return toDto(project.slug, updated); +}; diff --git a/backend/src/repositories/featureGroupsRepository.ts b/backend/src/repositories/featureGroupsRepository.ts new file mode 100644 index 0000000..6b46623 --- /dev/null +++ b/backend/src/repositories/featureGroupsRepository.ts @@ -0,0 +1,43 @@ +import { prisma } from "../db/prisma.js"; +import type { FeatureGroupDto, FocusAreaPurpose } from "../types/api.js"; + +const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) => + value.slug ?? value.readableId ?? value.id; + +const findProjectRecordBySlugOrId = (projectIdOrSlug: string) => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + }); + +export const listFeatureGroupsForProject = async ( + projectIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + const groups = await prisma.featureGroup.findMany({ + where: { + projectId: project.id, + deletedAt: null, + }, + orderBy: [{ name: "asc" }], + include: { + features: { + where: { deletedAt: null }, + orderBy: { name: "asc" }, + }, + }, + }); + + return groups.map((group) => ({ + id: toPublicId(group), + projectId: project.slug, + name: group.name, + description: group.description ?? undefined, + focusArea: group.focusArea as FocusAreaPurpose | undefined, + featureIds: group.features.map(toPublicId), + })); +}; diff --git a/backend/src/repositories/featuresRepository.ts b/backend/src/repositories/featuresRepository.ts new file mode 100644 index 0000000..0122002 --- /dev/null +++ b/backend/src/repositories/featuresRepository.ts @@ -0,0 +1,138 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { + CodeEvidenceDto, + EvidenceStrength, + FeatureDto, + FeatureStatus, +} from "../types/api.js"; + +const toPublicId = (value: { slug: string | null; readableId: string | null; id: string }) => + value.slug ?? value.readableId ?? value.id; + +type FeatureMetadata = { + bugCount?: number; + taskCount?: number; + decisionCount?: number; + codeEvidence?: CodeEvidenceDto[]; + aiSuggestionCount?: number; + evidenceIds?: string[]; + initiativeId?: string; +}; + +type DbFeatureRecord = { + id: string; + projectId: string; + featureGroupId: string; + slug: string | null; + readableId: string | null; + name: string; + description: string; + status: string; + lifecycle: FeatureDto["lifecycle"]; + intent: string | null; + confidence: number; + health: number; + evidenceStrength: string | null; + trustJson: Prisma.JsonValue | null; + featureGroup?: { + id: string; + slug: string | null; + readableId: string | null; + } | null; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const toFeatureMetadata = (value: Prisma.JsonValue | null): FeatureMetadata => { + if (!isRecord(value)) return {}; + return value as FeatureMetadata; +}; + +const findProjectRecordBySlugOrId = (projectIdOrSlug: string) => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + }); + +const mapFeatureStatus = (status: string): FeatureStatus => { + if (status === "done") return "Done"; + if (status === "in_progress") return "In Progress"; + return "Todo"; +}; + +const toFeatureDto = (feature: DbFeatureRecord, projectSlug: string): FeatureDto => { + const metadata = toFeatureMetadata(feature.trustJson); + + return { + id: toPublicId(feature), + projectId: projectSlug, + groupId: feature.featureGroup ? toPublicId(feature.featureGroup) : feature.featureGroupId, + name: feature.name, + description: feature.description, + status: mapFeatureStatus(feature.status), + lifecycle: feature.lifecycle, + intent: feature.intent ?? undefined, + initiativeId: metadata.initiativeId, + evidenceStrength: feature.evidenceStrength as EvidenceStrength | undefined, + confidence: feature.confidence, + health: feature.health, + bugCount: metadata.bugCount ?? 0, + taskCount: metadata.taskCount ?? 0, + decisionCount: metadata.decisionCount ?? 0, + codeEvidence: metadata.codeEvidence ?? [], + aiSuggestionCount: metadata.aiSuggestionCount ?? 0, + evidenceIds: metadata.evidenceIds ?? [], + }; +}; + +export const listFeaturesForProject = async ( + projectIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + const features = await prisma.feature.findMany({ + where: { + projectId: project.id, + deletedAt: null, + }, + orderBy: [{ name: "asc" }], + include: { + featureGroup: true, + }, + }); + + return features.map((feature) => toFeatureDto(feature, project.slug)); +}; + +export const getFeatureBySlugOrId = async ( + projectIdOrSlug: string, + featureIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + const feature = await prisma.feature.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: featureIdOrSlug }, { slug: featureIdOrSlug }, { readableId: featureIdOrSlug }], + }, + include: { + featureGroup: true, + }, + }); + + if (!feature) return undefined; + + return toFeatureDto(feature, project.slug); +}; + +export const projectExists = async (projectIdOrSlug: string): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + return Boolean(project); +}; diff --git a/backend/src/repositories/findingRepository.ts b/backend/src/repositories/findingRepository.ts new file mode 100644 index 0000000..63a4fd6 --- /dev/null +++ b/backend/src/repositories/findingRepository.ts @@ -0,0 +1,455 @@ +import { createHash, randomUUID } from "node:crypto"; +import { FindingSeverity, FindingStatus, InvestigationStatus, IssueSyncMode, type Prisma } from "@prisma/client"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { getGiteaIssueClient } from "../integrations/gitea/service.js"; +import type { GiteaIssue, GiteaLabel } from "../integrations/gitea/types.js"; +import { createFeatureDelivery, markFeatureDeliveryVerified, type FeatureDeliveryInput } from "./featureDeliveryRepository.js"; +import { createPortfolioAnalysisJob } from "./analysisJobRepository.js"; + +export type FindingEvidence = Record; +export type DetectedFinding = { severity: "info" | "warning" | "critical"; code: string; message: string; subject?: string; evidence?: FindingEvidence }; +type FindingScope = { + jobId: string; + analysisJobItemId: string; + projectId?: string; + repositoryLinkId?: string; + owner: string; + repository: string; + defaultBranch: string; + findings: DetectedFinding[]; + evaluatedRuleCodes?: string[]; +}; + +const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const severityRank: Record = { info: 1, warning: 2, critical: 3 }; +const baseRiskBySeverity: Record = { info: 20, warning: 60, critical: 90 }; +const terminalInvestigationStatuses: InvestigationStatus[] = [InvestigationStatus.resolved, InvestigationStatus.failed, InvestigationStatus.cancelled]; + +const projectByIdOrSlug = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + return project; +}; + +const subjectFor = (finding: DetectedFinding, defaultBranch: string) => finding.subject ?? (finding.code === "DEFAULT_BRANCH_UNPROTECTED" ? defaultBranch : "repository"); +export const repositoryFindingFingerprint = (owner: string, repository: string, code: string, subject: string) => createHash("sha256").update(`${owner.toLowerCase()}/${repository.toLowerCase()}:${code}:${subject}`).digest("hex"); +const titleFor = (finding: DetectedFinding, owner: string, repository: string) => `[ProjectBrain] ${finding.code.replaceAll("_", " ").toLowerCase()} in ${owner}/${repository}`; + +const evidenceNumber = (finding: DetectedFinding, key: string) => { + const value = finding.evidence?.[key]; + return typeof value === "number" ? value : 0; +}; +const evidenceString = (finding: DetectedFinding, key: string) => { + const value = finding.evidence?.[key]; + return typeof value === "string" ? value : undefined; +}; +export const findingRiskScore = (finding: DetectedFinding) => { + const scopeBonus = evidenceString(finding, "scope") === "default_branch" ? 8 : evidenceString(finding, "scope") === "scheduled" ? 4 : 0; + const persistenceBonus = Math.min(4, Math.max(0, evidenceNumber(finding, "consecutiveFailures") - 1)); + const ruleBonus = finding.code === "DEFAULT_BRANCH_ACTION_FAILED" ? 2 : finding.code === "DEFAULT_BRANCH_IS_AGENT_BRANCH" ? 3 : 0; + return Math.min(100, baseRiskBySeverity[finding.severity as FindingSeverity] + scopeBonus + persistenceBonus + ruleBonus); +}; + +const legacyMigrationCandidate = (finding: DetectedFinding) => finding.severity === "critical" && new Set(["DEFAULT_BRANCH_ACTION_FAILED", "SCHEDULED_ACTION_FAILED"]).has(finding.code); + +export const reconcileRepositoryFindings = async (scope: FindingScope) => { + const now = new Date(); + const observedFingerprints = scope.findings.map((finding) => repositoryFindingFingerprint(scope.owner, scope.repository, finding.code, subjectFor(finding, scope.defaultBranch))); + return prisma.$transaction(async (tx) => { + const reconciled = []; + for (let index = 0; index < scope.findings.length; index += 1) { + const detected = scope.findings[index]; + const severity = detected.severity as FindingSeverity; + const subject = subjectFor(detected, scope.defaultBranch); + const fingerprint = observedFingerprints[index]; + let existing = await tx.repositoryFinding.findUnique({ where: { fingerprint } }); + if (!existing && legacyMigrationCandidate(detected)) { + const legacy = await tx.repositoryFinding.findFirst({ + where: { + owner: { equals: scope.owner, mode: "insensitive" }, + repository: { equals: scope.repository, mode: "insensitive" }, + ruleCode: "LATEST_ACTION_FAILED", + status: { notIn: [FindingStatus.resolved, FindingStatus.suppressed] }, + }, + orderBy: { lastSeenAt: "desc" }, + }); + if (legacy) { + existing = await tx.repositoryFinding.update({ + where: { id: legacy.id }, + data: { + fingerprint, + ruleCode: detected.code, + subject, + status: legacy.status === FindingStatus.issue_open ? FindingStatus.triaged : legacy.status, + }, + }); + } + } + const suppressionExpired = existing?.status === FindingStatus.suppressed && existing.suppressedUntil && existing.suppressedUntil <= now; + const nextStatus = existing?.status === FindingStatus.resolved || suppressionExpired ? FindingStatus.regressed : existing?.status ?? FindingStatus.new; + const finding = existing + ? await tx.repositoryFinding.update({ where: { id: existing.id }, data: { + projectId: scope.projectId || existing.projectId, repositoryLinkId: scope.repositoryLinkId || existing.repositoryLinkId, + owner: scope.owner, repository: scope.repository, + title: titleFor(detected, scope.owner, scope.repository), message: detected.message, severity, riskScore: findingRiskScore(detected), + status: nextStatus, occurrenceCount: existing.lastAnalysisJobId === scope.jobId ? existing.occurrenceCount : { increment: 1 }, clearCount: 0, lastSeenAt: now, lastAnalysisJobId: scope.jobId, + resolvedAt: null, ...(suppressionExpired ? { suppressedAt: null, suppressedUntil: null, suppressionReason: null } : {}), + } }) + : await tx.repositoryFinding.create({ data: { + fingerprint, projectId: scope.projectId || undefined, repositoryLinkId: scope.repositoryLinkId, + owner: scope.owner, repository: scope.repository, ruleCode: detected.code, subject, + title: titleFor(detected, scope.owner, scope.repository), message: detected.message, severity, + confidence: 95, riskScore: findingRiskScore(detected), status: FindingStatus.new, firstSeenAt: now, lastSeenAt: now, lastAnalysisJobId: scope.jobId, + } }); + await tx.findingOccurrence.upsert({ + where: { findingId_analysisJobItemId: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId } }, + create: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId, observed: true, detailJson: toJson(detected) }, + update: { observed: true, detailJson: toJson(detected) }, + }); + reconciled.push(finding); + } + + const missing = await tx.repositoryFinding.findMany({ + where: { + owner: { equals: scope.owner, mode: "insensitive" }, + repository: { equals: scope.repository, mode: "insensitive" }, + fingerprint: { notIn: observedFingerprints }, + status: { notIn: [FindingStatus.resolved, FindingStatus.suppressed] }, + }, + }); + for (const finding of missing) { + if (!scope.evaluatedRuleCodes?.includes(finding.ruleCode)) continue; + if (finding.lastAnalysisJobId === scope.jobId) continue; + const clearCount = finding.clearCount + 1; + await tx.repositoryFinding.update({ where: { id: finding.id }, data: { + clearCount, + ...(clearCount >= 2 ? { status: FindingStatus.resolved, resolvedAt: now } : {}), + lastAnalysisJobId: scope.jobId, + } }); + await tx.findingOccurrence.upsert({ + where: { findingId_analysisJobItemId: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId } }, + create: { findingId: finding.id, analysisJobItemId: scope.analysisJobItemId, observed: false }, + update: { observed: false }, + }); + } + return reconciled; + }); +}; + +const findingInclude = { + project: { select: { id: true, slug: true, name: true } }, + repositoryLink: true, + issueLink: true, + investigations: { orderBy: { createdAt: "desc" as const }, take: 3, include: { deliveryPlan: { select: { id: true, slug: true, title: true, status: true, approvalState: true } } } }, + occurrences: { where: { observed: true }, orderBy: [{ createdAt: "desc" as const }, { id: "desc" as const }], take: 1, select: { observed: true, detailJson: true, createdAt: true } }, +} satisfies Prisma.RepositoryFindingInclude; + +export const listRepositoryFindings = async (input: { projectId?: string; statuses?: FindingStatus[]; severities?: FindingSeverity[]; limit?: number; cursor?: string }) => { + const project = input.projectId ? await projectByIdOrSlug(input.projectId) : undefined; + const cursor = Number.parseInt(input.cursor ?? "0", 10); + if (!Number.isInteger(cursor) || cursor < 0) throw new Error("FINDING_CURSOR_INVALID"); + const limit = Math.min(Math.max(input.limit ?? 20, 1), 50); + const rows = await prisma.repositoryFinding.findMany({ + where: { ...(project ? { projectId: project.id } : {}), ...(input.statuses?.length ? { status: { in: input.statuses } } : {}), ...(input.severities?.length ? { severity: { in: input.severities } } : {}) }, + orderBy: [{ riskScore: "desc" }, { lastSeenAt: "desc" }, { id: "asc" }], skip: cursor, take: limit + 1, include: findingInclude, + }); + return { items: rows.slice(0, limit), nextCursor: rows.length > limit ? String(cursor + limit) : undefined }; +}; + +export const getRepositoryFinding = async (findingId: string) => { + const finding = await prisma.repositoryFinding.findUnique({ where: { id: findingId }, include: { + ...findingInclude, + occurrences: { orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: 20, include: { analysisJobItem: { select: { jobId: true, status: true, summary: true, createdAt: true } } } }, + } }); + if (!finding) throw new Error("FINDING_NOT_FOUND"); + return finding; +}; + +export const suppressRepositoryFinding = async (findingId: string, reason: string, until?: Date) => { + const finding = await prisma.repositoryFinding.update({ where: { id: findingId }, data: { status: FindingStatus.suppressed, suppressionReason: reason, suppressedAt: new Date(), suppressedUntil: until } }); + return finding; +}; + +export const getFindingAutomationPolicy = async (projectIdOrSlug: string) => { + const project = await projectByIdOrSlug(projectIdOrSlug); + return prisma.findingAutomationPolicy.upsert({ where: { projectId: project.id }, create: { projectId: project.id }, update: {} }); +}; + +export const updateFindingAutomationPolicy = async (projectIdOrSlug: string, input: { + enabled?: boolean; issueSyncMode?: IssueSyncMode; minimumSeverity?: FindingSeverity; minimumOccurrences?: number; maxIssuesPerRun?: number; + autoCloseResolved?: boolean; autoReopenRegressed?: boolean; scheduledAnalysis?: boolean; scheduleMinutes?: number; updatedBy: string; +}) => { + const project = await projectByIdOrSlug(projectIdOrSlug); + return prisma.findingAutomationPolicy.upsert({ + where: { projectId: project.id }, + create: { projectId: project.id, ...input }, + update: input, + }); +}; + +export const findingIssueMarker = (finding: { id: string; fingerprint: string }) => ``; +const latestObservedDetection = (finding: Awaited>): DetectedFinding | undefined => { + const detail = finding.occurrences.find((occurrence) => occurrence.observed)?.detailJson; + return detail && typeof detail === "object" && !Array.isArray(detail) ? detail as DetectedFinding : undefined; +}; + +export const renderFindingEvidence = (evidence?: FindingEvidence) => { + if (!evidence) return ""; + const entries = [ + ["Workflow", evidence.workflow], + ["Branch", evidence.branch], + ["Event", evidence.event], + ["Conclusie", evidence.conclusion], + ["Opeenvolgende failures", evidence.consecutiveFailures], + ["Voltooid", evidence.completedAt], + ].filter((entry): entry is [string, string | number] => typeof entry[1] === "string" || typeof entry[1] === "number"); + const runUrl = typeof evidence.runUrl === "string" ? evidence.runUrl : undefined; + if (runUrl) entries.unshift(["Gitea Actions-run", `[open run](${runUrl})`]); + if (!entries.length) return ""; + return `\n## Bewijs\n\n${entries.map(([label, value]) => `- **${label}:** ${value}`).join("\n")}\n`; +}; + +const issueBodyFor = (finding: Awaited>) => `${findingIssueMarker(finding)} + +## ProjectBrain-bevinding + +${finding.message} + +- **Repository:** \`${finding.owner}/${finding.repository}\` +- **Regel:** \`${finding.ruleCode}\` +- **Ernst:** ${finding.severity} +- **Vertrouwen:** ${finding.confidence}% +- **Eerste detectie:** ${finding.firstSeenAt.toISOString()} +- **Laatste detectie:** ${finding.lastSeenAt.toISOString()} +- **Waarnemingen:** ${finding.occurrenceCount} +${renderFindingEvidence(latestObservedDetection(finding)?.evidence)} + +Dit issue wordt idempotent beheerd door ProjectBrain. Onderzoek en codewijzigingen blijven onder de ProjectBrain-deliverygoedkeuring vallen.`; + +const labelDefinitions = (finding: { severity: FindingSeverity; ruleCode: string }) => [ + { name: "projectbrain", color: "6d28d9", description: "Automatisch beheerd door ProjectBrain" }, + { name: `severity:${finding.severity}`, color: finding.severity === FindingSeverity.critical ? "dc2626" : finding.severity === FindingSeverity.warning ? "f59e0b" : "2563eb", description: "ProjectBrain finding severity" }, + { name: `finding:${finding.ruleCode.toLowerCase().replaceAll("_", "-")}`, color: "475569", description: `ProjectBrain rule ${finding.ruleCode}` }, +]; + +const ensureLabels = async (env: AppEnv, owner: string, repository: string, finding: { severity: FindingSeverity; ruleCode: string }): Promise => { + const client = getGiteaIssueClient(env); + const existing = await client.listLabels(owner, repository); + const labels: GiteaLabel[] = []; + for (const definition of labelDefinitions(finding)) { + const label = existing.find((candidate) => candidate.name === definition.name) ?? await client.createLabel(owner, repository, definition); + labels.push(label); + } + return labels; +}; + +const persistIssueLink = async (finding: Awaited>, issue: GiteaIssue, body: string) => prisma.$transaction(async (tx) => { + const bodyFingerprint = createHash("sha256").update(body).digest("hex"); + const link = await tx.giteaIssueLink.upsert({ + where: { findingId: finding.id }, + create: { findingId: finding.id, owner: finding.owner, repository: finding.repository, issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state, title: issue.title, bodyFingerprint, lastSyncedAt: new Date(), closedAt: issue.state === "closed" ? new Date(issue.closed_at ?? Date.now()) : undefined }, + update: { issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state, title: issue.title, bodyFingerprint, lastSyncedAt: new Date(), closedAt: issue.state === "closed" ? new Date(issue.closed_at ?? Date.now()) : null }, + }); + if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "finding", aggregateId: finding.id, eventType: "finding.issue.synced", payloadJson: toJson({ issueNumber: issue.number, issueUrl: issue.html_url, state: issue.state }) } }); + await tx.repositoryFinding.update({ where: { id: finding.id }, data: { status: issue.state === "open" ? FindingStatus.issue_open : finding.status } }); + return link; +}); + +export const syncFindingIssue = async (env: AppEnv, findingId: string, actor = "system") => { + const finding = await getRepositoryFinding(findingId); + if (!finding.repositoryLink || finding.repositoryLink.accessMode !== "read_write") throw new Error("FINDING_REPOSITORY_WRITE_LINK_REQUIRED"); + if (finding.status === FindingStatus.resolved || finding.status === FindingStatus.suppressed) throw new Error("FINDING_ISSUE_CREATION_FORBIDDEN"); + const client = getGiteaIssueClient(env); + const body = issueBodyFor(finding); + const marker = findingIssueMarker(finding); + const labels = await ensureLabels(env, finding.owner, finding.repository, finding); + let issue: GiteaIssue | undefined; + if (finding.issueLink) { + issue = await client.getIssue(finding.owner, finding.repository, finding.issueLink.issueNumber); + } else { + issue = (await client.listIssues(finding.owner, finding.repository, "all", 1, 100)).find((candidate) => candidate.body?.includes(marker)); + } + if (!issue) issue = await client.createIssue(finding.owner, finding.repository, { title: finding.title, body, labels: labels.map((label) => label.id) }); + else { + issue = await client.editIssue(finding.owner, finding.repository, issue.number, { title: finding.title, body, ...(issue.state === "closed" ? { state: "open" as const } : {}) }); + await client.replaceIssueLabels(finding.owner, finding.repository, issue.number, labels.map((label) => label.id)); + } + const link = await persistIssueLink(finding, issue, body); + return { finding: await getRepositoryFinding(finding.id), issue, link, actor }; +}; + +export const closeResolvedFindingIssue = async (env: AppEnv, findingId: string, summary: string) => { + const finding = await getRepositoryFinding(findingId); + if (!finding.issueLink || finding.issueLink.state === "closed") return finding; + const client = getGiteaIssueClient(env); + await client.createIssueComment(finding.owner, finding.repository, finding.issueLink.issueNumber, `ProjectBrain-verificatie: ${summary}`); + const issue = await client.editIssue(finding.owner, finding.repository, finding.issueLink.issueNumber, { state: "closed" }); + await persistIssueLink(finding, issue, issueBodyFor(finding)); + return getRepositoryFinding(finding.id); +}; + +export const closeAutomaticallyResolvedFindingIssues = async (env: AppEnv) => { + const findings = await prisma.repositoryFinding.findMany({ + where: { status: FindingStatus.resolved, projectId: { not: null }, issueLink: { state: "open" } }, + include: { project: { include: { findingAutomationPolicy: true } }, issueLink: true }, + orderBy: [{ resolvedAt: "asc" }, { id: "asc" }], + take: 25, + }); + const closed: string[] = []; + for (const finding of findings) { + const policy = finding.project?.findingAutomationPolicy ?? (finding.projectId ? await getFindingAutomationPolicy(finding.projectId) : undefined); + if (!policy?.autoCloseResolved) continue; + try { + await closeResolvedFindingIssue(env, finding.id, "Twee opeenvolgende analyses vonden de bevinding niet meer."); + closed.push(finding.id); + } catch (error) { + console.warn(JSON.stringify({ event: "finding_issue_close_failed", findingId: finding.id, code: error instanceof Error ? error.message : "FINDING_ISSUE_CLOSE_FAILED" })); + } + } + return closed; +}; + +export const reconcileFindingIssueWebhook = async (input: { owner: string; repository: string; issueNumber: number; state: string; title: string; url: string; actor: string }) => { + const link = await prisma.giteaIssueLink.findUnique({ where: { owner_repository_issueNumber: { owner: input.owner, repository: input.repository, issueNumber: input.issueNumber } }, include: { finding: true } }); + if (!link) return undefined; + return prisma.$transaction(async (tx) => { + await tx.giteaIssueLink.update({ where: { id: link.id }, data: { state: input.state, title: input.title, issueUrl: input.url, lastSyncedAt: new Date(), closedAt: input.state === "closed" ? new Date() : null } }); + const wasResolved = link.finding.status === FindingStatus.resolved || Boolean(link.finding.resolvedAt); + const status = input.state === "open" ? FindingStatus.issue_open : wasResolved ? FindingStatus.resolved : FindingStatus.suppressed; + const finding = await tx.repositoryFinding.update({ where: { id: link.findingId }, data: { + status, + ...(status === FindingStatus.resolved ? { suppressedAt: null, suppressedUntil: null, suppressionReason: null } : {}), + ...(status === FindingStatus.suppressed ? { suppressedAt: new Date(), suppressionReason: `Gitea issue handmatig gesloten door ${input.actor}` } : {}), + } }); + if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "finding", aggregateId: finding.id, eventType: "finding.issue.webhook", payloadJson: toJson(input) } }); + return finding; + }); +}; + +export const startFindingInvestigation = async (env: AppEnv, findingId: string, actor: string) => { + const finding = await getRepositoryFinding(findingId); + const active = finding.investigations.find((investigation) => !terminalInvestigationStatuses.includes(investigation.status)); + if (active) return active; + const client = getGiteaIssueClient(env); + const [repository, branches, pullRequests, actionRuns, issue, comments, root] = await Promise.all([ + client.getRepository(finding.owner, finding.repository), + client.listBranches(finding.owner, finding.repository), + client.listPullRequests(finding.owner, finding.repository, "open"), + client.listActionRuns(finding.owner, finding.repository, { limit: 10 }), + finding.issueLink ? client.getIssue(finding.owner, finding.repository, finding.issueLink.issueNumber) : Promise.resolve(undefined), + finding.issueLink ? client.listIssueComments(finding.owner, finding.repository, finding.issueLink.issueNumber) : Promise.resolve([]), + client.getContents(finding.owner, finding.repository, "", finding.repositoryLink?.defaultBranch ?? repositoryDefault(finding)).catch(() => []), + ]); + const rootEntries = (Array.isArray(root) ? root : [root]).slice(0, 50).map(({ content: _content, ...entry }) => entry); + const context = { + finding: { id: finding.id, title: finding.title, message: finding.message, ruleCode: finding.ruleCode, severity: finding.severity, confidence: finding.confidence, occurrenceCount: finding.occurrenceCount, firstSeenAt: finding.firstSeenAt, lastSeenAt: finding.lastSeenAt }, + repository: { fullName: `${finding.owner}/${finding.repository}`, defaultBranch: repository.default_branch, archived: repository.archived, private: repository.private }, + branches: branches.slice(0, 30), pullRequests: pullRequests.slice(0, 10), actionRuns: actionRuns.workflow_runs.slice(0, 10), rootEntries, + issue: issue ? { number: issue.number, title: issue.title, body: issue.body.slice(0, 10_000), state: issue.state, url: issue.html_url, comments: comments.slice(-10).map((comment) => ({ body: comment.body.slice(0, 2_000), author: comment.user?.login, createdAt: comment.created_at })) } : undefined, + }; + const investigation = await prisma.$transaction(async (tx) => { + const created = await tx.findingInvestigation.create({ data: { findingId: finding.id, projectId: finding.projectId, actor, status: InvestigationStatus.ready, contextJson: toJson(context) } }); + await tx.repositoryFinding.update({ where: { id: finding.id }, data: { status: FindingStatus.investigating } }); + if (finding.projectId) await tx.outboxEvent.create({ data: { eventId: randomUUID(), projectId: finding.projectId, aggregateType: "investigation", aggregateId: created.id, eventType: "finding.investigation.ready", payloadJson: toJson({ findingId: finding.id, actor }) } }); + return created; + }); + return investigation; +}; + +const repositoryDefault = (finding: Awaited>) => finding.repositoryLink?.defaultBranch ?? "main"; + +export const getFindingInvestigation = async (investigationId: string) => { + const investigation = await prisma.findingInvestigation.findUnique({ where: { id: investigationId }, include: { finding: { include: { issueLink: true, repositoryLink: true } }, deliveryPlan: true } }); + if (!investigation) throw new Error("FINDING_INVESTIGATION_NOT_FOUND"); + return investigation; +}; + +export const saveFindingInvestigationConclusion = async (investigationId: string, input: { diagnosis: string; rootCause: string; proposedChanges: string[]; confidence: number; conclusion: string }) => { + const investigation = await getFindingInvestigation(investigationId); + if (terminalInvestigationStatuses.includes(investigation.status)) throw new Error("FINDING_INVESTIGATION_LOCKED"); + return prisma.$transaction(async (tx) => { + const updated = await tx.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.concluded, diagnosis: input.diagnosis, rootCause: input.rootCause, proposedChangesJson: toJson(input.proposedChanges), confidence: input.confidence, conclusion: input.conclusion, concludedAt: new Date() } }); + await tx.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.triaged } }); + return updated; + }); +}; + +export const createDeliveryFromFindingInvestigation = async (investigationId: string, input: FeatureDeliveryInput, actor: string) => { + const investigation = await getFindingInvestigation(investigationId); + if (!investigation.projectId) throw new Error("FINDING_PROJECT_LINK_REQUIRED"); + if (investigation.deliveryPlanId) throw new Error("FINDING_DELIVERY_ALREADY_EXISTS"); + if (investigation.status !== InvestigationStatus.concluded) throw new Error("FINDING_INVESTIGATION_CONCLUSION_REQUIRED"); + const delivery = await createFeatureDelivery(investigation.projectId, input, actor); + const plan = await prisma.deliveryPlan.findFirstOrThrow({ where: { projectId: investigation.projectId, OR: [{ id: delivery.id }, { slug: delivery.id }] } }); + await prisma.$transaction([ + prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { deliveryPlanId: plan.id, status: InvestigationStatus.planned } }), + prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.planned } }), + ]); + return { delivery, investigation: await getFindingInvestigation(investigation.id) }; +}; + +export const queueFindingVerificationForDelivery = async (env: AppEnv, projectIdOrSlug: string, deliveryId: string) => { + const project = await projectByIdOrSlug(projectIdOrSlug); + const plan = await prisma.deliveryPlan.findFirst({ where: { projectId: project.id, OR: [{ id: deliveryId }, { slug: deliveryId }] }, include: { findingInvestigation: { include: { finding: true } } } }); + const investigation = plan?.findingInvestigation; + if (!plan || !investigation?.finding.repositoryLinkId) return undefined; + if (new Set([InvestigationStatus.verifying, InvestigationStatus.resolved, InvestigationStatus.failed, InvestigationStatus.cancelled]).has(investigation.status)) return undefined; + const result = await createPortfolioAnalysisJob(env, `system:verification:${plan.id}`, { repositoryIds: [investigation.finding.repositoryLinkId], includeBranches: true, includePullRequests: true, includeActions: true }); + await prisma.$transaction([ + prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.verifying } }), + prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.verifying } }), + ]); + return { job: result.job, investigationId: investigation.id, findingId: investigation.findingId }; +}; + +export const finalizeFindingVerificationForJob = async (env: AppEnv, jobId: string) => { + const items = await prisma.analysisJobItem.findMany({ where: { jobId }, include: { findingOccurrences: true } }); + const completed = []; + for (const item of items) { + if (item.status !== "succeeded") continue; + const investigations = await prisma.findingInvestigation.findMany({ where: { status: InvestigationStatus.verifying, finding: { owner: item.owner, repository: item.repository } }, include: { finding: { include: { issueLink: true } }, deliveryPlan: true } }); + for (const investigation of investigations) { + const observed = item.findingOccurrences.some((occurrence) => occurrence.findingId === investigation.findingId && occurrence.observed); + if (observed) { + await prisma.$transaction([ + prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.failed, completedAt: new Date(), conclusion: "De bevinding is na de merge opnieuw waargenomen." } }), + prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.regressed } }), + ]); + if (investigation.finding.issueLink) await getGiteaIssueClient(env).createIssueComment(item.owner, item.repository, investigation.finding.issueLink.issueNumber, "ProjectBrain-verificatie mislukt: de bevinding is na de merge opnieuw waargenomen.").catch(() => undefined); + completed.push({ findingId: investigation.findingId, resolved: false }); + } else if (item.findingOccurrences.some((occurrence) => occurrence.findingId === investigation.findingId && occurrence.observed === false)) { + await prisma.$transaction([ + prisma.findingInvestigation.update({ where: { id: investigation.id }, data: { status: InvestigationStatus.resolved, completedAt: new Date(), conclusion: "Gerichte heranalyse bevestigt dat de bevinding verdwenen is." } }), + prisma.repositoryFinding.update({ where: { id: investigation.findingId }, data: { status: FindingStatus.resolved, resolvedAt: new Date(), clearCount: { increment: 1 } } }), + ]); + if (investigation.deliveryPlan) await markFeatureDeliveryVerified(investigation.deliveryPlan.projectId, investigation.deliveryPlan.id, "projectbrain-verifier").catch(() => undefined); + const policy = investigation.finding.projectId ? await getFindingAutomationPolicy(investigation.finding.projectId) : undefined; + if (policy?.autoCloseResolved) await closeResolvedFindingIssue(env, investigation.findingId, "Gerichte heranalyse na de merge vond de bevinding niet meer.").catch(() => undefined); + completed.push({ findingId: investigation.findingId, resolved: true }); + } + } + } + return completed; +}; + +export const syncAutomaticFindingIssues = async (env: AppEnv, jobId: string) => { + const findings = await prisma.repositoryFinding.findMany({ where: { lastAnalysisJobId: jobId, projectId: { not: null }, repositoryLinkId: { not: null }, status: { in: [FindingStatus.new, FindingStatus.regressed, FindingStatus.triaged] } }, include: { project: { include: { findingAutomationPolicy: true } }, issueLink: true } }); + const created: string[] = []; + const counts = new Map(); + for (const finding of findings) { + const policy = finding.project?.findingAutomationPolicy ?? (finding.projectId ? await getFindingAutomationPolicy(finding.projectId) : undefined); + if (!policy?.enabled || policy.issueSyncMode !== IssueSyncMode.automatic || severityRank[finding.severity] < severityRank[policy.minimumSeverity] || finding.occurrenceCount < policy.minimumOccurrences) continue; + const projectCount = counts.get(finding.projectId!) ?? 0; + if (projectCount >= policy.maxIssuesPerRun) continue; + if (finding.status === FindingStatus.regressed && finding.issueLink?.state === "closed" && !policy.autoReopenRegressed) continue; + try { + await syncFindingIssue(env, finding.id, "system:analysis-worker"); + counts.set(finding.projectId!, projectCount + 1); + created.push(finding.id); + } catch (error) { + console.warn(JSON.stringify({ event: "finding_issue_sync_failed", findingId: finding.id, code: error instanceof Error ? error.message : "FINDING_ISSUE_SYNC_FAILED" })); + } + } + return created; +}; diff --git a/backend/src/repositories/initiativesRepository.ts b/backend/src/repositories/initiativesRepository.ts new file mode 100644 index 0000000..f3a5c96 --- /dev/null +++ b/backend/src/repositories/initiativesRepository.ts @@ -0,0 +1,406 @@ +import { ApprovalState, EntityType, EvidenceStrength, FeatureLifecycle, FeatureStatus, ReviewStatus, VerificationState } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { Initiative, Prisma } from "@prisma/client"; + +export type InitiativeDto = { + id: string; + projectId: string; + title: string; + intent: string; + status: "proposed" | "planned" | "active" | "complete" | "paused"; + featureIds: string[]; + priority: "critical" | "important" | "normal" | "low"; + confidence?: number; + approvalState?: string; + sourceBrainstormId?: string; + reviewItemId?: string; + sourceType?: string; + metadata?: unknown; + provenance?: unknown; + createdAt?: string; + updatedAt?: string; +}; + + +const slugify = (value: string) => + value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "initiative"; + +const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; + +export type CreateInitiativeInput = { + title: string; + intent: string; + priority?: InitiativeDto["priority"]; + status?: InitiativeDto["status"]; + confidence?: number; + featureIds?: string[]; + sourceType?: string; +}; + +export type UpdateInitiativeInput = Partial & { + approvalState?: string; +}; + +const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +const toStatus = (value: string): InitiativeDto["status"] => ["proposed", "planned", "active", "complete", "paused"].includes(value) ? value as InitiativeDto["status"] : "proposed"; +const toPriority = (value: string): InitiativeDto["priority"] => ["critical", "important", "normal", "low"].includes(value) ? value as InitiativeDto["priority"] : "normal"; + +const getProject = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }}); + if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`); + return project; +}; + +const toDto = (projectSlug: string, initiative: Initiative): InitiativeDto => ({ + id: initiative.slug ?? initiative.id, + projectId: projectSlug, + title: initiative.title, + intent: initiative.intent, + status: toStatus(initiative.status), + featureIds: toStringArray(initiative.featureIdsJson), + priority: toPriority(initiative.priority), + confidence: initiative.confidence, + approvalState: initiative.approvalState, + sourceBrainstormId: initiative.sourceBrainstormId ?? undefined, + reviewItemId: initiative.reviewItemId ?? undefined, + sourceType: initiative.sourceType, + metadata: initiative.metadataJson ?? undefined, + provenance: initiative.provenanceJson ?? undefined, + createdAt: initiative.createdAt.toISOString(), + updatedAt: initiative.updatedAt.toISOString(), +}); + +export const listInitiativesForProject = async (projectIdOrSlug: string): Promise => { + const project = await getProject(projectIdOrSlug); + const items = await prisma.initiative.findMany({ + where: { projectId: project.id, deletedAt: null }, + orderBy: [{ status: "asc" }, { updatedAt: "desc" }], + }); + return items.map((item) => toDto(project.slug, item)); +}; + +export const markInitiativePlannedFromReview = async (projectId: string, initiativeId: string, reviewItemId: string): Promise => { + const existing = await prisma.initiative.findFirst({ + where: { projectId, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] }, + }); + if (!existing) return undefined; + const updated = await prisma.initiative.update({ + where: { id: existing.id }, + data: { + status: "planned", + approvalState: ApprovalState.approved, + reviewItemId, + updatedBy: "review_queue", + }, + }); + return updated.slug ?? updated.id; +}; + + +export const getInitiativeForProject = async (projectIdOrSlug: string, initiativeId: string): Promise => { + const project = await getProject(projectIdOrSlug); + const item = await prisma.initiative.findFirst({ + where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] }, + }); + return item ? toDto(project.slug, item) : undefined; +}; + +export const createInitiativeForProject = async (projectIdOrSlug: string, input: CreateInitiativeInput): Promise => { + const project = await getProject(projectIdOrSlug); + const slug = `${slugify(input.title)}-${Date.now().toString(36)}`; + const item = await prisma.initiative.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + title: input.title, + intent: input.intent, + priority: input.priority ?? "normal", + status: input.status ?? "proposed", + confidence: input.confidence ?? 72, + featureIdsJson: toJson(input.featureIds ?? []), + approvalState: ApprovalState.pending, + sourceType: input.sourceType ?? "manual_initiative", + metadataJson: toJson({ reviewBeforeTruth: true, createdFrom: "initiative_endpoint" }), + provenanceJson: toJson({ source: input.sourceType ?? "manual_initiative" }), + }, + }); + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: `log-initiative-created-${slug}`, + readableId: `log-initiative-created-${slug}`, + type: "initiative created", + title: `Initiative created: ${input.title}`, + summary: "A roadmap initiative was created as a review-gated planning object.", + linkedEntityIdsJson: toJson([item.slug ?? item.id]), + source: "initiative_center", + importance: input.priority === "critical" ? "critical" : input.priority === "important" ? "important" : "normal", + payloadJson: toJson({ initiativeId: item.slug ?? item.id, status: item.status }), + provenanceJson: toJson({ source: input.sourceType ?? "manual_initiative" }), + }, + }); + return toDto(project.slug, item); +}; + +export const updateInitiativeForProject = async (projectIdOrSlug: string, initiativeId: string, input: UpdateInitiativeInput): Promise => { + const project = await getProject(projectIdOrSlug); + const existing = await prisma.initiative.findFirst({ + where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] }, + }); + if (!existing) return undefined; + const updated = await prisma.initiative.update({ + where: { id: existing.id }, + data: { + title: input.title, + intent: input.intent, + priority: input.priority, + status: input.status, + confidence: input.confidence, + featureIdsJson: input.featureIds ? toJson(input.featureIds) : undefined, + approvalState: input.approvalState === "approved" ? ApprovalState.approved : input.approvalState === "rejected" ? ApprovalState.rejected : undefined, + updatedBy: "initiative_endpoint", + }, + }); + return toDto(project.slug, updated); +}; + +const suggestedFeatureTitlesFor = (initiative: InitiativeDto): string[] => { + const text = `${initiative.title} ${initiative.intent}`.toLowerCase(); + if (text.includes("brainstorm")) return ["Brainstorm review detail", "Outcome conversion guardrails", "Idea graph comparison panel"]; + if (text.includes("blueprint") || text.includes("documentation")) return ["Blueprint acceptance workflow", "Documentation export audit", "Stale document regeneration queue"]; + if (text.includes("database") || text.includes("backend")) return ["Database smoke evidence panel", "API contract regression pack", "Persistence recovery checklist"]; + if (text.includes("ux") || text.includes("usability")) return ["Guided empty-state actions", "Workflow-based navigation polish", "Mobile action rail refinement"]; + return ["Initiative scope brief", "Feature candidate breakdown", "Acceptance criteria draft"]; +}; + +export type InitiativeFeatureCandidateDto = { + id: string; + title: string; + confidence: number; + reviewItemId: string; + targetRoute: string; + materializedFeatureId?: string; +}; + +export type InitiativeReviewSyncResultDto = { + applied: boolean; + action: "accept" | "reject"; + reviewItemId: string; + initiativeId?: string; + featureId?: string; + acceptedTruthId?: string; + affectedEntities: string[]; + message: string; +}; + +export const createFeatureCandidatesForInitiative = async (projectIdOrSlug: string, initiativeId: string): Promise<{ initiative: InitiativeDto; candidates: InitiativeFeatureCandidateDto[] } | undefined> => { + const project = await getProject(projectIdOrSlug); + const initiative = await getInitiativeForProject(projectIdOrSlug, initiativeId); + if (!initiative) return undefined; + const titles = suggestedFeatureTitlesFor(initiative); + const candidates: InitiativeFeatureCandidateDto[] = []; + + for (const [index, title] of titles.entries()) { + const slug = `rq-initiative-feature-${slugify(initiative.title)}-${index}-${Date.now().toString(36)}`; + const review = await prisma.reviewQueueItem.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + type: "feature", + title: `Feature candidate: ${title}`, + description: `Initiative '${initiative.title}' proposes this feature candidate.\n\nIntent: ${initiative.intent}`, + priority: initiative.priority === "critical" ? "critical" : initiative.priority === "important" ? "important" : "normal", + status: ReviewStatus.pending, + source: "initiative_center", + confidence: Math.max(62, (initiative.confidence ?? 72) - index * 6), + approvalState: ApprovalState.pending, + evidenceStrength: EvidenceStrength.medium, + verificationState: VerificationState.needs_evidence, + suggestedAction: "Review whether this feature should be promoted into the Feature Map.", + createdBy: "api", + updatedBy: "api", + sourceType: "initiative_feature_candidate", + targetEntityType: undefined, + targetEntityId: initiative.id, + payloadJson: toJson({ initiativeId: initiative.id, candidateTitle: title, candidateIndex: index }), + provenanceJson: toJson({ createdFrom: "Initiative feature generation", initiativeId: initiative.id }), + }, + }); + candidates.push({ id: `${initiative.id}-candidate-${index}`, title, confidence: Math.max(62, (initiative.confidence ?? 72) - index * 6), reviewItemId: review.slug ?? review.id, targetRoute: `/projects/${project.slug}/review-queue` }); + } + + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: `log-initiative-feature-candidates-${slugify(initiative.title)}-${Date.now().toString(36)}`, + readableId: `log-initiative-feature-candidates-${slugify(initiative.title)}-${Date.now().toString(36)}`, + type: "initiative planning", + title: `Feature candidates generated for ${initiative.title}`, + summary: `${candidates.length} feature candidates were generated and queued for review.`, + linkedEntityIdsJson: toJson([initiative.id, ...candidates.map((item) => item.reviewItemId)]), + source: "initiative_center", + importance: initiative.priority === "critical" ? "critical" : initiative.priority === "important" ? "important" : "normal", + payloadJson: toJson({ initiativeId: initiative.id, candidateCount: candidates.length }), + provenanceJson: toJson({ source: "initiative_feature_generation" }), + }, + }); + + return { initiative, candidates }; +}; + + +const safeRecord = (value: unknown): Record => + typeof value === "object" && value !== null ? value as Record : {}; + +const getString = (value: unknown, fallback = ""): string => typeof value === "string" && value.trim() ? value : fallback; + +const acceptedTruthKindForInitiativeReview = (reviewType: string) => + reviewType === "feature" ? "feature" : reviewType; + +const ensureInitiativeFeatureGroup = async (projectId: string) => + prisma.featureGroup.upsert({ + where: { projectId_slug: { projectId, slug: "initiative-roadmap" } }, + create: { + projectId, + slug: "initiative-roadmap", + readableId: "initiative-roadmap", + name: "Initiative Roadmap", + description: "Features materialized from accepted roadmap initiative candidates.", + focusArea: "Roadmap", + }, + update: {}, + }); + +export const syncInitiativeReviewAction = async ( + projectIdOrSlug: string, + reviewItemId: string, + action: "accept" | "reject", +): Promise => { + const project = await getProject(projectIdOrSlug); + const review = await prisma.reviewQueueItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }], + }, + }); + if (!review || review.sourceType !== "initiative_feature_candidate") return undefined; + + const payload = safeRecord(review.payloadJson); + const initiativeId = getString(payload.initiativeId, review.targetEntityId ?? ""); + const candidateTitle = getString(payload.candidateTitle, review.title.replace(/^Feature candidate:\s*/i, "")); + const affectedEntities = [review.slug ?? review.id]; + + let featureId: string | undefined; + let acceptedTruthId: string | undefined; + + if (initiativeId) affectedEntities.push(initiativeId); + + if (action === "accept") { + const group = await ensureInitiativeFeatureGroup(project.id); + const featureSlug = `initiative-feature-${slugify(candidateTitle)}-${Date.now().toString(36)}`; + const feature = await prisma.feature.create({ + data: { + projectId: project.id, + featureGroupId: group.id, + slug: featureSlug, + readableId: featureSlug, + name: candidateTitle, + description: review.description, + status: FeatureStatus.todo, + lifecycle: FeatureLifecycle.planned, + intent: `Materialized from accepted roadmap initiative candidate. Initiative: ${initiativeId || "unknown"}.`, + confidence: review.confidence, + health: 65, + approvalState: ApprovalState.approved, + evidenceStrength: review.evidenceStrength, + verificationState: VerificationState.needs_evidence, + trustJson: toJson({ initiativeId, reviewItemId: review.slug ?? review.id, materializedFrom: "initiative_feature_candidate" }), + provenanceJson: toJson({ source: "initiative_review_accept", initiativeId, reviewItemId: review.id, candidateTitle }), + sourceType: "initiative_review_accept", + createdBy: "review_queue", + updatedBy: "review_queue", + }, + }); + featureId = feature.slug ?? feature.id; + affectedEntities.push(featureId); + + if (initiativeId) { + const initiative = await prisma.initiative.findFirst({ + where: { projectId: project.id, deletedAt: null, OR: [{ id: initiativeId }, { slug: initiativeId }, { readableId: initiativeId }] }, + }); + if (initiative) { + const existingFeatureIds = toStringArray(initiative.featureIdsJson); + await prisma.initiative.update({ + where: { id: initiative.id }, + data: { + status: initiative.status === "proposed" ? "planned" : initiative.status, + approvalState: ApprovalState.approved, + featureIdsJson: toJson(Array.from(new Set([...existingFeatureIds, featureId]))), + updatedBy: "review_queue", + }, + }); + } + } + + const existingTruth = await prisma.acceptedTruthItem.findFirst({ where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null } }); + if (existingTruth) acceptedTruthId = existingTruth.slug ?? existingTruth.id; + else { + const slug = `truth-initiative-feature-${slugify(candidateTitle)}-${Date.now().toString(36)}`; + const truth = await prisma.acceptedTruthItem.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + sourceReviewItemId: review.id, + kind: acceptedTruthKindForInitiativeReview(review.type), + title: `Accepted initiative feature: ${candidateTitle}`, + summary: review.description, + status: "accepted", + source: "initiative_center", + confidence: review.confidence, + approvalState: ApprovalState.approved, + evidenceStrength: review.evidenceStrength, + verificationState: VerificationState.verified, + payloadJson: toJson({ initiativeId, featureId, reviewItemId: review.slug ?? review.id, candidateTitle, targetRoute: `/projects/${project.slug}/features/${featureId}` }), + provenanceJson: toJson({ createdFrom: "Initiative feature candidate review acceptance", sourceType: review.sourceType }), + verifiedAt: new Date(), + verifiedBy: "api", + }, + }); + acceptedTruthId = truth.slug ?? truth.id; + } + if (acceptedTruthId) affectedEntities.push(acceptedTruthId); + } + + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: `log-initiative-review-${slugify(candidateTitle)}-${Date.now().toString(36)}`, + readableId: `log-initiative-review-${slugify(candidateTitle)}-${Date.now().toString(36)}`, + type: "initiative review sync", + title: action === "accept" ? `Initiative feature accepted: ${candidateTitle}` : `Initiative feature rejected: ${candidateTitle}`, + summary: action === "accept" ? "Accepted initiative feature candidate was materialized into the Feature Map and Accepted Truth." : "Rejected initiative feature candidate was left out of the Feature Map.", + linkedEntityIdsJson: toJson(affectedEntities), + source: "initiative_center", + importance: action === "accept" ? "important" : "normal", + payloadJson: toJson({ action, initiativeId, featureId, acceptedTruthId }), + provenanceJson: toJson({ reviewItemId: review.id, sourceType: review.sourceType }), + }, + }); + + return { + applied: true, + action, + reviewItemId: review.slug ?? review.id, + initiativeId, + featureId, + acceptedTruthId, + affectedEntities, + message: action === "accept" + ? "Initiative feature candidate accepted and materialized into Feature Map." + : "Initiative feature candidate rejected without materializing a feature.", + }; +}; diff --git a/backend/src/repositories/p7RuntimeRepository.ts b/backend/src/repositories/p7RuntimeRepository.ts new file mode 100644 index 0000000..1a0dbdd --- /dev/null +++ b/backend/src/repositories/p7RuntimeRepository.ts @@ -0,0 +1,568 @@ +import { ApprovalState, type Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import { listContextPacksForProject } from "./contextPacksRepository.js"; +import { selectContextPacks } from "../services/contextPackSelector.js"; +import type { + CodexRunImportDto, + CodexTaskDto, + CodexTaskListFilters, + CreateCodexTaskFromHandoffRequestDto, + CreateHandoffBundleRequestDto, + CurrentStateReportDto, + CurrentStateReportListFilters, + GenerateCurrentStateReportRequestDto, + HandoffBundleDto, + HandoffBundleListFilters, + HandoffTarget, + ImportCodexOutputRequestDto, + P7MutationResponseDto, +} from "../types/api.js"; + +type ProjectRecord = { id: string; slug: string; activeContextPackId: string | null }; +type CurrentStateRecord = Prisma.CurrentStateReportGetPayload>; +type HandoffRecord = Prisma.HandoffBundleGetPayload>; +type CodexTaskRecord = Prisma.CodexTaskGetPayload>; +type CodexImportRecord = Prisma.CodexRunImportGetPayload>; + +type PageOptions = { page: number; pageSize: number; sort?: string }; + +const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const asStrings = (value: Prisma.JsonValue | null | undefined): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +const asRecord = (value: Prisma.JsonValue | null | undefined): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : undefined; +const publicId = (record: { id: string; slug: string | null; readableId: string | null }) => record.slug ?? record.readableId ?? record.id; + +type StoredContextSelection = { + primaryContextPackId?: string; + selectedContextPackIds?: string[]; + decisionSummary?: string; + candidateCount?: number; +}; + +const readStoredContextSelection = (value: Prisma.JsonValue | null | undefined): StoredContextSelection | undefined => { + const record = asRecord(value); + const rawSelection = asRecord(record?.contextSelection as Prisma.JsonValue | undefined); + if (!rawSelection) return undefined; + const primaryContextPackId = typeof rawSelection.primaryContextPackId === "string" ? rawSelection.primaryContextPackId : undefined; + const selectedContextPackIds = asStrings(rawSelection.selectedContextPackIds as Prisma.JsonValue | undefined); + const decisionSummary = typeof rawSelection.decisionSummary === "string" ? rawSelection.decisionSummary : undefined; + const candidateCount = typeof rawSelection.candidateCount === "number" ? rawSelection.candidateCount : undefined; + if (!primaryContextPackId && !selectedContextPackIds.length && !decisionSummary) return undefined; + return { primaryContextPackId, selectedContextPackIds, decisionSummary, candidateCount }; +}; + +const findProjectRecord = (projectIdOrSlug: string): Promise => + prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }] }, + select: { id: true, slug: true, activeContextPackId: true }, + }); + +const findHandoffRecord = (projectId: string, idOrSlug: string) => + prisma.handoffBundle.findFirst({ + where: { projectId, deletedAt: null, OR: [{ id: idOrSlug }, { slug: idOrSlug }, { readableId: idOrSlug }] }, + }); + +const findCodexTaskRecord = (projectId: string, idOrSlug: string) => + prisma.codexTask.findFirst({ + where: { projectId, deletedAt: null, OR: [{ id: idOrSlug }, { slug: idOrSlug }, { readableId: idOrSlug }] }, + }); + +const paginateMeta = (items: T[], total: number, page: number, pageSize: number, sort?: string, filters?: Record) => ({ + items, + total, + page, + pageSize, + hasNextPage: page < Math.ceil(total / pageSize), + hasPreviousPage: page > 1, + ...(sort ? { sort } : {}), + ...(filters ? { filters: filters as Record } : {}), +}); + +const toCurrentStateDto = (record: CurrentStateRecord, projectSlug: string): CurrentStateReportDto => ({ + id: publicId(record), + projectId: projectSlug, + generatedAt: record.generatedAt.toISOString(), + exists: asStrings(record.existsJson), + works: asStrings(record.worksJson), + uncertain: asStrings(record.uncertainJson), + broken: asStrings(record.brokenJson), + currentRisks: asStrings(record.currentRisksJson), + pendingReviewItemIds: asStrings(record.pendingReviewItemIdsJson), + ...(record.activeContextPackId ? { activeContextPackId: record.activeContextPackId } : {}), + recommendedNextActions: asStrings(record.recommendedNextActionsJson), + source: record.source, + approvalState: record.approvalState, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), +}); + +const toHandoffDto = (record: HandoffRecord, projectSlug: string): HandoffBundleDto => { + const contextSelection = readStoredContextSelection(record.payloadJson); + return { + id: publicId(record), + projectId: projectSlug, + target: record.target as HandoffTarget, + title: record.title, + markdown: record.markdown, + qualityScore: record.qualityScore, + ...(contextSelection?.primaryContextPackId ? { primaryContextPackId: contextSelection.primaryContextPackId } : {}), + ...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}), + sourceTruthItemIds: asStrings(record.sourceTruthItemIdsJson), + sourceReviewItemIds: asStrings(record.sourceReviewItemIdsJson), + sourceCaptureItemIds: asStrings(record.sourceCaptureItemIdsJson), + sourceProjectUpdateIds: asStrings(record.sourceProjectUpdateIdsJson), + sourceOperatingLogEntryIds: asStrings(record.sourceOperatingLogEntryIdsJson), + status: record.status as HandoffBundleDto["status"], + approvalState: record.approvalState, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), +}; +}; + +const toCodexTaskDto = (record: CodexTaskRecord, projectSlug: string): CodexTaskDto => { + const contextSelection = readStoredContextSelection(record.payloadJson); + return { + id: publicId(record), + projectId: projectSlug, + ...(record.handoffBundleId ? { handoffBundleId: record.handoffBundleId } : {}), + goal: record.goal, + ...(record.contextPackId ? { contextPackId: record.contextPackId } : {}), + ...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}), + ...(contextSelection?.decisionSummary ? { contextSelectionSummary: contextSelection.decisionSummary } : {}), + constraints: asStrings(record.constraintsJson), + relevantFiles: asStrings(record.relevantFilesJson), + doNotBreakRules: asStrings(record.doNotBreakRulesJson), + validationCommands: asStrings(record.validationCommandsJson), + expectedReportFormat: record.expectedReportFormat, + outputImportInstructions: record.outputImportInstructions, + approvalState: record.approvalState, + status: record.status as CodexTaskDto["status"], + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }; +}; + +const toCodexImportDto = (record: CodexImportRecord, projectSlug: string): CodexRunImportDto => { + const contextSelection = readStoredContextSelection(record.payloadJson); + return { + id: publicId(record), + projectId: projectSlug, + ...(record.codexTaskId ? { codexTaskId: record.codexTaskId } : {}), + sourceRun: record.sourceRun, + summary: record.summary, + ...(contextSelection?.primaryContextPackId ? { contextPackId: contextSelection.primaryContextPackId } : {}), + ...(contextSelection?.selectedContextPackIds?.length ? { selectedContextPackIds: contextSelection.selectedContextPackIds } : {}), + ...(contextSelection?.decisionSummary ? { contextSelectionSummary: contextSelection.decisionSummary } : {}), + extractedReviewItemIds: asStrings(record.extractedReviewItemIdsJson), + status: record.status as CodexRunImportDto["status"], + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + }; +}; + +const resolveRuntimeContextSelection = async (args: { + projectSlug: string; + activeContextPackId?: string | null; + target: HandoffTarget | "codex-task"; + title: string; + intent?: string; + explicitContextPackId?: string; + existingSelection?: StoredContextSelection; +}): Promise => { + if (args.explicitContextPackId) { + return { + primaryContextPackId: args.explicitContextPackId, + selectedContextPackIds: [args.explicitContextPackId], + decisionSummary: `Explicit context pack '${args.explicitContextPackId}' was supplied by the caller.`, + candidateCount: 1, + }; + } + + if (args.existingSelection?.primaryContextPackId || args.existingSelection?.selectedContextPackIds?.length) { + return args.existingSelection; + } + + const contextPacks = await listContextPacksForProject(args.projectSlug); + if (!contextPacks?.length) return undefined; + + const mode = args.target === "codex-task" ? "codex" : args.target === "current-state" ? "validation" : "workspace"; + const selection = selectContextPacks( + args.projectSlug, + contextPacks, + { + mode, + intent: args.intent?.trim() || `${args.title}. Runtime handoff should preserve accepted truth, review discipline and current project constraints.`, + targetType: args.target === "codex-task" ? "build" : "project", + maxPacks: 3, + }, + args.activeContextPackId ?? undefined, + ); + + return { + primaryContextPackId: selection.primaryContextPackId, + selectedContextPackIds: selection.selectedContextPackIds, + decisionSummary: selection.decisionSummary, + candidateCount: selection.candidates.length, + }; +}; + +const mkSlug = (prefix: string, seed: string) => `${prefix}-${seed.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 42)}-${Date.now().toString(36)}`; + +export const listCurrentStateReportsFromDatabase = async (projectIdOrSlug: string, filters: CurrentStateReportListFilters, options: PageOptions) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const where: Prisma.CurrentStateReportWhereInput = { + projectId: project.id, + deletedAt: null, + ...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}), + ...(filters.source?.length ? { source: { in: filters.source } } : {}), + }; + const total = await prisma.currentStateReport.count({ where }); + const records = await prisma.currentStateReport.findMany({ + where, + orderBy: { generatedAt: options.sort?.endsWith(":asc") ? "asc" : "desc" }, + skip: (options.page - 1) * options.pageSize, + take: options.pageSize, + }); + return paginateMeta(records.map((record) => toCurrentStateDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters); +}; + +export const generateCurrentStateReportInDatabase = async ( + projectIdOrSlug: string, + _body: GenerateCurrentStateReportRequestDto, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const [truth, updates, review, logs, contextPack] = await Promise.all([ + prisma.acceptedTruthItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }), + prisma.projectUpdate.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }), + prisma.reviewQueueItem.findMany({ where: { projectId: project.id, deletedAt: null, status: "pending" }, orderBy: { createdAt: "desc" }, take: 12 }), + prisma.operatingLogEntry.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 8 }), + prisma.contextPack.findFirst({ where: { projectId: project.id, deletedAt: null }, orderBy: { updatedAt: "desc" } }), + ]); + const exists = ["Project", "Workspace", "Project DNA", "Feature/Evidence DB parity", "Capture/Review DB parity", "Accepted Truth/Project Updates/Operating Log DB parity"]; + const works = [ + ...truth.slice(0, 3).map((item) => `Accepted truth: ${item.title}`), + ...updates.slice(0, 3).map((item) => `Project update ${item.status}: ${item.title}`), + ...logs.slice(0, 2).map((item) => `Operating log: ${item.title}`), + ]; + const uncertain = review.length ? [`${review.length} pending Review Queue item(s) still require approval.`] : []; + const broken = updates.filter((item) => item.status !== "applied").length ? ["Some project updates are not applied yet."] : []; + const currentRisks = [ + "Generated reports remain reviewable artifacts; they do not mutate project truth silently.", + ...(review.length ? ["Pending review items may hide unaccepted build or product facts."] : []), + ]; + const recommendedNextActions = [ + updates.some((item) => item.status !== "applied") ? "Apply ready project updates after review." : "Prepare the next context handoff from accepted truth.", + "Create a Codex handoff bundle from the latest accepted truth and current state.", + "Import Codex/build output through Capture and Review Queue, not directly into truth.", + ]; + const record = await prisma.currentStateReport.create({ + data: { + projectId: project.id, + slug: mkSlug("current-state", project.slug), + readableId: mkSlug("csr", project.slug), + existsJson: toJson(exists), + worksJson: toJson(works.length ? works : ["Backend persistence gates up to P6 are available."]), + uncertainJson: toJson(uncertain), + brokenJson: toJson(broken), + currentRisksJson: toJson(currentRisks), + pendingReviewItemIdsJson: toJson(review.map((item) => item.slug ?? item.readableId ?? item.id)), + activeContextPackId: project.activeContextPackId ?? contextPack?.slug ?? contextPack?.readableId ?? contextPack?.id, + recommendedNextActionsJson: toJson(recommendedNextActions), + source: "database-runtime-generator", + approvalState: ApprovalState.pending, + provenanceJson: toJson({ createdFrom: "P7 current state generator" }), + }, + }); + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: mkSlug("log-current-state", project.slug), + readableId: mkSlug("log", project.slug), + type: "build summary", + title: "Current State report generated", + summary: `Generated ${publicId(record)} from accepted truth, project updates and operating log.`, + linkedEntityIdsJson: toJson([record.id]), + source: "p7-current-state", + importance: "important", + }, + }); + return toCurrentStateDto(record, project.slug); +}; + +const markdownForHandoff = (args: { + projectSlug: string; + target: HandoffTarget; + title: string; + truth: Array<{ id: string; slug: string | null; readableId: string | null; title: string; kind: string; summary: string }>; + updates: Array<{ id: string; slug: string | null; readableId: string | null; title: string; targetArea: string; status: string; summary: string }>; + logs: Array<{ id: string; slug: string | null; readableId: string | null; title: string; type: string; summary: string }>; + contextSelection?: StoredContextSelection; +}) => [ + `# ${args.title}`, + "", + `Project: ${args.projectSlug}`, + `Target: ${args.target}`, + `Generated: ${new Date().toISOString()}`, + "", + "## Automatic context pack selection", + args.contextSelection?.primaryContextPackId ? `Primary pack: ${args.contextSelection.primaryContextPackId}` : "Primary pack: none", + args.contextSelection?.selectedContextPackIds?.length ? `Selected packs: ${args.contextSelection.selectedContextPackIds.join(", ")}` : "Selected packs: none", + args.contextSelection?.decisionSummary ? `Decision: ${args.contextSelection.decisionSummary}` : "Decision: no selector result attached", + typeof args.contextSelection?.candidateCount === "number" ? `Candidates considered: ${args.contextSelection.candidateCount}` : "Candidates considered: 0", + "", + "## Accepted truth", + ...(args.truth.length ? args.truth.map((item) => `- [${item.kind}] ${item.title}: ${item.summary}`) : ["- None yet."]), + "", + "## Project updates", + ...(args.updates.length ? args.updates.map((item) => `- [${item.status}] [${item.targetArea}] ${item.title}: ${item.summary}`) : ["- None yet."]), + "", + "## Operating log context", + ...(args.logs.length ? args.logs.map((item) => `- [${item.type}] ${item.title}: ${item.summary}`) : ["- None yet."]), + "", + "## Guardrails", + "- Do not mutate project truth directly from AI/build output.", + "- Route output back through Capture and Review Queue.", + "- Preserve existing API contracts and fixture mode unless explicitly changing a persistence gate.", +].join("\n"); + +export const listHandoffBundlesFromDatabase = async (projectIdOrSlug: string, filters: HandoffBundleListFilters, options: PageOptions) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const where: Prisma.HandoffBundleWhereInput = { + projectId: project.id, + deletedAt: null, + ...(filters.target?.length ? { target: { in: filters.target } } : {}), + ...(filters.status?.length ? { status: { in: filters.status } } : {}), + ...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}), + }; + const total = await prisma.handoffBundle.count({ where }); + const records = await prisma.handoffBundle.findMany({ where, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.pageSize, take: options.pageSize }); + return paginateMeta(records.map((record) => toHandoffDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters); +}; + +export const createHandoffBundleInDatabase = async (projectIdOrSlug: string, body: CreateHandoffBundleRequestDto): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const [truth, updates, review, captures, logs] = await Promise.all([ + prisma.acceptedTruthItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 12 }), + prisma.projectUpdate.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 12 }), + prisma.reviewQueueItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }), + prisma.captureItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { createdAt: "desc" }, take: 8 }), + prisma.operatingLogEntry.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 8 }), + ]); + const target = body.target ?? "codex-task"; + const title = body.title?.trim() || `${target} handoff for ${project.slug}`; + const contextSelection = await resolveRuntimeContextSelection({ + projectSlug: project.slug, + activeContextPackId: project.activeContextPackId, + target, + title, + intent: body.selectionIntent, + explicitContextPackId: body.contextPackId, + }); + const qualityScore = Math.min(98, 45 + truth.length * 10 + updates.length * 5 + logs.length * 2 + (contextSelection?.primaryContextPackId ? 6 : 0)); + const markdown = markdownForHandoff({ projectSlug: project.slug, target, title, truth, updates, logs, contextSelection }); + const record = await prisma.handoffBundle.create({ + data: { + projectId: project.id, + slug: mkSlug("handoff", title), + readableId: mkSlug("hb", project.slug), + target, + title, + markdown, + qualityScore, + sourceTruthItemIdsJson: toJson(truth.map(publicId)), + sourceReviewItemIdsJson: toJson(review.map(publicId)), + sourceCaptureItemIdsJson: toJson(captures.map(publicId)), + sourceProjectUpdateIdsJson: toJson(updates.map(publicId)), + sourceOperatingLogEntryIdsJson: toJson(logs.map(publicId)), + status: "ready", + approvalState: ApprovalState.pending, + payloadJson: toJson({ contextSelection }), + provenanceJson: toJson({ createdFrom: "P7 handoff bundle generator", contextSelectionSummary: contextSelection?.decisionSummary }), + }, + }); + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: mkSlug("log-handoff", project.slug), + readableId: mkSlug("log", project.slug), + type: "project update", + title: "Handoff bundle created", + summary: `${title} created for ${target} with ${qualityScore}% quality.`, + linkedEntityIdsJson: toJson([record.id]), + source: "p7-handoff", + importance: qualityScore >= 75 ? "important" : "normal", + }, + }); + return toHandoffDto(record, project.slug); +}; + +export const listCodexTasksFromDatabase = async (projectIdOrSlug: string, filters: CodexTaskListFilters, options: PageOptions) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const handoffFilter = filters.handoffBundleId ? await findHandoffRecord(project.id, filters.handoffBundleId) : undefined; + if (filters.handoffBundleId && !handoffFilter) { + return paginateMeta([], 0, options.page, options.pageSize, options.sort, filters); + } + const where: Prisma.CodexTaskWhereInput = { + projectId: project.id, + deletedAt: null, + ...(filters.status?.length ? { status: { in: filters.status } } : {}), + ...(filters.approvalState?.length ? { approvalState: { in: filters.approvalState } } : {}), + ...(handoffFilter ? { handoffBundleId: handoffFilter.id } : {}), + }; + const total = await prisma.codexTask.count({ where }); + const records = await prisma.codexTask.findMany({ where, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.pageSize, take: options.pageSize }); + return paginateMeta(records.map((record) => toCodexTaskDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters); +}; + +export const createCodexTaskFromHandoffInDatabase = async ( + projectIdOrSlug: string, + handoffBundleId: string, + body: CreateCodexTaskFromHandoffRequestDto, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const handoff = await findHandoffRecord(project.id, handoffBundleId); + if (!handoff) return "HANDOFF_NOT_FOUND"; + const goal = body.goal?.trim() || `Implement ${handoff.title}`; + const existingSelection = readStoredContextSelection(handoff.payloadJson); + const contextSelection = body.autoSelectContext === false + ? await resolveRuntimeContextSelection({ + projectSlug: project.slug, + activeContextPackId: project.activeContextPackId, + target: "codex-task", + title: goal, + explicitContextPackId: body.contextPackId ?? existingSelection?.primaryContextPackId ?? project.activeContextPackId ?? undefined, + existingSelection, + }) + : await resolveRuntimeContextSelection({ + projectSlug: project.slug, + activeContextPackId: project.activeContextPackId, + target: "codex-task", + title: goal, + intent: body.selectionIntent ?? `${goal}. ${handoff.title}.`, + explicitContextPackId: body.contextPackId, + existingSelection, + }); + const selectedContextPackId = body.contextPackId ?? contextSelection?.primaryContextPackId ?? project.activeContextPackId ?? undefined; + const record = await prisma.codexTask.create({ + data: { + projectId: project.id, + slug: mkSlug("codex-task", goal), + readableId: mkSlug("ct", project.slug), + handoffBundleId: handoff.id, + goal, + contextPackId: selectedContextPackId, + constraintsJson: toJson(["Preserve fixture mode.", "Do not write AI output directly into project truth.", "Keep review-before-truth semantics."]), + relevantFilesJson: toJson(["backend/src/routes", "backend/src/repositories", "src/features/operations", "docs"]), + doNotBreakRulesJson: toJson(["No Vite middleware expansion.", "No frontend default switch to real mode.", "No silent truth mutation."]), + validationCommandsJson: toJson(body.validationCommands?.length ? body.validationCommands : ["pnpm run validate:all", "pnpm run audit:runtime", "pnpm run audit:release"]), + expectedReportFormat: "Changed files, validation commands, remaining warnings, smoke status and next checklist.", + outputImportInstructions: "Import Codex/build output as Capture, extract Review Queue items, and only materialize approved truth after review.", + approvalState: ApprovalState.pending, + status: "ready", + payloadJson: toJson({ contextSelection }), + provenanceJson: toJson({ handoffBundleId: publicId(handoff), createdFrom: "P7 handoff", contextSelectionSummary: contextSelection?.decisionSummary }), + }, + }); + await prisma.handoffBundle.update({ where: { id: handoff.id }, data: { status: "used" } }); + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: mkSlug("log-codex-task", project.slug), + readableId: mkSlug("log", project.slug), + type: "codex run", + title: "Codex task created from handoff", + summary: `${goal} was created from ${handoff.title}.${selectedContextPackId ? ` Context pack: ${selectedContextPackId}.` : ""}`, + linkedEntityIdsJson: toJson([handoff.id, record.id]), + source: "p7-codex-task", + importance: "important", + }, + }); + return toCodexTaskDto(record, project.slug); +}; + +export const importCodexOutputInDatabase = async ( + projectIdOrSlug: string, + codexTaskId: string, + body: ImportCodexOutputRequestDto, +): Promise<{ importDto: CodexRunImportDto; mutation: P7MutationResponseDto } | undefined | "CODEX_TASK_NOT_FOUND"> => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const task = await findCodexTaskRecord(project.id, codexTaskId); + if (!task) return "CODEX_TASK_NOT_FOUND"; + const contextSelection = readStoredContextSelection(task.payloadJson) ?? (task.contextPackId + ? { + primaryContextPackId: task.contextPackId, + selectedContextPackIds: [task.contextPackId], + decisionSummary: `Imported output inherits Codex task context pack '${task.contextPackId}'.`, + candidateCount: 1, + } + : undefined); + const review = await prisma.reviewQueueItem.create({ + data: { + projectId: project.id, + slug: mkSlug("review-codex-output", project.slug), + readableId: mkSlug("rq", project.slug), + type: "documentation_update", + title: `Review Codex output for ${task.goal}`, + description: body.summary, + priority: "important", + status: "pending", + source: body.sourceRun ?? task.slug ?? task.id, + confidence: 72, + approvalState: ApprovalState.pending, + evidenceStrength: "medium", + verificationState: "unverified", + suggestedAction: "Review generated changes, then accept only verified feature, documentation, memory or regression updates.", + createdBy: "api", + updatedBy: "api", + sourceType: "codex_output_import", + payloadJson: toJson({ outputText: body.outputText ?? "", codexTaskId: task.id, contextSelection }), + provenanceJson: toJson({ createdFrom: "P7 codex import", contextSelectionSummary: contextSelection?.decisionSummary }), + }, + }); + const record = await prisma.codexRunImport.create({ + data: { + projectId: project.id, + slug: mkSlug("codex-import", project.slug), + readableId: mkSlug("cri", project.slug), + codexTaskId: task.id, + sourceRun: body.sourceRun ?? `manual-${Date.now().toString(36)}`, + summary: body.summary, + extractedReviewItemIdsJson: toJson([publicId(review)]), + status: "imported", + payloadJson: toJson({ outputText: body.outputText ?? "", contextSelection }), + provenanceJson: toJson({ createdFrom: "P7 codex output import", contextSelectionSummary: contextSelection?.decisionSummary }), + }, + }); + await prisma.codexTask.update({ where: { id: task.id }, data: { status: "imported" } }); + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: mkSlug("log-codex-import", project.slug), + readableId: mkSlug("log", project.slug), + type: "codex run", + title: "Codex output imported to Review Queue", + summary: `${body.summary}${contextSelection?.primaryContextPackId ? ` Context pack: ${contextSelection.primaryContextPackId}.` : ""}`, + linkedEntityIdsJson: toJson([task.id, record.id, review.id]), + source: "p7-codex-import", + importance: "important", + }, + }); + return { + importDto: toCodexImportDto(record, project.slug), + mutation: { + success: true, + message: "Codex output imported as a Review Queue item.", + createdEntityId: publicId(review), + updatedEntityId: publicId(task), + affectedEntities: [publicId(task), publicId(record), publicId(review)], + }, + }; +}; diff --git a/backend/src/repositories/p8KnowledgeMemoryRepository.ts b/backend/src/repositories/p8KnowledgeMemoryRepository.ts new file mode 100644 index 0000000..c96cc22 --- /dev/null +++ b/backend/src/repositories/p8KnowledgeMemoryRepository.ts @@ -0,0 +1,270 @@ +import type { Prisma } from "@prisma/client"; +import { ApprovalState, EvidenceStrength, VerificationState } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { + KnowledgeItemDto, + KnowledgeListFilters, + KnowledgeMutationRequestDto, + MemoryRuleDto, + MemoryRuleListFilters, + MemoryRuleMutationRequestDto, +} from "../types/api.js"; + +export type DocumentationItemDto = { + id: string; + projectId: string; + title: string; + kind: string; + status: string; + linkedFeatureIds: string[]; + lastUpdated?: string; + primaryContextPackId?: string; + selectedContextPackIds?: string[]; + contextSelectionSummary?: string; + approvalState: "pending" | "approved" | "rejected" | "blocked"; + content?: string; + createdAt: string; + updatedAt: string; +}; + +type ApprovalStateInput = ApprovalState | "pending" | "approved" | "rejected" | "blocked"; + +export type DocumentationListFilters = { + kind?: string[]; + status?: string[]; + approvalState?: string[]; + search?: string; +}; + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; +const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +const toJson = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; + +export const projectExists = async (projectIdOrSlug: string): Promise => { + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }, select: { id: true }}); + return Boolean(project); +}; + +const getProject = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }}); + if (!project) throw new Error(`Project '${projectIdOrSlug}' not found`); + return project; +}; + +const metadata = (value: Prisma.JsonValue | null | undefined): Record => isRecord(value) ? value : {}; + +const trust = (source: string, confidence: number, approvalState: ApprovalState, createdAt: Date) => ({ + source, + confidence, + evidence: [], + lastChecked: createdAt.toISOString(), + approvalState, +}); + +export const toKnowledgeDto = (item: { + slug: string | null; readableId: string | null; project: { slug: string }; title: string; category: string; content: string; source: string; confidence: number; metadataJson: Prisma.JsonValue | null; trustJson: Prisma.JsonValue | null; approvalState: ApprovalState; createdAt: Date; updatedAt: Date; +}): KnowledgeItemDto => { + const meta = metadata(item.metadataJson); + const trustJson = isRecord(item.trustJson) ? item.trustJson : trust(item.source, item.confidence, item.approvalState, item.updatedAt); + return { + id: item.slug ?? item.readableId ?? item.title, + projectId: item.project.slug, + title: item.title, + category: item.category, + content: item.content, + linkedFeatureIds: toStringArray(meta.linkedFeatureIds), + source: item.source, + confidence: item.confidence, + includeInContextPacks: toStringArray(meta.includeInContextPacks), + trust: trustJson as KnowledgeItemDto["trust"], + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + }; +}; + +export const toMemoryRuleDto = (rule: { + slug: string | null; readableId: string | null; project: { slug: string }; title: string; rule: string; scope: string; category: string; severity: string; includeInContextPacks: boolean; confidence: number; trustJson: Prisma.JsonValue | null; provenanceJson: Prisma.JsonValue | null; approvalState: ApprovalState; createdAt: Date; updatedAt: Date; +}): MemoryRuleDto => { + const meta = metadata(rule.provenanceJson); + const trustJson = isRecord(rule.trustJson) ? rule.trustJson : trust("database memory rule", rule.confidence, rule.approvalState, rule.updatedAt); + return { + id: rule.slug ?? rule.readableId ?? rule.title, + projectId: rule.project.slug, + title: rule.title, + rule: rule.rule, + scope: rule.scope as MemoryRuleDto["scope"], + category: rule.category as MemoryRuleDto["category"], + severity: rule.severity as MemoryRuleDto["severity"], + linkedFeatureIds: toStringArray(meta.linkedFeatureIds), + includeInContextPacks: toStringArray(meta.includeInContextPacks), + includeInContextPacksDefault: rule.includeInContextPacks, + trust: trustJson as MemoryRuleDto["trust"], + createdAt: rule.createdAt.toISOString(), + updatedAt: rule.updatedAt.toISOString(), + }; +}; + +export const toDocumentationDto = (doc: { + slug: string | null; readableId: string | null; project: { slug: string }; title: string; kind: string; status: string; approvalState: ApprovalState; content: string | null; metadataJson: Prisma.JsonValue | null; createdAt: Date; updatedAt: Date; +}): DocumentationItemDto => { + const meta = metadata(doc.metadataJson); + return { + id: doc.slug ?? doc.readableId ?? doc.title, + projectId: doc.project.slug, + title: doc.title, + kind: doc.kind, + status: doc.status, + linkedFeatureIds: toStringArray(meta.linkedFeatureIds), + lastUpdated: typeof meta.lastUpdated === "string" ? meta.lastUpdated : doc.updatedAt.toISOString(), + primaryContextPackId: typeof meta.primaryContextPackId === "string" ? meta.primaryContextPackId : undefined, + selectedContextPackIds: toStringArray(meta.selectedContextPackIds), + contextSelectionSummary: typeof meta.contextSelectionSummary === "string" ? meta.contextSelectionSummary : undefined, + approvalState: doc.approvalState, + content: doc.content ?? undefined, + createdAt: doc.createdAt.toISOString(), + updatedAt: doc.updatedAt.toISOString(), + }; +}; + +const filterSearch = (haystack: string, search?: string) => !search || haystack.toLowerCase().includes(search.toLowerCase()); + +export const listKnowledgeForProject = async (projectId: string, filters: KnowledgeListFilters = {}): Promise => { + const project = await getProject(projectId); + const rows = await prisma.knowledgeItem.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }}); + return rows.map(toKnowledgeDto).filter((item) => + (!filters.category?.length || filters.category.includes(item.category)) && + (!filters.source?.length || filters.source.includes(item.source)) && + (filters.confidence === undefined || item.confidence === filters.confidence) && + (!filters.featureId || item.linkedFeatureIds.includes(filters.featureId)) && + filterSearch(`${item.title} ${item.content} ${item.category}`, filters.search) + ); +}; + +export const getKnowledgeBySlugOrId = async (projectId: string, knowledgeId: string): Promise => { + const project = await getProject(projectId); + const row = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }, include: { project: { select: { slug: true } } }}); + return row ? toKnowledgeDto(row) : undefined; +}; + +export const createKnowledgeItem = async (projectId: string, body: KnowledgeMutationRequestDto & { title: string; content: string }): Promise => { + const project = await getProject(projectId); + const now = new Date(); + const confidence = body.confidence ?? 75; + const slug = `kn-${project.slug}-${Date.now().toString(36)}`; + const row = await prisma.knowledgeItem.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title.trim(), category: body.category?.trim() || "Technical", content: body.content.trim(), source: body.source?.trim() || "manual", confidence, includeInContextPacks: false, approvalState: ApprovalState.pending, evidenceStrength: EvidenceStrength.medium, verificationState: VerificationState.unverified, metadataJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], includeInContextPacks: [] }), trustJson: toJson(trust("database knowledge create", confidence, ApprovalState.pending, now)), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }}); + return toKnowledgeDto(row); +}; + +export const updateKnowledgeItem = async (projectId: string, knowledgeId: string, body: KnowledgeMutationRequestDto): Promise => { + const project = await getProject(projectId); + const current = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }}); + if (!current) return undefined; + const meta = metadata(current.metadataJson); + const confidence = body.confidence ?? current.confidence; + const row = await prisma.knowledgeItem.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.category !== undefined ? { category: body.category.trim() } : {}), ...(body.content !== undefined ? { content: body.content.trim() } : {}), ...(body.source !== undefined ? { source: body.source.trim() } : {}), ...(body.confidence !== undefined ? { confidence } : {}), ...(body.linkedFeatureIds !== undefined ? { metadataJson: toJson({ ...meta, linkedFeatureIds: body.linkedFeatureIds }) } : {}), updatedBy: "api", trustJson: toJson(trust("database knowledge update", confidence, current.approvalState, new Date())) }, include: { project: { select: { slug: true } } }}); + return toKnowledgeDto(row); +}; + +export const includeKnowledgeInContextPack = async (projectId: string, knowledgeId: string, contextPackId: string): Promise => { + const project = await getProject(projectId); + const contextPack = await prisma.contextPack.findFirst({ where: { projectId: project.id, OR: [{ id: contextPackId }, { slug: contextPackId }, { readableId: contextPackId }], deletedAt: null }}); + if (!contextPack) throw new Error("CONTEXT_PACK_NOT_FOUND"); + const current = await prisma.knowledgeItem.findFirst({ where: { projectId: project.id, OR: [{ id: knowledgeId }, { slug: knowledgeId }, { readableId: knowledgeId }], deletedAt: null }}); + if (!current) return undefined; + const meta = metadata(current.metadataJson); + const list = new Set(toStringArray(meta.includeInContextPacks)); + list.add(contextPack.slug ?? contextPack.readableId ?? contextPack.id); + const row = await prisma.knowledgeItem.update({ where: { id: current.id }, data: { includeInContextPacks: true, metadataJson: toJson({ ...meta, includeInContextPacks: [...list] }), updatedBy: "api" }, include: { project: { select: { slug: true } } }}); + return toKnowledgeDto(row); +}; + +export const listMemoryRulesForProject = async (projectId: string, filters: MemoryRuleListFilters = {}): Promise => { + const project = await getProject(projectId); + const rows = await prisma.memoryRule.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }}); + return rows.map(toMemoryRuleDto).filter((item) => + (!filters.scope?.length || filters.scope.includes(item.scope)) && + (!filters.category?.length || filters.category.includes(item.category)) && + (!filters.severity?.length || filters.severity.includes(item.severity)) && + (filters.includeInContextPacks === undefined || item.includeInContextPacksDefault === filters.includeInContextPacks) && + (!filters.featureId || item.linkedFeatureIds.includes(filters.featureId)) && + filterSearch(`${item.title} ${item.rule} ${item.category}`, filters.search) + ); +}; + +export const getMemoryRuleBySlugOrId = async (projectId: string, ruleId: string): Promise => { + const project = await getProject(projectId); + const row = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }, include: { project: { select: { slug: true } } }}); + return row ? toMemoryRuleDto(row) : undefined; +}; + +export const createMemoryRuleItem = async (projectId: string, body: MemoryRuleMutationRequestDto & { rule: string }): Promise => { + const project = await getProject(projectId); + const slug = `R-${project.slug.toUpperCase()}-${Date.now().toString(36)}`; + const confidence = 78; + const now = new Date(); + const row = await prisma.memoryRule.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title?.trim() || body.rule.trim().slice(0, 60), rule: body.rule.trim(), scope: body.scope ?? "global", category: body.category ?? "Technical", severity: body.severity ?? "important", includeInContextPacks: body.includeInContextPacks ?? false, confidence, approvalState: ApprovalState.pending, evidenceStrength: EvidenceStrength.medium, verificationState: VerificationState.unverified, provenanceJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], includeInContextPacks: [] }), trustJson: toJson(trust("database memory rule create", confidence, ApprovalState.pending, now)), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }}); + return toMemoryRuleDto(row); +}; + +export const updateMemoryRuleItem = async (projectId: string, ruleId: string, body: MemoryRuleMutationRequestDto): Promise => { + const project = await getProject(projectId); + const current = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }}); + if (!current) return undefined; + const meta = metadata(current.provenanceJson); + const row = await prisma.memoryRule.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.rule !== undefined ? { rule: body.rule.trim() } : {}), ...(body.scope !== undefined ? { scope: body.scope } : {}), ...(body.category !== undefined ? { category: body.category } : {}), ...(body.severity !== undefined ? { severity: body.severity } : {}), ...(body.includeInContextPacks !== undefined ? { includeInContextPacks: body.includeInContextPacks } : {}), ...(body.linkedFeatureIds !== undefined ? { provenanceJson: toJson({ ...meta, linkedFeatureIds: body.linkedFeatureIds }) } : {}), updatedBy: "api", trustJson: toJson(trust("database memory rule update", current.confidence, current.approvalState, new Date())) }, include: { project: { select: { slug: true } } }}); + return toMemoryRuleDto(row); +}; + +export const includeMemoryRuleInContextPack = async (projectId: string, ruleId: string, contextPackId: string): Promise => { + const project = await getProject(projectId); + const contextPack = await prisma.contextPack.findFirst({ where: { projectId: project.id, OR: [{ id: contextPackId }, { slug: contextPackId }, { readableId: contextPackId }], deletedAt: null }}); + if (!contextPack) throw new Error("CONTEXT_PACK_NOT_FOUND"); + const current = await prisma.memoryRule.findFirst({ where: { projectId: project.id, OR: [{ id: ruleId }, { slug: ruleId }, { readableId: ruleId }], deletedAt: null }}); + if (!current) return undefined; + const meta = metadata(current.provenanceJson); + const list = new Set(toStringArray(meta.includeInContextPacks)); + list.add(contextPack.slug ?? contextPack.readableId ?? contextPack.id); + const row = await prisma.memoryRule.update({ where: { id: current.id }, data: { includeInContextPacks: true, provenanceJson: toJson({ ...meta, includeInContextPacks: [...list] }), updatedBy: "api" }, include: { project: { select: { slug: true } } }}); + return toMemoryRuleDto(row); +}; + +export const listDocumentationForProject = async (projectId: string, filters: DocumentationListFilters = {}): Promise => { + const project = await getProject(projectId); + const rows = await prisma.documentationItem.findMany({ where: { projectId: project.id, deletedAt: null }, include: { project: { select: { slug: true } } }, orderBy: { updatedAt: "desc" }}); + return rows.map(toDocumentationDto).filter((item) => + (!filters.kind?.length || filters.kind.includes(item.kind)) && + (!filters.status?.length || filters.status.includes(item.status)) && + (!filters.approvalState?.length || filters.approvalState.includes(item.approvalState)) && + filterSearch(`${item.title} ${item.kind} ${item.content ?? ""}`, filters.search) + ); +}; + +export const getDocumentationBySlugOrId = async (projectId: string, docId: string): Promise => { + const project = await getProject(projectId); + const row = await prisma.documentationItem.findFirst({ where: { projectId: project.id, OR: [{ id: docId }, { slug: docId }, { readableId: docId }], deletedAt: null }, include: { project: { select: { slug: true } } }}); + return row ? toDocumentationDto(row) : undefined; +}; + +export const createDocumentationItem = async (projectId: string, body: { title: string; kind?: string; status?: string; linkedFeatureIds?: string[]; content?: string; approvalState?: ApprovalStateInput; primaryContextPackId?: string; selectedContextPackIds?: string[]; contextSelectionSummary?: string }): Promise => { + const project = await getProject(projectId); + const slug = `doc-${project.slug}-${Date.now().toString(36)}`; + const now = new Date(); + const row = await prisma.documentationItem.create({ data: { projectId: project.id, slug, readableId: slug, title: body.title.trim(), kind: body.kind?.trim() || "current state report", status: body.status?.trim() || "draft", approvalState: body.approvalState ?? ApprovalState.pending, content: body.content?.trim(), metadataJson: toJson({ linkedFeatureIds: body.linkedFeatureIds ?? [], lastUpdated: now.toISOString(), primaryContextPackId: body.primaryContextPackId, selectedContextPackIds: body.selectedContextPackIds ?? [], contextSelectionSummary: body.contextSelectionSummary }), trustJson: toJson(trust("database documentation create", 75, body.approvalState ?? ApprovalState.pending, now)), provenanceJson: toJson({ source: "database_api" }), createdBy: "api", updatedBy: "api", sourceType: "database_api" }, include: { project: { select: { slug: true } } }}); + return toDocumentationDto(row); +}; + +export const updateDocumentationItem = async (projectId: string, docId: string, body: { title?: string; kind?: string; status?: string; linkedFeatureIds?: string[]; content?: string; approvalState?: ApprovalStateInput; primaryContextPackId?: string; selectedContextPackIds?: string[]; contextSelectionSummary?: string }): Promise => { + const project = await getProject(projectId); + const current = await prisma.documentationItem.findFirst({ where: { projectId: project.id, OR: [{ id: docId }, { slug: docId }, { readableId: docId }], deletedAt: null }}); + if (!current) return undefined; + const meta = metadata(current.metadataJson); + const row = await prisma.documentationItem.update({ where: { id: current.id }, data: { ...(body.title !== undefined ? { title: body.title.trim() } : {}), ...(body.kind !== undefined ? { kind: body.kind.trim() } : {}), ...(body.status !== undefined ? { status: body.status.trim() } : {}), ...(body.approvalState !== undefined ? { approvalState: body.approvalState } : {}), ...(body.content !== undefined ? { content: body.content.trim() } : {}), metadataJson: toJson({ + ...meta, + ...(body.linkedFeatureIds !== undefined ? { linkedFeatureIds: body.linkedFeatureIds } : {}), + ...(body.primaryContextPackId !== undefined ? { primaryContextPackId: body.primaryContextPackId } : {}), + ...(body.selectedContextPackIds !== undefined ? { selectedContextPackIds: body.selectedContextPackIds } : {}), + ...(body.contextSelectionSummary !== undefined ? { contextSelectionSummary: body.contextSelectionSummary } : {}), + lastUpdated: new Date().toISOString(), + }), updatedBy: "api" }, include: { project: { select: { slug: true } } }}); + return toDocumentationDto(row); +}; diff --git a/backend/src/repositories/projectSetupState.ts b/backend/src/repositories/projectSetupState.ts new file mode 100644 index 0000000..c4c20c9 --- /dev/null +++ b/backend/src/repositories/projectSetupState.ts @@ -0,0 +1,50 @@ +const persistedSetupKeys = [ + "activeContextPackId", + "featureGroups", + "memoryRules", + "promptTemplates", + "constraints", + "principles", + "firstRun", + "starterContextPacks", + "starterDocumentationItems", + "starterReviewItems", + "starterOperatingLog", + "brainstorms", + "codexSkills", + "disabledCodexSkillIds", + "workspaceState", +] as const; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const sanitizeProjectSetupState = (value: unknown): Record => { + if (!isRecord(value)) return {}; + + return Object.fromEntries( + persistedSetupKeys.flatMap((key) => + Object.prototype.hasOwnProperty.call(value, key) ? [[key, value[key]]] : [], + ), + ); +}; + +export const projectSetupWorkspaceState = (value: unknown): Record | undefined => { + const setup = sanitizeProjectSetupState(value); + return isRecord(setup.workspaceState) ? setup.workspaceState : undefined; +}; + +export const projectSetupActiveContextPackId = (value: unknown): string | undefined => { + const setup = sanitizeProjectSetupState(value); + return typeof setup.activeContextPackId === "string" && setup.activeContextPackId.trim() + ? setup.activeContextPackId + : undefined; +}; + +export const mergeProjectSetupState = >( + setupState: unknown, + canonical: T, +): T => ({ + ...sanitizeProjectSetupState(setupState), + ...canonical, +}) as T; diff --git a/backend/src/repositories/projectsRepository.ts b/backend/src/repositories/projectsRepository.ts new file mode 100644 index 0000000..3b1edb7 --- /dev/null +++ b/backend/src/repositories/projectsRepository.ts @@ -0,0 +1,514 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { + CurrentStateSummaryDto, + FocusAreaDto, + HealthSummaryDto, + ProjectDetailDto, + ProjectDna, + ProjectStatus, + ProjectSummaryDto, + CreateProjectRequestDto, + UpdateActiveContextPackRequestDto, + UpdateProjectRequestDto, + WorkspaceReadDto, + WorkspaceStateDto, +} from "../types/api.js"; +import { + mergeProjectSetupState, + projectSetupActiveContextPackId, + projectSetupWorkspaceState, + sanitizeProjectSetupState, +} from "./projectSetupState.js"; +import { humanAttentionReviewWhere } from "./reviewAttentionPolicy.js"; +import { projectHealth } from "../services/projectHealthSemantics.js"; + +const projectRelationsInclude = { + dna: true, + workspaceState: true, + giteaRepositories: { select: { id: true, syncStatus: true, lastSyncAt: true } }, + sourceItems: { orderBy: { updatedAt: "desc" as const } }, + repositoryFindings: { where: { status: { notIn: ["resolved", "suppressed"] } }, select: { severity: true, status: true, lastSeenAt: true } }, + _count: { + select: { + features: { where: { deletedAt: null } }, + contextPacks: { where: { deletedAt: null } }, + deliveryPlans: true, + documentationItems: { where: { deletedAt: null } }, + knowledgeItems: { where: { deletedAt: null } }, + reviewQueueItems: { where: humanAttentionReviewWhere }, + initiatives: { where: { deletedAt: null, reviewItemId: null, approvalState: { in: ["pending", "blocked"] } } }, + }, + }, +} as const satisfies Prisma.ProjectInclude; + +type ProjectWithP2Relations = Prisma.ProjectGetPayload<{ include: typeof projectRelationsInclude }>; + +type WorkspaceJsonPayload = { + workspaceState?: WorkspaceStateDto; + focusAreas?: FocusAreaDto[]; + activeFocusAreaId?: string; + activeContextPackId?: string; + currentStateSummary?: CurrentStateSummaryDto; + healthSummary?: HealthSummaryDto; + setupState?: Record; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const slugify = (value: string): string => { + const slug = value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return slug || "project"; +}; + +const toStringArray = (value: Prisma.JsonValue): string[] => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; + +const isDisposableProject = (project: Pick) => + /\b(smoke|disposable|test project|proof project)\b/i.test(`${project.name} ${project.description}`); + +const toDna = ( + dna: NonNullable, +): ProjectDna => ({ + vision: dna.vision, + goals: toStringArray(dna.goalsJson), + nonGoals: toStringArray(dna.nonGoalsJson), + targetAudience: dna.targetAudience, + principles: toStringArray(dna.principlesJson), + successMetrics: Array.isArray(dna.metricsJson) + ? (dna.metricsJson as ProjectDna["successMetrics"]) + : [], + maturityStage: dna.maturityStage as ProjectDna["maturityStage"], + revision: dna.revision, + updatedAt: dna.updatedAt.toISOString(), +}); + +const projectLastSync = (project: ProjectWithP2Relations) => { + const timestamps = [ + project.updatedAt.getTime(), + ...project.giteaRepositories.flatMap((repository) => repository.lastSyncAt ? [repository.lastSyncAt.getTime()] : []), + ...project.repositoryFindings.map((finding) => finding.lastSeenAt.getTime()), + ]; + return new Date(Math.max(...timestamps)).toISOString(); +}; + +const toProjectSummary = ( + project: ProjectWithP2Relations, +): ProjectSummaryDto => { + const health = projectHealth(project); + const workspacePayload = project.workspaceState ? toWorkspacePayload(project.workspaceState.notesJson) : {}; + const configuredSkills = Array.isArray(workspacePayload.setupState?.codexSkills) + ? workspacePayload.setupState.codexSkills + : []; + return { + id: project.slug, + name: project.name, + description: project.description, + health: health.score, + healthStatus: health.healthStatus, + lastSync: projectLastSync(project), + domain: project.domain, + status: project.status as ProjectStatus, + createdAt: project.createdAt.toISOString(), + updatedAt: project.updatedAt.toISOString(), + activeContextPackId: project.activeContextPackId ?? undefined, + resourceCounts: { + repositories: project.giteaRepositories.length, + readyRepositories: project.giteaRepositories.filter((repository) => repository.syncStatus === "ready").length, + sources: project.sourceItems.filter((source) => source.status !== "archived").length, + documentation: project._count.documentationItems, + pendingReviews: project._count.reviewQueueItems, + attentionItems: project._count.reviewQueueItems + project._count.initiatives, + activeFindings: health.activeFindingCount, + criticalFindings: health.criticalCount, + warningFindings: health.warningCount, + regressedFindings: health.regressedCount, + contextPacks: project._count.contextPacks, + features: project._count.features, + deliveryPlans: project._count.deliveryPlans, + knowledge: project._count.knowledgeItems, + enabledSkills: configuredSkills.filter((skill) => isRecord(skill) && skill.enabled === true).length, + }, + }; +}; + +const toWorkspacePayload = ( + value: Prisma.JsonValue, +): WorkspaceJsonPayload => { + if (!isRecord(value)) return {}; + + return value as WorkspaceJsonPayload; +}; + +const toWorkspaceState = ( + project: ProjectWithP2Relations, +): WorkspaceStateDto | undefined => { + if (!project.workspaceState) return undefined; + + const payload = toWorkspacePayload(project.workspaceState.notesJson); + if (payload.workspaceState) return payload.workspaceState; + + return { + projectId: project.slug, + activeContextPackId: + project.workspaceState.activeContextPackId ?? undefined, + primaryObjective: project.workspaceState.primaryObjective, + focusAreas: [], + openTasksCount: 0, + openBugCount: 0, + activeSection: project.workspaceState + .activeSection as WorkspaceStateDto["activeSection"], + updatedAt: project.workspaceState.updatedAt.toISOString(), + notes: [], + }; +}; + +const toProjectDetail = ( + project: ProjectWithP2Relations, +): ProjectDetailDto | undefined => { + if (!project.dna) return undefined; + + const workspacePayload = project.workspaceState + ? toWorkspacePayload(project.workspaceState.notesJson) + : {}; + + return mergeProjectSetupState(workspacePayload.setupState, { + ...toProjectSummary(project), + dna: toDna(project.dna), + constraints: Array.isArray(workspacePayload.setupState?.constraints) + ? workspacePayload.setupState.constraints as ProjectDetailDto["constraints"] + : [], + principles: Array.isArray(workspacePayload.setupState?.principles) + ? workspacePayload.setupState.principles as ProjectDetailDto["principles"] + : [], + initiatives: [], + featureGroups: Array.isArray(workspacePayload.setupState?.featureGroups) + ? workspacePayload.setupState.featureGroups as ProjectDetailDto["featureGroups"] + : [], + memoryRules: Array.isArray(workspacePayload.setupState?.memoryRules) + ? workspacePayload.setupState.memoryRules as ProjectDetailDto["memoryRules"] + : [], + promptTemplates: Array.isArray(workspacePayload.setupState?.promptTemplates) + ? workspacePayload.setupState.promptTemplates as ProjectDetailDto["promptTemplates"] + : [], + sourceLibrary: project.sourceItems.map((source) => ({ + id: source.id, + projectId: project.slug, + title: source.title, + kind: source.kind, + inputMode: source.inputMode, + originalName: source.path ?? undefined, + url: source.url ?? undefined, + text: source.content, + summary: source.summary, + status: source.status, + suggestedDocumentationTargets: [], + suggestedContextPackIds: [], + extractedAssetIds: [], + createdAt: source.createdAt.toISOString(), + updatedAt: source.updatedAt.toISOString(), + repositoryPath: source.path ?? undefined, + repositoryBranch: source.branch ?? undefined, + commitSha: source.commitSha ?? undefined, + lastSyncedAt: source.lastSyncedAt?.toISOString(), + attribution: { createdBy: "imported", sourceRun: source.commitSha ?? undefined, approvalState: "approved" }, + })), + workspaceState: toWorkspaceState(project), + } as ProjectDetailDto & Record); +}; + +const defaultDnaForProject = ( + input: CreateProjectRequestDto, +): Omit => ({ + vision: input.dna?.vision || input.primaryGoal || input.description, + goals: input.dna?.goals ?? (input.primaryGoal ? [input.primaryGoal] : []), + nonGoals: input.dna?.nonGoals ?? [], + targetAudience: input.dna?.targetAudience || input.targetAudience || "Delivery teams", + principles: input.dna?.principles ?? [], + successMetrics: input.dna?.successMetrics ?? [], + maturityStage: input.dna?.maturityStage ?? "Discovery", + revision: input.dna?.revision ?? 1, +}); + +const defaultWorkspacePayload = ( + projectSlug: string, + input: CreateProjectRequestDto, +): WorkspaceJsonPayload => { + const setupWorkspace = projectSetupWorkspaceState(input.setupState); + const workspaceState = { + ...setupWorkspace, + projectId: projectSlug, + primaryObjective: typeof setupWorkspace?.primaryObjective === "string" + ? setupWorkspace.primaryObjective + : input.primaryGoal || input.description, + focusAreas: Array.isArray(setupWorkspace?.focusAreas) ? setupWorkspace.focusAreas : [], + openTasksCount: typeof setupWorkspace?.openTasksCount === "number" ? setupWorkspace.openTasksCount : 0, + openBugCount: typeof setupWorkspace?.openBugCount === "number" ? setupWorkspace.openBugCount : 0, + activeSection: typeof setupWorkspace?.activeSection === "string" ? setupWorkspace.activeSection : "workspace", + updatedAt: new Date().toISOString(), + notes: Array.isArray(setupWorkspace?.notes) ? setupWorkspace.notes : [], + ...(projectSetupActiveContextPackId(input.setupState) + ? { activeContextPackId: projectSetupActiveContextPackId(input.setupState) } + : {}), + } as WorkspaceStateDto; + + return { + workspaceState, + focusAreas: workspaceState.focusAreas, + healthSummary: { + health: 100, + healthStatus: "healthy", + openTasksCount: workspaceState.openTasksCount, + openBugCount: workspaceState.openBugCount, + riskCount: 0, + pendingReviewCount: 0, + }, + setupState: sanitizeProjectSetupState(input.setupState), + }; +}; + +const findProjectRecordBySlugOrId = (projectIdOrSlug: string) => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + include: projectRelationsInclude, + }); + +export const listProjects = async (): Promise => { + const projects = await prisma.project.findMany({ + where: { + deletedAt: null, + }, + orderBy: { + updatedAt: "desc", + }, + include: projectRelationsInclude, + }); + + return projects.filter((project) => !isDisposableProject(project)).map(toProjectSummary); +}; + +export const createProject = async ( + input: CreateProjectRequestDto, +): Promise => { + const slug = slugify(input.id || input.name); + const existing = await prisma.project.findFirst({ + where: { + slug, + deletedAt: null, + }, + }); + + if (existing) { + throw new Error("PROJECT_SLUG_ALREADY_EXISTS"); + } + + const dna = defaultDnaForProject(input); + const workspacePayload = defaultWorkspacePayload(slug, input); + const project = await prisma.project.create({ + data: { + slug, + readableId: slug, + name: input.name.trim(), + description: input.description.trim(), + domain: input.domain?.trim() || "general", + status: "active", + health: 100, + healthStatus: "healthy", + activeContextPackId: projectSetupActiveContextPackId(input.setupState), + createdBy: "frontend", + updatedBy: "frontend", + sourceType: "project_setup", + dna: { + create: { + vision: dna.vision, + goalsJson: dna.goals, + nonGoalsJson: dna.nonGoals, + targetAudience: dna.targetAudience, + principlesJson: dna.principles, + metricsJson: dna.successMetrics, + maturityStage: dna.maturityStage, + revision: dna.revision, + createdBy: "frontend", + updatedBy: "frontend", + sourceType: "project_setup", + }, + }, + workspaceState: { + create: { + primaryObjective: workspacePayload.workspaceState?.primaryObjective ?? input.primaryGoal ?? input.description.trim(), + activeContextPackId: projectSetupActiveContextPackId(input.setupState), + activeSection: workspacePayload.workspaceState?.activeSection ?? "workspace", + notesJson: workspacePayload as Prisma.InputJsonValue, + createdBy: "frontend", + updatedBy: "frontend", + sourceType: "project_setup", + }, + }, + }, + include: projectRelationsInclude, + }); + + const detail = toProjectDetail(project); + if (!detail) { + throw new Error("PROJECT_CREATE_FAILED"); + } + + return detail; +}; + +export const updateProject = async ( + projectIdOrSlug: string, + input: UpdateProjectRequestDto, +): Promise => { + const existing = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!existing) return undefined; + + const project = await prisma.project.update({ + where: { + id: existing.id, + }, + data: { + ...(input.name !== undefined ? { name: input.name.trim() } : {}), + ...(input.description !== undefined ? { description: input.description.trim() } : {}), + ...(input.domain !== undefined ? { domain: input.domain.trim() || "general" } : {}), + ...(input.status !== undefined ? { status: input.status } : {}), + updatedBy: "frontend", + }, + include: projectRelationsInclude, + }); + + return toProjectDetail(project); +}; + +export const updateActiveContextPack = async ( + projectIdOrSlug: string, + input: UpdateActiveContextPackRequestDto, +): Promise => { + const existing = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!existing?.dna) return undefined; + + const currentPayload = existing.workspaceState + ? toWorkspacePayload(existing.workspaceState.notesJson) + : {}; + const currentWorkspace = toWorkspaceState(existing); + const updatedAt = new Date().toISOString(); + const nextWorkspaceState: WorkspaceStateDto = { + projectId: existing.slug, + primaryObjective: + currentWorkspace?.primaryObjective ?? + existing.workspaceState?.primaryObjective ?? + existing.description, + focusAreas: currentWorkspace?.focusAreas ?? currentPayload.focusAreas ?? [], + openTasksCount: currentWorkspace?.openTasksCount ?? 0, + openBugCount: currentWorkspace?.openBugCount ?? 0, + activeSection: currentWorkspace?.activeSection ?? "context-packs", + updatedAt, + notes: currentWorkspace?.notes ?? [], + activeContextPackId: input.contextPackId, + }; + + const payload: WorkspaceJsonPayload = { + ...currentPayload, + workspaceState: nextWorkspaceState, + focusAreas: currentPayload.focusAreas ?? nextWorkspaceState.focusAreas, + activeContextPackId: input.contextPackId, + }; + + await prisma.project.update({ + where: { + id: existing.id, + }, + data: { + activeContextPackId: input.contextPackId, + updatedBy: "frontend", + workspaceState: { + upsert: { + create: { + activeContextPackId: input.contextPackId, + primaryObjective: nextWorkspaceState.primaryObjective, + activeSection: nextWorkspaceState.activeSection, + notesJson: payload as Prisma.InputJsonValue, + createdBy: "frontend", + updatedBy: "frontend", + sourceType: "workspace_update", + }, + update: { + activeContextPackId: input.contextPackId, + primaryObjective: nextWorkspaceState.primaryObjective, + activeSection: nextWorkspaceState.activeSection, + notesJson: payload as Prisma.InputJsonValue, + updatedBy: "frontend", + sourceType: "workspace_update", + }, + }, + }, + }, + }); + + return getProjectWorkspace(existing.slug); +}; + +export const getProjectBySlugOrId = async ( + projectIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project) return undefined; + + return toProjectDetail(project); +}; + +export const getProjectDNA = async ( + projectIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project?.dna) return undefined; + + return toDna(project.dna); +}; + +export const getProjectWorkspace = async ( + projectIdOrSlug: string, +): Promise => { + const project = await findProjectRecordBySlugOrId(projectIdOrSlug); + if (!project?.dna) return undefined; + + const workspaceState = toWorkspaceState(project); + const payload = project.workspaceState + ? toWorkspacePayload(project.workspaceState.notesJson) + : {}; + const activeContextPackId = + workspaceState?.activeContextPackId ?? + project.activeContextPackId ?? + undefined; + const operationalHealth = projectHealth(project); + + return { + project: toProjectSummary(project), + dna: toDna(project.dna), + activeFocusAreaId: payload.activeFocusAreaId, + activeContextPackId, + focusAreas: payload.focusAreas ?? workspaceState?.focusAreas ?? [], + workspaceState, + currentStateSummary: payload.currentStateSummary, + healthSummary: { + ...(payload.healthSummary ?? {}), + health: operationalHealth.score, + healthStatus: operationalHealth.healthStatus, + openTasksCount: workspaceState?.openTasksCount ?? 0, + openBugCount: workspaceState?.openBugCount ?? 0, + riskCount: payload.healthSummary?.riskCount ?? 0, + pendingReviewCount: payload.healthSummary?.pendingReviewCount ?? 0, + }, + }; +}; diff --git a/backend/src/repositories/releaseEvidenceRepository.ts b/backend/src/repositories/releaseEvidenceRepository.ts new file mode 100644 index 0000000..a12a5f5 --- /dev/null +++ b/backend/src/repositories/releaseEvidenceRepository.ts @@ -0,0 +1,211 @@ +import { + ApprovalState, + EntityType, + EvidenceStrength, + VerificationState, + type Prisma, +} from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { + ApprovalState as ApprovalStateDto, + CreateReleaseEvidenceRecordRequestDto, + EvidenceStrength as EvidenceStrengthDto, + ReleaseEvidenceKind, + ReleaseEvidenceListFilters, + ReleaseEvidenceRecordDto, + ReleaseEvidenceStatus, + VerificationState as VerificationStateDto, +} from "../types/api.js"; + +type ProjectRecord = { id: string; slug: string }; +type ReleaseEvidenceRecord = Prisma.ReleaseEvidenceRecordGetPayload>; + +const findProjectRecord = (projectIdOrSlug: string): Promise => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + select: { id: true, slug: true }, + }); + +const toEvidenceStrengthEnum = ( + value: EvidenceStrengthDto | string | null | undefined, +): EvidenceStrength => { + if (value === "verified") return EvidenceStrength.verified; + if (value === "strong") return EvidenceStrength.strong; + if (value === "medium") return EvidenceStrength.medium; + return EvidenceStrength.weak; +}; + +const toVerificationStateEnum = ( + value: VerificationStateDto | string | null | undefined, +): VerificationState => { + if (value === "blocked") return VerificationState.blocked; + if (value === "needs evidence" || value === "needs_evidence") return VerificationState.needs_evidence; + if (value === "unverified") return VerificationState.unverified; + return VerificationState.verified; +}; + +const toVerificationStateDto = (value: VerificationState): VerificationStateDto => + value === VerificationState.needs_evidence ? "needs evidence" : value; + +const toDto = (record: ReleaseEvidenceRecord, projectSlug: string): ReleaseEvidenceRecordDto => ({ + id: record.slug ?? record.readableId ?? record.id, + projectId: projectSlug, + capturedAt: record.capturedAt.toISOString(), + kind: record.kind as ReleaseEvidenceKind, + title: record.title, + summary: record.summary, + status: record.status as ReleaseEvidenceStatus, + ...(record.command ? { command: record.command } : {}), + ...(record.outputText ? { outputText: record.outputText } : {}), + ...(record.route ? { route: record.route } : {}), + source: record.source, + confidence: record.confidence, + approvalState: record.approvalState as ApprovalStateDto, + evidenceStrength: record.evidenceStrength, + verificationState: toVerificationStateDto(record.verificationState), + createdBy: record.createdBy, + ...(record.verifiedAt ? { verifiedAt: record.verifiedAt.toISOString() } : {}), + ...(record.verifiedBy ? { verifiedBy: record.verifiedBy } : {}), + ...(record.metadataJson && typeof record.metadataJson === "object" && !Array.isArray(record.metadataJson) + ? { metadata: record.metadataJson as Record } + : {}), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), +}); + +const paginateMeta = ( + items: T[], + total: number, + page: number, + pageSize: number, + sort?: string, + filters?: Record, +) => ({ + items, + total, + page, + pageSize, + hasNextPage: page < Math.ceil(total / pageSize), + hasPreviousPage: page > 1, + ...(sort ? { sort } : {}), + ...(filters ? { filters: filters as Record } : {}), +}); + +export const listReleaseEvidenceRecordsFromDatabase = async ( + projectIdOrSlug: string, + filters: ReleaseEvidenceListFilters, + options: { page: number; pageSize: number; sort?: string }, +) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const where: Prisma.ReleaseEvidenceRecordWhereInput = { + projectId: project.id, + deletedAt: null, + ...(filters.kind?.length ? { kind: { in: filters.kind } } : {}), + ...(filters.status?.length ? { status: { in: filters.status } } : {}), + ...(filters.evidenceStrength?.length + ? { evidenceStrength: { in: filters.evidenceStrength.map(toEvidenceStrengthEnum) } } + : {}), + ...(filters.verificationState?.length + ? { verificationState: { in: filters.verificationState.map(toVerificationStateEnum) } } + : {}), + ...(filters.source?.length ? { source: { in: filters.source } } : {}), + ...(filters.createdBy?.length ? { createdBy: { in: filters.createdBy } } : {}), + }; + const total = await prisma.releaseEvidenceRecord.count({ where }); + const records = await prisma.releaseEvidenceRecord.findMany({ + where, + orderBy: { capturedAt: options.sort?.endsWith(":asc") ? "asc" : "desc" }, + skip: (options.page - 1) * options.pageSize, + take: options.pageSize, + }); + return paginateMeta( + records.map((record) => toDto(record, project.slug)), + total, + options.page, + options.pageSize, + options.sort, + filters, + ); +}; + +export const getReleaseEvidenceRecordFromDatabase = async ( + projectIdOrSlug: string, + releaseEvidenceId: string, +) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const record = await prisma.releaseEvidenceRecord.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [ + { id: releaseEvidenceId }, + { slug: releaseEvidenceId }, + { readableId: releaseEvidenceId }, + ], + }, + }); + if (!record) return "RELEASE_EVIDENCE_NOT_FOUND" as const; + return toDto(record, project.slug); +}; + +export const createReleaseEvidenceRecordInDatabase = async ( + projectIdOrSlug: string, + body: CreateReleaseEvidenceRecordRequestDto, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const capturedAt = new Date(); + const slug = `rel-${project.slug}-${capturedAt.getTime()}-${Math.random().toString(36).slice(2, 8)}`; + const verificationState = toVerificationStateEnum(body.verificationState ?? "verified"); + const evidenceStrength = toEvidenceStrengthEnum(body.evidenceStrength ?? "verified"); + const verifiedAt = verificationState === VerificationState.verified ? capturedAt : undefined; + const record = await prisma.releaseEvidenceRecord.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + capturedAt, + kind: body.kind, + title: body.title, + summary: body.summary, + status: body.status ?? "captured", + command: body.command, + outputText: body.outputText, + route: body.route, + source: body.source ?? "runtime_readiness", + confidence: body.confidence ?? 100, + approvalState: ApprovalState.approved, + evidenceStrength, + verificationState, + metadataJson: (body.metadata ?? {}) as Prisma.InputJsonValue, + provenanceJson: { + createdFrom: "release-evidence-api", + sourceType: "runtime_readiness", + }, + createdBy: body.createdBy ?? "api", + verifiedAt, + verifiedBy: body.verifiedBy ?? (verifiedAt ? "api" : undefined), + }, + }); + await prisma.activityEvent.create({ + data: { + projectId: project.id, + entityType: EntityType.release_evidence_record, + entityId: record.id, + eventType: "release_evidence_captured", + actor: body.createdBy ?? "api", + payloadJson: { + kind: record.kind, + title: record.title, + status: record.status, + route: record.route, + }, + }, + }); + return toDto(record, project.slug); +}; diff --git a/backend/src/repositories/reviewAttentionPolicy.ts b/backend/src/repositories/reviewAttentionPolicy.ts new file mode 100644 index 0000000..78308fa --- /dev/null +++ b/backend/src/repositories/reviewAttentionPolicy.ts @@ -0,0 +1,22 @@ +import { ReviewStatus, type Prisma, type ReviewQueueItem } from "@prisma/client"; + +type ReviewAttentionRecord = Pick; + +const isSyntheticSource = (value: string | null | undefined) => value?.toLowerCase().includes("smoke") ?? false; + +/** Pending test and seed artefacts are evidence, not owner decisions. */ +export const reviewRequiresHumanAttention = (record: ReviewAttentionRecord) => + record.status === ReviewStatus.pending && + record.createdBy !== "seed" && + !isSyntheticSource(record.source) && + !isSyntheticSource(record.sourceType); + +export const humanAttentionReviewWhere = { + deletedAt: null, + status: ReviewStatus.pending, + NOT: [ + { createdBy: "seed" }, + { source: { contains: "smoke", mode: "insensitive" } }, + { sourceType: { contains: "smoke", mode: "insensitive" } }, + ], +} satisfies Prisma.ReviewQueueItemWhereInput; diff --git a/backend/src/repositories/reviewQueueRepository.ts b/backend/src/repositories/reviewQueueRepository.ts new file mode 100644 index 0000000..e1a8c1a --- /dev/null +++ b/backend/src/repositories/reviewQueueRepository.ts @@ -0,0 +1,448 @@ +import type { Prisma } from "@prisma/client"; +import { ApprovalState, EntityType, EvidenceStrength, ReviewStatus, VerificationState } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { + CaptureItemDto, + ReviewMutationResponseDto, + ReviewQueueConvertTarget, + ReviewQueueItemDto, + ReviewQueueListFilters, + ReviewQueuePriority, + ReviewQueueStatus, + UpdateReviewQueueItemRequestDto, +} from "../types/api.js"; +import { reviewRequiresHumanAttention } from "./reviewAttentionPolicy.js"; + +type ReviewRecord = Prisma.ReviewQueueItemGetPayload>; +type ProjectRecord = { id: string; slug: string }; + +const findProjectRecord = (projectIdOrSlug: string): Promise => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + select: { id: true, slug: true }, + }); + +const mapEvidenceStrength = (value: string): EvidenceStrength => { + if (value === "verified") return EvidenceStrength.verified; + if (value === "strong") return EvidenceStrength.strong; + if (value === "medium") return EvidenceStrength.medium; + return EvidenceStrength.weak; +}; + +const mapVerificationState = (value: string): VerificationState => { + if (value === "verified") return VerificationState.verified; + if (value === "blocked") return VerificationState.blocked; + if (value === "needs evidence") return VerificationState.needs_evidence; + return VerificationState.unverified; +}; + +const mapReviewStatus = (value: ReviewQueueStatus): ReviewStatus => { + if (value === "accepted") return ReviewStatus.accepted; + if (value === "rejected") return ReviewStatus.rejected; + if (value === "merged") return ReviewStatus.merged; + if (value === "converted") return ReviewStatus.converted; + return ReviewStatus.pending; +}; + +const toReviewStatus = (value: ReviewStatus): ReviewQueueStatus => { + if (value === ReviewStatus.accepted) return "accepted"; + if (value === ReviewStatus.rejected) return "rejected"; + if (value === ReviewStatus.merged) return "merged"; + if (value === ReviewStatus.converted) return "converted"; + return "pending"; +}; + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; +const toStringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + +const toBlueprintReviewMetadata = (record: ReviewRecord): ReviewQueueItemDto["blueprint"] | undefined => { + const payload = isRecord(record.payloadJson) ? record.payloadJson : {}; + const provenance = isRecord(record.provenanceJson) ? record.provenanceJson : {}; + const contextSelection = isRecord(payload.contextSelection) ? payload.contextSelection : {}; + const graphId = typeof payload.graphId === "string" ? payload.graphId : typeof provenance.graphId === "string" ? provenance.graphId : undefined; + const nodeId = typeof payload.nodeId === "string" ? payload.nodeId : typeof provenance.nodeId === "string" ? provenance.nodeId : undefined; + if (!graphId && !nodeId && record.source !== "blueprint_engine_v52") return undefined; + return { + graphId, + nodeId, + outputPath: typeof payload.outputPath === "string" ? payload.outputPath : undefined, + contextPackId: typeof contextSelection.primaryContextPackId === "string" ? contextSelection.primaryContextPackId : undefined, + selectedContextPackIds: toStringArray(contextSelection.selectedContextPackIds), + }; +}; + +const toReviewDto = (record: ReviewRecord, projectSlug: string): ReviewQueueItemDto & { attentionRequired: boolean; sourceType: string } => ({ + id: record.slug ?? record.readableId ?? record.id, + projectId: projectSlug, + type: record.type as ReviewQueueItemDto["type"], + title: record.title, + description: record.description, + priority: record.priority as ReviewQueuePriority, + status: toReviewStatus(record.status), + source: record.source, + confidence: record.confidence, + evidenceStrength: record.evidenceStrength, + verificationState: + record.verificationState === VerificationState.needs_evidence + ? "needs evidence" + : (record.verificationState as ReviewQueueItemDto["verificationState"]), + suggestedAction: record.suggestedAction, + attentionRequired: reviewRequiresHumanAttention(record), + sourceType: record.sourceType, + ...(record.notes ? { notes: record.notes } : {}), + ...(record.targetEntityId ? { targetEntityId: record.targetEntityId } : {}), + ...(isRecord(record.payloadJson) && typeof record.payloadJson.convertedTarget === "string" + ? { convertedTarget: record.payloadJson.convertedTarget as ReviewQueueConvertTarget } + : {}), + ...(record.createdEntityId ? { createdEntityId: record.createdEntityId } : {}), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + ...(toBlueprintReviewMetadata(record) ? { blueprint: toBlueprintReviewMetadata(record) } : {}), +}); + +const toWhere = ( + projectId: string, + filters: ReviewQueueListFilters = {}, +): Prisma.ReviewQueueItemWhereInput => ({ + projectId, + deletedAt: null, + ...(filters.priority?.length ? { priority: { in: filters.priority } } : {}), + ...(filters.type?.length ? { type: { in: filters.type } } : {}), + ...(filters.status?.length + ? { status: { in: filters.status.map(mapReviewStatus) } } + : {}), + ...(filters.source?.length ? { source: { in: filters.source } } : {}), + ...(filters.confidence !== undefined ? { confidence: filters.confidence } : {}), + ...(filters.evidenceStrength?.length + ? { evidenceStrength: { in: filters.evidenceStrength.map(mapEvidenceStrength) } } + : {}), + ...(filters.verificationState?.length + ? { verificationState: { in: filters.verificationState.map(mapVerificationState) } } + : {}), +}); + +export const listReviewItemsFromDatabase = async ( + projectIdOrSlug: string, + filters: ReviewQueueListFilters, + options: { page: number; pageSize: number; cursor?: string; sort?: string }, +) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + + const where = toWhere(project.id, filters); + const total = await prisma.reviewQueueItem.count({ where }); + const records = await prisma.reviewQueueItem.findMany({ + where, + orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" }, + skip: (options.page - 1) * options.pageSize, + take: options.pageSize, + }); + const pageCount = Math.ceil(total / options.pageSize); + + return { + items: records.map((record) => toReviewDto(record, project.slug)), + total, + page: options.page, + pageSize: options.pageSize, + hasNextPage: options.page < pageCount, + hasPreviousPage: options.page > 1, + ...(options.sort ? { sort: options.sort } : {}), + ...(options.cursor ? { nextCursor: undefined, cursor: options.cursor } : {}), + filters, + }; +}; + +export const getReviewItemFromDatabase = async ( + projectIdOrSlug: string, + reviewItemId: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const record = await prisma.reviewQueueItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }], + }, + }); + return record ? toReviewDto(record, project.slug) : undefined; +}; + +const toReviewType = ( + classificationType: NonNullable, +): ReviewQueueItemDto["type"] => { + if (classificationType === "documentation_update") return "documentation_update"; + if (classificationType === "open_question") return "open_question"; + if (classificationType === "memory_rule") return "memory_rule"; + return classificationType; +}; + +export const sendCaptureItemToReviewInDatabase = async ( + projectIdOrSlug: string, + captureItemId: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const captureItem = await prisma.captureItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: captureItemId }, { slug: captureItemId }, { readableId: captureItemId }], + }, + }); + if (!captureItem) return undefined; + if (captureItem.status === "in_review" || captureItem.linkedReviewItemId) return "CAPTURE_ALREADY_IN_REVIEW"; + if (captureItem.status !== "classified" || !captureItem.classificationType) return "CAPTURE_NOT_CLASSIFIED"; + + return prisma.$transaction(async (tx) => { + const claimed = await tx.captureItem.updateMany({ + where: { id: captureItem.id, status: "classified", linkedReviewItemId: null }, + data: { status: "in_review", updatedBy: "api" }, + }); + if (claimed.count !== 1) return "CAPTURE_ALREADY_IN_REVIEW"; + + const createdAt = new Date(); + const slug = `rq-capture-${captureItem.id}`; + const record = await tx.reviewQueueItem.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + type: toReviewType(captureItem.classificationType as NonNullable), + title: captureItem.title, + description: captureItem.content, + priority: captureItem.confidence && captureItem.confidence >= 90 ? "important" : "normal", + status: ReviewStatus.pending, + source: captureItem.source, + confidence: captureItem.confidence ?? 72, + approvalState: ApprovalState.pending, + evidenceStrength: captureItem.evidenceStrength ?? EvidenceStrength.weak, + verificationState: VerificationState.needs_evidence, + suggestedAction: captureItem.suggestedAction ?? "Review captured item before project truth changes.", + createdBy: "api", + updatedBy: "api", + sourceType: "capture_item", + createdAt, + payloadJson: { captureItemId: captureItem.id }, + provenanceJson: { createdFrom: "capture_send_to_review" }, + }, + }); + await tx.captureItem.update({ + where: { id: captureItem.id }, + data: { linkedReviewItemId: record.slug ?? record.id, updatedBy: "api" }, + }); + return toReviewDto(record, project.slug); + }); +}; + +export const updateReviewItemInDatabase = async ( + projectIdOrSlug: string, + reviewItemId: string, + body: UpdateReviewQueueItemRequestDto, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const existing = await prisma.reviewQueueItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }], + }, + }); + if (!existing) return undefined; + if (existing.status !== ReviewStatus.pending) return "REVIEW_ALREADY_DECIDED"; + const claimed = await prisma.reviewQueueItem.updateMany({ + where: { id: existing.id, status: ReviewStatus.pending }, + data: { + ...(body.title !== undefined ? { title: body.title.trim() } : {}), + ...(body.description !== undefined ? { description: body.description.trim() } : {}), + ...(body.priority !== undefined ? { priority: body.priority } : {}), + ...(body.notes !== undefined ? { notes: body.notes.trim() } : {}), + updatedBy: "api", + }, + }); + if (claimed.count !== 1) return "REVIEW_ALREADY_DECIDED"; + const record = await prisma.reviewQueueItem.findUniqueOrThrow({ where: { id: existing.id } }); + return toReviewDto(record, project.slug); +}; + +const statusToApproval = (status: ReviewStatus): ApprovalState => { + if (status === ReviewStatus.accepted || status === ReviewStatus.converted || status === ReviewStatus.merged) { + return ApprovalState.approved; + } + if (status === ReviewStatus.rejected) return ApprovalState.rejected; + return ApprovalState.pending; +}; + +const mutationResult = ( + reviewItemId: string, + message: string, + affectedEntities: string[], + createdEntityId?: string, +): ReviewMutationResponseDto => ({ + success: true, + message, + updatedEntityId: reviewItemId, + ...(createdEntityId ? { createdEntityId } : {}), + affectedEntities, +}); + +export const applyReviewActionInDatabase = async ( + projectIdOrSlug: string, + reviewItemId: string, + action: "accept" | "reject", + notes?: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const existing = await prisma.reviewQueueItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }], + }, + }); + if (!existing) return undefined; + const status = action === "accept" ? ReviewStatus.accepted : ReviewStatus.rejected; + const claimed = await prisma.reviewQueueItem.updateMany({ + where: { id: existing.id, status: ReviewStatus.pending }, + data: { + status, + approvalState: statusToApproval(status), + notes, + updatedBy: "api", + verifiedAt: action === "accept" ? new Date() : undefined, + verifiedBy: action === "accept" ? "api" : undefined, + }, + }); + if (claimed.count !== 1) return "REVIEW_ALREADY_DECIDED"; + return mutationResult(reviewItemId, `Review item '${reviewItemId}' ${action === "accept" ? "accepted" : "rejected"}.`, [reviewItemId]); +}; + +export const mergeReviewItemInDatabase = async ( + projectIdOrSlug: string, + reviewItemId: string, + targetEntityId: string, + notes?: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const existing = await prisma.reviewQueueItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }], + }, + }); + if (!existing) return undefined; + if (existing.status !== ReviewStatus.pending) return "REVIEW_ALREADY_DECIDED"; + + const publicId = { + projectId: project.id, + deletedAt: null, + OR: [{ id: targetEntityId }, { slug: targetEntityId }, { readableId: targetEntityId }], + }; + const targets = await Promise.all([ + prisma.feature.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.feature), + prisma.memoryRule.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.memory_rule), + prisma.knowledgeItem.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.knowledge_item), + prisma.documentationItem.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.documentation_item), + prisma.acceptedTruthItem.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.accepted_truth_item), + prisma.projectUpdate.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.project_update), + prisma.contextPack.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.context_pack), + prisma.initiative.findFirst({ where: publicId, select: { id: true } }).then((item) => item && EntityType.initiative), + prisma.reviewQueueItem.findFirst({ + where: { ...publicId, id: { not: existing.id } }, + select: { id: true }, + }).then((item) => item && EntityType.review_queue_item), + ]); + const targetEntityType = targets.find((target) => Boolean(target)); + if (!targetEntityType) return "REVIEW_TARGET_NOT_FOUND"; + + const claimed = await prisma.reviewQueueItem.updateMany({ + where: { id: existing.id, status: ReviewStatus.pending }, + data: { + status: ReviewStatus.merged, + approvalState: ApprovalState.approved, + targetEntityType, + targetEntityId, + notes, + updatedBy: "api", + verifiedAt: new Date(), + verifiedBy: "api", + }, + }); + if (claimed.count !== 1) return "REVIEW_ALREADY_DECIDED"; + return mutationResult(reviewItemId, `Review item '${reviewItemId}' merged into '${targetEntityId}'.`, [reviewItemId, targetEntityId]); +}; + +const toAcceptedTruthKind = (target: ReviewQueueConvertTarget) => + target === "documentation_update" ? "documentation" : target; + +export const convertReviewItemInDatabase = async ( + projectIdOrSlug: string, + reviewItemId: string, + target: ReviewQueueConvertTarget, + notes?: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const existing = await prisma.reviewQueueItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }], + }, + }); + if (!existing) return undefined; + if (existing.status !== ReviewStatus.pending) return "REVIEW_ALREADY_DECIDED"; + const createdEntityId = `truth-converted-${target}-${existing.id}`; + const converted = await prisma.$transaction(async (tx) => { + const claimed = await tx.reviewQueueItem.updateMany({ + where: { id: existing.id, status: ReviewStatus.pending }, + data: { + status: ReviewStatus.converted, + approvalState: ApprovalState.approved, + createdEntityId, + createdEntityType: EntityType.accepted_truth_item, + payloadJson: { + ...(isRecord(existing.payloadJson) ? existing.payloadJson : {}), + convertedTarget: target, + }, + notes, + updatedBy: "api", + verifiedAt: new Date(), + verifiedBy: "api", + }, + }); + if (claimed.count !== 1) return false; + await tx.acceptedTruthItem.create({ + data: { + projectId: project.id, + slug: createdEntityId, + readableId: createdEntityId, + sourceReviewItemId: existing.id, + kind: toAcceptedTruthKind(target), + title: existing.title, + summary: existing.description, + status: "converted", + source: existing.source, + confidence: existing.confidence, + approvalState: ApprovalState.approved, + evidenceStrength: existing.evidenceStrength, + verificationState: VerificationState.verified, + payloadJson: { reviewItemId: existing.id, convertedTarget: target }, + provenanceJson: { createdFrom: "review_queue_convert" }, + createdBy: "api", + verifiedAt: new Date(), + verifiedBy: "api", + }, + }); + return true; + }); + if (!converted) return "REVIEW_ALREADY_DECIDED"; + return mutationResult(reviewItemId, `Review item '${reviewItemId}' converted into durable ${target} accepted-truth artifact '${createdEntityId}'.`, [reviewItemId, createdEntityId], createdEntityId); +}; diff --git a/backend/src/repositories/runtimeRepository.ts b/backend/src/repositories/runtimeRepository.ts new file mode 100644 index 0000000..66fa704 --- /dev/null +++ b/backend/src/repositories/runtimeRepository.ts @@ -0,0 +1,424 @@ +import { ApprovalState, EvidenceStrength, VerificationState, type Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import type { + AcceptedTruthItemDto, + AcceptedTruthKind, + AcceptedTruthListFilters, + AcceptedTruthStatus, + CreateOperatingLogEntryRequestDto, + EvidenceStrength as EvidenceStrengthDto, + OperatingLogEntryDto, + OperatingLogListFilters, + ProjectUpdateDto, + ProjectUpdateListFilters, + ProjectUpdateTargetArea, + ReviewMutationResponseDto, +} from "../types/api.js"; + +type ProjectRecord = { id: string; slug: string }; +type ReviewRecord = Prisma.ReviewQueueItemGetPayload>; +type TruthRecord = Prisma.AcceptedTruthItemGetPayload>; +type UpdateRecord = Prisma.ProjectUpdateGetPayload>; +type LogRecord = Prisma.OperatingLogEntryGetPayload>; + +const findProjectRecord = (projectIdOrSlug: string): Promise => + prisma.project.findFirst({ + where: { + deletedAt: null, + OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }], + }, + select: { id: true, slug: true }, + }); + +const findReviewRecord = async ( + projectId: string, + reviewItemId: string, +): Promise => + prisma.reviewQueueItem.findFirst({ + where: { + projectId, + deletedAt: null, + OR: [{ id: reviewItemId }, { slug: reviewItemId }, { readableId: reviewItemId }], + }, + }); + +const toEvidenceStrengthEnum = (value: EvidenceStrengthDto | string | null | undefined): EvidenceStrength => { + if (value === "verified") return EvidenceStrength.verified; + if (value === "strong") return EvidenceStrength.strong; + if (value === "medium") return EvidenceStrength.medium; + return EvidenceStrength.weak; +}; + +const toEvidenceStrengthDto = (value: EvidenceStrength): EvidenceStrengthDto => value; + +const normalizeKind = (type: string): AcceptedTruthKind => { + if (type === "memory_rule") return "memory_rule"; + if (type === "documentation_update") return "documentation"; + if (type === "open_question") return "open_question"; + if (type === "feature") return "feature"; + if (type === "bug") return "bug"; + if (type === "task") return "task"; + if (type === "decision") return "decision"; + if (type === "knowledge") return "knowledge"; + if (type === "risk") return "risk"; + if (type === "assumption") return "assumption"; + return "knowledge"; +}; + +const targetAreaForKind = (kind: AcceptedTruthKind): ProjectUpdateTargetArea => { + if (kind === "feature") return "feature-map"; + if (kind === "bug") return "bugs"; + if (kind === "task") return "tasks"; + if (kind === "decision") return "decisions"; + if (kind === "memory_rule") return "memory"; + if (kind === "documentation") return "documentation"; + if (kind === "risk") return "risks"; + if (kind === "open_question") return "questions"; + return "knowledge"; +}; + +const toTruthDto = (record: TruthRecord, projectSlug: string): AcceptedTruthItemDto => ({ + id: record.slug ?? record.readableId ?? record.id, + projectId: projectSlug, + sourceReviewItemId: record.sourceReviewItemId, + kind: record.kind as AcceptedTruthKind, + title: record.title, + summary: record.summary, + status: record.status as AcceptedTruthStatus, + source: record.source, + confidence: record.confidence, + evidenceStrength: toEvidenceStrengthDto(record.evidenceStrength), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), +}); + +const toProjectUpdateDto = (record: UpdateRecord, projectSlug: string): ProjectUpdateDto => ({ + id: record.slug ?? record.readableId ?? record.id, + projectId: projectSlug, + sourceTruthItemId: record.sourceTruthItemId, + kind: record.kind as AcceptedTruthKind, + title: record.title, + summary: record.summary, + targetArea: record.targetArea as ProjectUpdateTargetArea, + status: record.status as ProjectUpdateDto["status"], + confidence: record.confidence, + evidenceStrength: toEvidenceStrengthDto(record.evidenceStrength), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + ...(record.appliedAt ? { appliedAt: record.appliedAt.toISOString() } : {}), +}); + +const linkedIdsFromJson = (value: Prisma.JsonValue): string[] => + Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; + +const toLogDto = (record: LogRecord, projectSlug: string): OperatingLogEntryDto => ({ + id: record.slug ?? record.readableId ?? record.id, + projectId: projectSlug, + timestamp: record.timestamp.toISOString(), + type: record.type as OperatingLogEntryDto["type"], + title: record.title, + summary: record.summary, + linkedEntityIds: linkedIdsFromJson(record.linkedEntityIdsJson), + source: record.source, + importance: record.importance as OperatingLogEntryDto["importance"], + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), +}); + +const paginateMeta = ( + items: T[], + total: number, + page: number, + pageSize: number, + sort?: string, + filters?: Record, +) => ({ + items, + total, + page, + pageSize, + hasNextPage: page < Math.ceil(total / pageSize), + hasPreviousPage: page > 1, + ...(sort ? { sort } : {}), + ...(filters ? { filters: filters as Record } : {}), +}); + +export const listAcceptedTruthFromDatabase = async ( + projectIdOrSlug: string, + filters: AcceptedTruthListFilters, + options: { page: number; pageSize: number; sort?: string }, +) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const where: Prisma.AcceptedTruthItemWhereInput = { + projectId: project.id, + deletedAt: null, + ...(filters.kind?.length ? { kind: { in: filters.kind } } : {}), + ...(filters.status?.length ? { status: { in: filters.status } } : {}), + ...(filters.confidence !== undefined ? { confidence: filters.confidence } : {}), + ...(filters.evidenceStrength?.length + ? { evidenceStrength: { in: filters.evidenceStrength.map(toEvidenceStrengthEnum) } } + : {}), + ...(filters.sourceReviewItemId ? { sourceReviewItemId: filters.sourceReviewItemId } : {}), + }; + const total = await prisma.acceptedTruthItem.count({ where }); + const records = await prisma.acceptedTruthItem.findMany({ + where, + orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" }, + skip: (options.page - 1) * options.pageSize, + take: options.pageSize, + }); + return paginateMeta(records.map((record) => toTruthDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters); +}; + +export const materializeReviewItemAsTruthInDatabase = async ( + projectIdOrSlug: string, + reviewItemId: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const review = await findReviewRecord(project.id, reviewItemId); + if (!review) return undefined; + if (!["accepted", "converted", "merged"].includes(review.status)) { + return { + success: true, + message: `Review item '${reviewItemId}' is not accepted, converted or merged yet.`, + updatedEntityId: reviewItemId, + affectedEntities: [reviewItemId], + }; + } + const existing = await prisma.acceptedTruthItem.findFirst({ + where: { projectId: project.id, sourceReviewItemId: review.id, deletedAt: null }, + }); + if (existing) { + return { + success: true, + message: `Accepted truth already exists for review item '${reviewItemId}'.`, + updatedEntityId: reviewItemId, + createdEntityId: existing.slug ?? existing.readableId ?? existing.id, + affectedEntities: [reviewItemId, existing.slug ?? existing.id], + }; + } + const createdAt = new Date(); + const kind = normalizeKind(review.createdEntityType ?? review.type); + const slug = `truth-${project.slug}-${createdAt.getTime()}`; + const truth = await prisma.acceptedTruthItem.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + sourceReviewItemId: review.id, + kind, + title: review.title, + summary: review.description, + status: review.status === "converted" ? "converted" : review.status === "merged" ? "merged" : "accepted", + source: review.source, + confidence: review.confidence, + approvalState: ApprovalState.approved, + evidenceStrength: review.evidenceStrength, + verificationState: VerificationState.verified, + payloadJson: { reviewItemId: review.id, reviewSlug: review.slug, suggestedAction: review.suggestedAction }, + provenanceJson: { createdFrom: "P6 review materialization" }, + verifiedAt: createdAt, + verifiedBy: "api", + }, + }); + await createOperatingLogEntryInDatabase(project.slug, { + type: "accepted truth", + title: `Accepted truth created: ${review.title}`, + summary: review.description, + linkedEntityIds: [review.id, truth.id], + source: "review_queue", + importance: review.priority === "critical" ? "critical" : review.priority === "important" ? "important" : "normal", + }); + return { + success: true, + message: `Review item '${reviewItemId}' materialized as accepted truth.`, + updatedEntityId: reviewItemId, + createdEntityId: truth.slug ?? truth.id, + affectedEntities: [reviewItemId, truth.slug ?? truth.id], + }; +}; + +export const listProjectUpdatesFromDatabase = async ( + projectIdOrSlug: string, + filters: ProjectUpdateListFilters, + options: { page: number; pageSize: number; sort?: string }, +) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const where: Prisma.ProjectUpdateWhereInput = { + projectId: project.id, + deletedAt: null, + ...(filters.status?.length ? { status: { in: filters.status } } : {}), + ...(filters.targetArea?.length ? { targetArea: { in: filters.targetArea } } : {}), + ...(filters.kind?.length ? { kind: { in: filters.kind } } : {}), + ...(filters.sourceTruthItemId ? { sourceTruthItemId: filters.sourceTruthItemId } : {}), + }; + const total = await prisma.projectUpdate.count({ where }); + const records = await prisma.projectUpdate.findMany({ + where, + orderBy: { createdAt: options.sort?.endsWith(":asc") ? "asc" : "desc" }, + skip: (options.page - 1) * options.pageSize, + take: options.pageSize, + }); + return paginateMeta(records.map((record) => toProjectUpdateDto(record, project.slug)), total, options.page, options.pageSize, options.sort, filters); +}; + +export const createProjectUpdateFromTruthInDatabase = async ( + projectIdOrSlug: string, + truthItemId: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const truth = await prisma.acceptedTruthItem.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: truthItemId }, { slug: truthItemId }, { readableId: truthItemId }], + }, + }); + if (!truth) return undefined; + const existing = await prisma.projectUpdate.findFirst({ + where: { projectId: project.id, sourceTruthItemId: truth.id, deletedAt: null }, + }); + if (existing) { + return { + success: true, + message: `Project update already exists for accepted truth '${truthItemId}'.`, + updatedEntityId: truthItemId, + createdEntityId: existing.slug ?? existing.id, + affectedEntities: [truthItemId, existing.slug ?? existing.id], + }; + } + const createdAt = new Date(); + const slug = `update-${project.slug}-${createdAt.getTime()}`; + const update = await prisma.projectUpdate.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + sourceTruthItemId: truth.id, + kind: truth.kind, + title: truth.title, + summary: truth.summary, + targetArea: targetAreaForKind(truth.kind as AcceptedTruthKind), + status: "ready", + confidence: truth.confidence, + approvalState: ApprovalState.pending, + evidenceStrength: truth.evidenceStrength, + payloadJson: { acceptedTruthItemId: truth.id }, + provenanceJson: { createdFrom: "P6 accepted truth" }, + }, + }); + await createOperatingLogEntryInDatabase(project.slug, { + type: "project update", + title: `Project update prepared: ${truth.title}`, + summary: truth.summary, + linkedEntityIds: [truth.id, update.id], + source: "accepted_truth", + importance: truth.confidence >= 90 ? "important" : "normal", + }); + return { + success: true, + message: `Project update prepared from accepted truth '${truthItemId}'.`, + updatedEntityId: truthItemId, + createdEntityId: update.slug ?? update.id, + affectedEntities: [truthItemId, update.slug ?? update.id], + }; +}; + +export const applyProjectUpdateInDatabase = async ( + projectIdOrSlug: string, + projectUpdateId: string, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const existing = await prisma.projectUpdate.findFirst({ + where: { + projectId: project.id, + deletedAt: null, + OR: [{ id: projectUpdateId }, { slug: projectUpdateId }, { readableId: projectUpdateId }], + }, + }); + if (!existing) return undefined; + const appliedAt = new Date(); + const claimed = await prisma.projectUpdate.updateMany({ + where: { id: existing.id, status: "ready" }, + data: { + status: "applied", + approvalState: ApprovalState.approved, + appliedAt, + updatedBy: "api", + }, + }); + if (claimed.count !== 1) return "PROJECT_UPDATE_ALREADY_APPLIED"; + const updated = await prisma.projectUpdate.findUniqueOrThrow({ where: { id: existing.id } }); + await createOperatingLogEntryInDatabase(project.slug, { + type: "project update", + title: `Project update applied: ${updated.title}`, + summary: updated.summary, + linkedEntityIds: [updated.id, updated.sourceTruthItemId], + source: "project_update", + importance: "important", + }); + return { + success: true, + message: `Project update '${projectUpdateId}' applied.`, + updatedEntityId: updated.slug ?? updated.id, + affectedEntities: [updated.slug ?? updated.id, updated.sourceTruthItemId], + }; +}; + +export const listOperatingLogEntriesFromDatabase = async ( + projectIdOrSlug: string, + filters: OperatingLogListFilters, + options: { page: number; pageSize: number; sort?: string }, +) => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const where: Prisma.OperatingLogEntryWhereInput = { + projectId: project.id, + deletedAt: null, + ...(filters.type?.length ? { type: { in: filters.type } } : {}), + ...(filters.source?.length ? { source: { in: filters.source } } : {}), + ...(filters.importance?.length ? { importance: { in: filters.importance } } : {}), + }; + const total = await prisma.operatingLogEntry.count({ where }); + const records = await prisma.operatingLogEntry.findMany({ + where, + orderBy: { timestamp: options.sort?.endsWith(":asc") ? "asc" : "desc" }, + skip: (options.page - 1) * options.pageSize, + take: options.pageSize, + }); + const items = records + .map((record) => toLogDto(record, project.slug)) + .filter((entry) => !filters.linkedEntityId || entry.linkedEntityIds.includes(filters.linkedEntityId)); + return paginateMeta(items, total, options.page, options.pageSize, options.sort, filters); +}; + +export const createOperatingLogEntryInDatabase = async ( + projectIdOrSlug: string, + body: CreateOperatingLogEntryRequestDto, +): Promise => { + const project = await findProjectRecord(projectIdOrSlug); + if (!project) return undefined; + const createdAt = new Date(); + const slug = `log-${project.slug}-${createdAt.getTime()}-${Math.random().toString(36).slice(2, 8)}`; + const record = await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug, + readableId: slug, + timestamp: createdAt, + type: body.type, + title: body.title, + summary: body.summary, + linkedEntityIdsJson: body.linkedEntityIds ?? [], + source: body.source ?? "manual", + importance: body.importance ?? "normal", + payloadJson: { createdFrom: "P6 operating log API" }, + }, + }); + return toLogDto(record, project.slug); +}; diff --git a/backend/src/routes/activity.ts b/backend/src/routes/activity.ts new file mode 100644 index 0000000..d5a7701 --- /dev/null +++ b/backend/src/routes/activity.ts @@ -0,0 +1,205 @@ +import { createHash } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { createGiteaActivityRuntimeLoader, isActiveRunnerJob, prioritizeRuntimeJobs, type CompactRunnerJob } from "../services/activityRuntime.js"; +import { loadAutonomyRuntime } from "../services/autonomyRuntime.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { apiPaths } from "@projectbrain/contracts"; + +type ActivityItem = { + id: string; + type: "delivery" | "repository" | "project" | "operating-log" | "analysis" | "finding" | "runner-job" | "autonomy"; + title: string; + description: string; + status?: string; + actor?: string; + repository?: string; + branch?: string; + url?: string; + createdAt: string; +}; + +const humanize = (value: string) => value.replace(/[._-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +const clip = (value: string, maximum = 500) => value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`; +const activityTime = (job: CompactRunnerJob) => job.completedAt ?? job.startedAt ?? job.createdAt; +const newestFirst = (items: ActivityItem[]) => items.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)); +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; + +const loadDurableActivity = async (projectId: string): Promise => { + const [deliveryEvents, repositoryEvents, projectEvents, operatingLogs, analysisItems, findings, workspace] = await Promise.all([ + prisma.outboxEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 50 }), + prisma.mcpAuditEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 50 }), + prisma.activityEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 50 }), + prisma.operatingLogEntry.findMany({ where: { projectId, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 50 }), + prisma.analysisJobItem.findMany({ + where: { projectId }, orderBy: { createdAt: "desc" }, take: 50, + include: { job: { select: { actor: true, status: true, progressCurrent: true, progressTotal: true } } }, + }), + prisma.repositoryFinding.findMany({ + where: { projectId }, orderBy: { updatedAt: "desc" }, take: 50, + select: { id: true, owner: true, repository: true, title: true, message: true, status: true, updatedAt: true, issueLink: { select: { issueUrl: true } } }, + }), + prisma.workspaceState.findUnique({ where: { projectId }, select: { notesJson: true } }), + ]); + + const autonomyEvents = (() => { + const events = asRecord(asRecord(workspace?.notesJson).autonomy).events; + return Array.isArray(events) ? events.flatMap((value) => { + const event = asRecord(value); + if (typeof event.id !== "string" || typeof event.at !== "string" || typeof event.type !== "string" || typeof event.summary !== "string") return []; + return [{ + id: `autonomy:${event.id}`, + type: "autonomy" as const, + title: clip(humanize(event.type), 180), + description: clip(event.summary), + status: event.type.split(".").at(-1), + actor: typeof event.actor === "string" ? event.actor : undefined, + createdAt: event.at, + }]; + }) : []; + })(); + + return newestFirst([ + ...deliveryEvents.map((event) => ({ + id: `delivery:${event.id}`, type: "delivery" as const, title: clip(humanize(event.eventType), 180), + description: `${humanize(event.aggregateType)} ${event.aggregateId}`, status: event.publishedAt ? "published" : "recorded", createdAt: event.createdAt.toISOString(), + })), + ...repositoryEvents.map((event) => ({ + id: `repository:${event.id}`, type: "repository" as const, title: clip(humanize(event.toolName), 180), + description: clip([event.repository, event.branch].filter(Boolean).join(" · ") || "ProjectBrain repositoryactie"), + status: event.outcome, actor: event.actor, repository: event.repository ?? undefined, branch: event.branch ?? undefined, createdAt: event.createdAt.toISOString(), + })), + ...projectEvents.map((event) => ({ + id: `project:${event.id}`, type: "project" as const, title: clip(humanize(event.eventType), 180), + description: `${humanize(event.entityType)} ${event.entityId}`, actor: event.actor, createdAt: event.createdAt.toISOString(), + })), + ...operatingLogs.map((event) => ({ + id: `log:${event.id}`, type: "operating-log" as const, title: clip(event.title, 180), description: clip(event.summary), + status: event.importance, actor: event.createdBy, createdAt: event.timestamp.toISOString(), + })), + ...analysisItems.map((item) => ({ + id: `analysis:${item.id}`, type: "analysis" as const, title: `Repositoryanalyse ${humanize(item.status)}`, + description: clip(item.summary ?? `${item.owner}/${item.repository} · job ${item.job.progressCurrent}/${item.job.progressTotal}`), + status: item.status, actor: item.job.actor, repository: `${item.owner}/${item.repository}`, branch: item.defaultBranch, + createdAt: (item.completedAt ?? item.startedAt ?? item.createdAt).toISOString(), + })), + ...findings.map((finding) => ({ + id: `finding:${finding.id}`, type: "finding" as const, title: clip(finding.title, 180), + description: clip(finding.message), status: finding.status, repository: `${finding.owner}/${finding.repository}`, + url: finding.issueLink?.issueUrl, createdAt: finding.updatedAt.toISOString(), + })), + ...autonomyEvents, + ]).slice(0, 150); +}; + +export const registerActivityRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + const loadGiteaRuntime = createGiteaActivityRuntimeLoader(options.env); + + app.get("/api/portfolio/runtime", async (request, reply) => { + reply.header("Cache-Control", "private, no-store"); + const [snapshot, worker, autonomy] = await Promise.all([ + loadGiteaRuntime(), + prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }), + loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS), + ]); + return successEnvelope({ + ...snapshot, + worker: worker ? { status: Date.now() - worker.lastSeenAt.getTime() < 30_000 ? (worker.activeJobId ? "working" : worker.status) : "stale", activeJobId: worker.activeJobId ?? undefined, detail: worker.detail ?? undefined, lastSeenAt: worker.lastSeenAt.toISOString() } : undefined, + autonomy, + pollAfterMs: snapshot.counts.running || snapshot.counts.runnable || snapshot.counts.dependencyBlocked || autonomy.counts.activeProjects || autonomy.counts.claimableProjects ? 8_000 : 30_000, + }, getRequestId(request)); + }); + + app.get(apiPaths.projectActivity(), async (request, reply) => { + reply.header("Cache-Control", "private, no-store"); + const { projectId } = request.params as { projectId: string }; + const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectId }, { slug: projectId }] }, select: { id: true } }); + if (!project) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", "Project not found", getRequestId(request))); + return successEnvelope(await loadDurableActivity(project.id), getRequestId(request)); + }); + + app.get(apiPaths.projectActivityRuntime(), async (request, reply) => { + reply.header("Cache-Control", "private, no-store"); + const { projectId } = request.params as { projectId: string }; + const { stateVersion: requestedStateVersion } = request.query as { stateVersion?: string }; + const knownStateVersion = requestedStateVersion && /^[a-f0-9]{24}$/.test(requestedStateVersion) ? requestedStateVersion : undefined; + const project = await prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: projectId }, { slug: projectId }] }, + select: { id: true, giteaRepositories: { select: { owner: true, repository: true } } }, + }); + if (!project) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", "Project not found", getRequestId(request))); + + const [durableItems, giteaRuntime, worker, autonomy] = await Promise.all([ + loadDurableActivity(project.id), + loadGiteaRuntime(), + prisma.analysisWorkerHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }), + loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS), + ]); + const projectAutonomy = autonomy.projects.find((item) => item.projectId === project.id); + const repositoryNames = new Set(project.giteaRepositories.map((link) => `${link.owner}/${link.repository}`.toLowerCase())); + const projectJobs = giteaRuntime.jobs.filter((job) => job.repository && repositoryNames.has(job.repository.toLowerCase())).slice(0, 20); + const activePortfolioJobs = giteaRuntime.jobs.filter((job) => isActiveRunnerJob(job.status)).slice(0, 12); + const monitorJobs = prioritizeRuntimeJobs([...new Map([...projectJobs, ...activePortfolioJobs].map((job) => [job.id, { + ...job, + scope: job.repository && repositoryNames.has(job.repository.toLowerCase()) ? "project" as const : "portfolio" as const, + }])).values()]).slice(0, 24); + const runnerItems: ActivityItem[] = projectJobs.map((job) => ({ + id: `runner-job:${job.id}`, + type: "runner-job", + title: job.name, + description: [job.repository, job.currentStep, job.runnerName].filter(Boolean).join(" · "), + status: job.conclusion ?? job.status, + repository: job.repository, + branch: job.branch, + url: job.url, + createdAt: activityTime(job), + })); + const items = newestFirst([...durableItems, ...runnerItems]).slice(0, 150); + const workerFresh = worker ? Date.now() - worker.lastSeenAt.getTime() < 30_000 : false; + const workerSnapshot = worker ? { + status: workerFresh ? (worker.activeJobId ? "working" : worker.status) : "stale", + instanceId: worker.instanceId, + activeJobId: worker.activeJobId ?? undefined, + detail: worker.detail ?? undefined, + lastSeenAt: worker.lastSeenAt.toISOString(), + } : undefined; + const hasActiveWork = giteaRuntime.counts.running > 0 + || giteaRuntime.counts.runnable > 0 + || giteaRuntime.counts.dependencyBlocked > 0 + || giteaRuntime.runners.some((runner) => runner.busy) + || Boolean(workerSnapshot?.activeJobId) + || Boolean(projectAutonomy?.activeWorkKind) + || Boolean(projectAutonomy?.enabled && projectAutonomy.status === "running"); + const stateVersion = createHash("sha256").update(JSON.stringify({ + items: items.map((item) => [item.id, item.status, item.createdAt]), + gitea: giteaRuntime.stateVersion, + worker: workerSnapshot ? [workerSnapshot.status, workerSnapshot.activeJobId, workerSnapshot.detail] : undefined, + autonomy: projectAutonomy ? [projectAutonomy.status, projectAutonomy.pendingWorkCount, projectAutonomy.activeWorkKind, projectAutonomy.blockerKind, projectAutonomy.blockerReason, autonomy.executor?.status] : undefined, + })).digest("hex").slice(0, 24); + + const pollAfterMs = hasActiveWork ? 8_000 : 30_000; + if (knownStateVersion === stateVersion) { + return successEnvelope({ unchanged: true, stateVersion, pollAfterMs }, getRequestId(request)); + } + return successEnvelope({ + items, + runtime: { + availability: giteaRuntime.availability, + warning: giteaRuntime.warning, + runners: giteaRuntime.runners, + jobs: monitorJobs, + activeJobCount: giteaRuntime.counts.running, + queuedJobCount: giteaRuntime.counts.runnable, + dependencyBlockedJobCount: giteaRuntime.counts.dependencyBlocked, + worker: workerSnapshot, + autonomy: { executor: autonomy.executor, executors: autonomy.executors, project: projectAutonomy }, + generatedAt: giteaRuntime.generatedAt, + }, + stateVersion, + pollAfterMs, + }, getRequestId(request)); + }); +}; diff --git a/backend/src/routes/aiActions.ts b/backend/src/routes/aiActions.ts new file mode 100644 index 0000000..2b170b5 --- /dev/null +++ b/backend/src/routes/aiActions.ts @@ -0,0 +1,331 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { + applyAIActionFilters, + applyAIActionRunFilters, + findAIAction, + findAIActionRun, + listAIActionRunsForProject, + listAIActionsForProject, + runAIAction, +} from "../fixtures/aiActionFixtures.js"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + AIActionCategory, + AIActionId, + AIActionInputSource, + AIActionListFilters, + AIActionOutputTarget, + AIActionRunListFilters, + AIActionRunStatus, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + getIdempotencyKey, + getIdempotentResponse, + setIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const actionParamsSchema = z.object({ projectId: idSchema, actionId: idSchema }); +const runParamsSchema = z.object({ projectId: idSchema, runId: idSchema }); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const actionQuerySchema = paginationQuerySchema.extend({ + category: z.string().optional(), + outputTarget: z.string().optional(), + sourceType: z.string().optional(), +}); +const runQuerySchema = paginationQuerySchema.extend({ + actionId: z.string().optional(), + status: z.string().optional(), + outputTarget: z.string().optional(), + createdFrom: z.string().optional(), +}); +const inputSources = [ + "constitution", + "dna", + "feature_map", + "architecture", + "capture_items", + "context_pack", + "checks", + "manual", +] as const; +const outputTargets = [ + "review_queue", + "context_pack", + "documentation", + "roadmap", + "current_state", + "codex_task", +] as const; +const runBodySchema = z.object({ + inputSource: z.enum(inputSources).optional(), + inputId: z.string().optional(), + outputTarget: z.enum(outputTargets).optional(), + objective: z.string().optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare( + String(right[field] ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +const toPaginationInput = ( + query: z.output, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const toActionFilters = ( + query: z.output, +): AIActionListFilters => ({ + ...(query.category ? { category: splitCsv(query.category) as AIActionCategory[] } : {}), + ...(query.outputTarget + ? { outputTarget: splitCsv(query.outputTarget) as AIActionOutputTarget[] } + : {}), + ...(query.sourceType + ? { sourceType: splitCsv(query.sourceType) as AIActionInputSource[] } + : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const toRunFilters = ( + query: z.output, +): AIActionRunListFilters => ({ + ...(query.actionId ? { actionId: splitCsv(query.actionId) as AIActionId[] } : {}), + ...(query.status ? { status: splitCsv(query.status) as AIActionRunStatus[] } : {}), + ...(query.outputTarget + ? { outputTarget: splitCsv(query.outputTarget) as AIActionOutputTarget[] } + : {}), + ...(query.createdFrom ? { createdFrom: splitCsv(query.createdFrom) } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + return idempotencyKey; +}; + +const sendActionNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + actionId: string, +) => + reply.status(404).send( + errorEnvelope( + "AI_ACTION_NOT_FOUND", + `AI action '${actionId}' was not found.`, + getRequestId(request), + ), + ); + +const sendRunNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + runId: string, +) => + reply.status(404).send( + errorEnvelope( + "AI_ACTION_RUN_NOT_FOUND", + `AI action run '${runId}' was not found.`, + getRequestId(request), + ), + ); + +export const registerAIActionRoutes = async (app: FastifyInstance) => { + app.get("/api/projects/:projectId/ai-actions", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(actionQuerySchema, request, reply); + if (!query) return reply; + + const filters = toActionFilters(query); + const actions = sortByField( + applyAIActionFilters(listAIActionsForProject(params.projectId), filters), + query.sort, + ); + + return successEnvelope( + paginate(actions, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get("/api/projects/:projectId/ai-actions/runs", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(runQuerySchema, request, reply); + if (!query) return reply; + + const filters = toRunFilters(query); + const runs = sortByField( + applyAIActionRunFilters(listAIActionRunsForProject(params.projectId), filters), + query.sort, + ); + + return successEnvelope( + paginate(runs, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get( + "/api/projects/:projectId/ai-actions/runs/:runId", + async (request, reply) => { + const params = parseParams(runParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const run = findAIActionRun(params.projectId, params.runId); + if (!run) return sendRunNotFound(request, reply, params.runId); + return successEnvelope(run, getRequestId(request)); + }, + ); + + app.post( + "/api/projects/:projectId/ai-actions/:actionId/run", + async (request, reply) => { + const params = parseParams(actionParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = runBodySchema.safeParse(request.body); + if ( + !parsedBody.success || + (parsedBody.data.objective !== undefined && + !parsedBody.data.objective.trim()) + ) { + return reply.status(400).send( + errorEnvelope( + "AI_ACTION_VALIDATION_FAILED", + "AI action run body is invalid.", + getRequestId(request), + ), + ); + } + + const action = findAIAction(params.projectId, params.actionId); + if (!action) return sendActionNotFound(request, reply, params.actionId); + + const body = successEnvelope( + runAIAction(params.projectId, action, parsedBody.data), + getRequestId(request), + ); + setIdempotentResponse(cacheKey, { statusCode: 201, body }); + return reply.status(201).send(body); + }, + ); +}; diff --git a/backend/src/routes/analysisJobs.ts b/backend/src/routes/analysisJobs.ts new file mode 100644 index 0000000..cda0ff5 --- /dev/null +++ b/backend/src/routes/analysisJobs.ts @@ -0,0 +1,84 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { cancelProjectAnalysisJob, createPortfolioAnalysisJob, getProjectAnalysisJob, getProjectAnalysisJobResults, listProjectAnalysisJobs } from "../repositories/analysisJobRepository.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import type { AppEnv } from "../config/env.js"; + +const projectParams = z.object({ projectId: z.string().min(1) }); +const jobParams = projectParams.extend({ jobId: z.string().min(1) }); +const startBody = z.object({ + repositoryIds: z.array(z.string().trim().min(1)).max(200).optional(), + includeBranches: z.boolean().default(true), + includePullRequests: z.boolean().default(true), + includeActions: z.boolean().default(true), + changedPaths: z.array(z.string().trim().min(1)).max(200).optional(), +}); +const pageQuery = z.object({ + cursor: z.string().regex(/^\d+$/).optional(), + pageSize: z.coerce.number().int().min(1).max(10).default(5), + characterBudget: z.coerce.number().int().min(4000).max(32000).default(24000), +}); + +const requireOwner = (request: FastifyRequest) => request.authUser?.role === "owner"; +const analysisError = (error: unknown, request: FastifyRequest, reply: FastifyReply) => { + const code = error instanceof Error ? error.message : "ANALYSIS_JOB_FAILED"; + const status = code.endsWith("NOT_FOUND") ? 404 : code.endsWith("LIMIT_EXCEEDED") ? 422 : 409; + return reply.status(status).send(errorEnvelope(code, code === "ANALYSIS_REPOSITORIES_NOT_FOUND" ? "No linked repositories match this analysis scope." : "The repository analysis request could not be completed.", getRequestId(request))); +}; + +export const registerAnalysisJobRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + app.get("/api/projects/:projectId/analysis-jobs", async (request) => { + const { projectId } = projectParams.parse(request.params); + const limit = z.coerce.number().int().min(1).max(25).default(10).parse((request.query as { limit?: unknown }).limit); + return successEnvelope({ items: await listProjectAnalysisJobs(projectId, limit) }, getRequestId(request)); + }); + + app.post("/api/projects/:projectId/analysis-jobs", async (request, reply) => { + if (!requireOwner(request)) return reply.status(403).send(errorEnvelope("ANALYSIS_JOB_OWNER_REQUIRED", "Only the project owner can start server-side analyses.", getRequestId(request))); + const { projectId } = projectParams.parse(request.params); + const body = startBody.parse(request.body ?? {}); + try { + const result = await createPortfolioAnalysisJob(options.env, `web:${request.authUser!.username}`, { + projectIds: [projectId], + repositoryIds: body.repositoryIds, + includeBranches: body.includeBranches, + includePullRequests: body.includePullRequests, + includeActions: body.includeActions, + changedPaths: body.changedPaths, + }); + return reply.status(result.deduplicated ? 200 : 202).send(successEnvelope(result, getRequestId(request))); + } catch (error) { + return analysisError(error, request, reply); + } + }); + + app.get("/api/projects/:projectId/analysis-jobs/:jobId", async (request, reply) => { + const { projectId, jobId } = jobParams.parse(request.params); + try { + return successEnvelope({ job: await getProjectAnalysisJob(projectId, jobId) }, getRequestId(request)); + } catch (error) { + return analysisError(error, request, reply); + } + }); + + app.get("/api/projects/:projectId/analysis-jobs/:jobId/results", async (request, reply) => { + const { projectId, jobId } = jobParams.parse(request.params); + const query = pageQuery.parse(request.query ?? {}); + try { + return successEnvelope(await getProjectAnalysisJobResults(projectId, jobId, { cursor: query.cursor, pageSize: query.pageSize, characterBudget: query.characterBudget }), getRequestId(request)); + } catch (error) { + return analysisError(error, request, reply); + } + }); + + app.post("/api/projects/:projectId/analysis-jobs/:jobId/cancel", async (request, reply) => { + if (!requireOwner(request)) return reply.status(403).send(errorEnvelope("ANALYSIS_JOB_OWNER_REQUIRED", "Only the project owner can cancel server-side analyses.", getRequestId(request))); + const { projectId, jobId } = jobParams.parse(request.params); + try { + return successEnvelope({ job: await cancelProjectAnalysisJob(projectId, jobId) }, getRequestId(request)); + } catch (error) { + return analysisError(error, request, reply); + } + }); +}; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..9700ef1 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,47 @@ +import type { FastifyInstance } from "fastify"; +import { apiPaths, loginCredentialsSchema, rotatePasswordSchema } from "@projectbrain/contracts"; +import type { AppEnv } from "../config/env.js"; +import { authenticate, createSession, revokeSession, rotatePassword } from "../security/authStore.js"; +import { SESSION_COOKIE, sessionCookieOptions } from "../plugins/authentication.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +export const registerAuthRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + const { env } = options; + app.post(apiPaths.auth.login, { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, async (request, reply) => { + const parsed = loginCredentialsSchema.safeParse(request.body); + if (!parsed.success) { + request.log.warn({ requestId: getRequestId(request), invalidFields: parsed.error.issues.map((issue) => issue.path.join(".")) }, "Authentication request validation failed"); + return reply.status(400).send(errorEnvelope("AUTH_VALIDATION_FAILED", "Valid credentials are required", getRequestId(request))); + } + const user = await authenticate(env, parsed.data.username, parsed.data.password); + if (!user) { + request.log.warn({ requestId: getRequestId(request), code: "INVALID_CREDENTIALS" }, "Authentication failed"); + return reply.status(401).send(errorEnvelope("INVALID_CREDENTIALS", "Invalid username or password", getRequestId(request))); + } + const session = await createSession(env, user); + request.log.info({ requestId: getRequestId(request), userId: user.id, role: user.role }, "Authentication succeeded"); + reply.setCookie(SESSION_COOKIE, session.token, sessionCookieOptions(env)); + return successEnvelope({ user: { id: user.id, username: user.username, role: user.role }, csrfToken: session.csrfToken, expiresAt: session.expiresAt.toISOString() }, getRequestId(request)); + }); + + app.get(apiPaths.auth.session, async (request) => successEnvelope({ user: request.authUser, csrfToken: request.authCsrfToken }, getRequestId(request))); + + app.post(apiPaths.auth.logout, async (request, reply) => { + const signedToken = request.cookies[SESSION_COOKIE]; + const unsigned = signedToken ? request.unsignCookie(signedToken) : undefined; + const token = unsigned?.valid ? unsigned.value : undefined; + if (token) await revokeSession(env, token); + reply.clearCookie(SESSION_COOKIE, { path: "/api" }); + return successEnvelope({ loggedOut: true }, getRequestId(request)); + }); + + app.post(apiPaths.auth.rotatePassword, async (request, reply) => { + const parsed = rotatePasswordSchema.safeParse(request.body); + if (!parsed.success || !request.authUser) return reply.status(400).send(errorEnvelope("PASSWORD_ROTATION_INVALID", "Valid current and new passwords are required", getRequestId(request))); + const rotated = await rotatePassword(env, request.authUser, parsed.data.currentPassword, parsed.data.newPassword); + if (!rotated) return reply.status(403).send(errorEnvelope("CURRENT_PASSWORD_INVALID", "Current password is invalid", getRequestId(request))); + reply.clearCookie(SESSION_COOKIE, { path: "/api" }); + return successEnvelope({ rotated: true, loginRequired: true }, getRequestId(request)); + }); +}; diff --git a/backend/src/routes/autonomy.ts b/backend/src/routes/autonomy.ts new file mode 100644 index 0000000..c9c1cd5 --- /dev/null +++ b/backend/src/routes/autonomy.ts @@ -0,0 +1,201 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { autonomyProfileSchema } from "@projectbrain/contracts"; +import type { AppEnv } from "../config/env.js"; +import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork, resumeAutonomy } from "../repositories/autonomyRepository.js"; +import { enqueueAutonomyTask } from "../services/autonomyAssignments.js"; +import { claimAutonomyWork } from "../services/autonomyRuntimeControl.js"; +import { reconcileRepositoryAutonomyRoadmap } from "../services/repositoryAutonomy.js"; +import { syncGitSources } from "../services/gitSourceSync.js"; +import { reconcileGiteaAutonomyExecution } from "../services/giteaAutonomyExecution.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); +const paramsSchema = z.object({ projectId: idSchema }); +const configSchema = z.object({ + enabled: z.boolean().optional(), + profile: autonomyProfileSchema.optional(), + autoBrainstorm: z.boolean().optional(), + maxBrainstormIterations: z.number().int().min(1).max(10).optional(), + maxConsecutiveFailures: z.number().int().min(1).max(10).optional(), + leaseMinutes: z.number().int().min(5).max(120).optional(), + maxWorkItemsPerRun: z.number().int().min(1).max(20).optional(), + maxRunMinutes: z.number().int().min(5).max(55).optional(), + maxRecoveryMinutes: z.number().int().min(15).max(1_440).optional(), + pauseAfterCurrent: z.boolean().optional(), + schedulingPolicy: z.enum(["balanced", "downstream-impact"]).optional(), +}).refine((value) => Object.keys(value).length > 0, "At least one Auto Mode setting is required."); +const roadmapSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), markdown: z.string().trim().min(3).max(100_000) }); +const taskSchema = z.object({ title: z.string().trim().min(2).max(200).optional(), instruction: z.string().trim().min(3).max(1_200) }); +const claimSchema = z.object({ + runId: z.string().trim().min(1).max(160).optional(), +}).default({}); +// A browser caller may provide a scheduled-run identifier, but not the +// identity that owns a durable Auto Mode lease or its audit events. +export const parseAutonomyClaimInput = (input: unknown) => claimSchema.parse(input); +const reportSchema = z.object({ + token: z.string().uuid(), + outcome: z.enum(["success", "failed", "blocked"]), + summary: z.string().trim().min(3).max(12_000), + blockerType: z.enum(["human", "retryable"]).optional(), + retryAfterMinutes: z.number().int().min(5).max(1_440).optional(), + deliveryId: z.string().trim().min(1).max(240).optional(), + discoveredTasks: z.array(z.string().trim().min(2).max(1_000)).max(100).optional(), + brainstorm: z.object({ + contextSummary: z.string().trim().min(3).max(8_000).optional(), + ideas: z.array(z.object({ id: z.string().min(1).max(160), title: z.string().min(1).max(300), description: z.string().min(1).max(4_000), score: z.number().min(0).max(100).optional() })).max(50).optional(), + outcomes: z.array(z.string().min(1).max(2_000)).max(50).optional(), + selectedIdeaIds: z.array(z.string().min(1).max(160)).max(50).optional(), + nextActions: z.array(z.string().min(2).max(1_000)).max(100).optional(), + }).optional(), +}); + +const sendValidationError = (request: FastifyRequest, reply: FastifyReply, issues: z.ZodIssue[]) => + reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), issues.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); + +const params = (request: FastifyRequest, reply: FastifyReply) => { + const parsed = paramsSchema.safeParse(request.params); + if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } + return parsed.data; +}; + +const requireDatabase = (dataSource: AppEnv["PROJECTBRAIN_DATA_SOURCE"], request: FastifyRequest, reply: FastifyReply) => { + if (dataSource === "database") return false; + reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Auto Mode requires database mode because its roadmap and loop state must be durable.", getRequestId(request))); + return true; +}; + +const actorFor = (request: FastifyRequest, fallback = "owner") => request.authUser?.username ?? fallback; + +const sendAutonomyError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => { + const code = error instanceof Error ? error.message : "AUTO_MODE_OPERATION_FAILED"; + const conflictCodes = ["AUTO_MODE_DISABLED", "AUTO_MODE_ROADMAP_REQUIRED", "AUTO_MODE_LEASE_STALE", "AUTO_MODE_LEASE_OWNER_MISMATCH", "AUTO_MODE_ROADMAP_CHANGED", "AUTO_MODE_CONFLICT", "AUTO_MODE_DELIVERY_REQUIRED", "AUTO_MODE_DELIVERY_NOT_MERGED", "AUTO_MODE_DELIVERY_ALREADY_LINKED"]; + const status = code === "PROJECT_NOT_FOUND" ? 404 : code === "AUTO_MODE_WORK_ACTIVE" || conflictCodes.includes(code) ? 409 : 400; + const messages: Record = { + PROJECT_NOT_FOUND: "Project not found.", + AUTO_MODE_DISABLED: "Auto Mode is disabled for this project.", + AUTO_MODE_WORK_ACTIVE: "Er wordt nog aan deze roadmap gewerkt. Wacht tot de huidige uitvoering klaar is of pauzeer Auto voordat je de roadmap vervangt.", + AUTO_MODE_ROADMAP_REQUIRED: "Import a roadmap before claiming autonomous work.", + AUTO_MODE_LEASE_STALE: "This autonomous work lease is no longer active. Claim the current work item again.", + AUTO_MODE_LEASE_OWNER_MISMATCH: "This autonomous work lease belongs to a different authenticated executor.", + AUTO_MODE_ROADMAP_CHANGED: "The roadmap changed while this work item was active. Claim a fresh work item.", + AUTO_MODE_CONFLICT: "Auto Mode state changed concurrently. Reload the state and retry the operation.", + AUTO_MODE_DELIVERY_NOT_MERGED: "Implementation is unfinished. Complete exact-head validation and merge on the canonical Gitea execution branch before reporting success.", + AUTO_MODE_DELIVERY_REQUIRED: "Implementation success requires reconciled Gitea validated-merge evidence or a backward-compatible ProjectBrain delivery record.", + AUTO_MODE_DELIVERY_ALREADY_LINKED: "This work item already owns a delivery. Resume it instead of replacing it.", + }; + return reply.status(status).send(errorEnvelope(code, messages[code] ?? "Auto Mode operation failed.", getRequestId(request))); +}; + +export const registerAutonomyRoutes = async (app: FastifyInstance, options: { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; env?: AppEnv } = {}) => { + const dataSource = options.dataSource ?? "fixture"; + const env = options.env; + + app.get("/api/projects/:projectId/autonomy", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + try { + if (env) { + try { + return successEnvelope((await reconcileGiteaAutonomyExecution(env, parsed.projectId, "system:autonomy-read")).state, getRequestId(request)); + } catch (error) { + request.log.warn({ error }, "Gitea Auto execution reconciliation failed during state read; returning durable controller state"); + } + } + return successEnvelope(await getAutonomyState(parsed.projectId), getRequestId(request)); + } + catch (error) { return sendAutonomyError(request, reply, error); } + }); + + app.patch("/api/projects/:projectId/autonomy", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const body = configSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues); + try { + let state = await configureAutonomy(parsed.projectId, body.data, actorFor(request)); + if (state.enabled) { + if (env) { + try { + const sourceSync = await syncGitSources(env, { projectId: parsed.projectId }); + if (sourceSync.errors.length) request.log.warn({ errors: sourceSync.errors }, "Auto Mode repository source refresh completed with warnings"); + } catch (error) { + request.log.warn({ error }, "Auto Mode repository source refresh failed; continuing with durable repository state"); + } + } + state = (await reconcileRepositoryAutonomyRoadmap(parsed.projectId, actorFor(request))).state; + } + return successEnvelope(state, getRequestId(request)); + } + catch (error) { return sendAutonomyError(request, reply, error); } + }); + + app.post("/api/projects/:projectId/autonomy/roadmap", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const body = roadmapSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues); + try { return reply.status(201).send(successEnvelope(await importAutonomyRoadmap(parsed.projectId, body.data, actorFor(request)), getRequestId(request))); } + catch (error) { return sendAutonomyError(request, reply, error); } + }); + + app.post("/api/projects/:projectId/autonomy/tasks", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const body = taskSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues); + try { return reply.status(201).send(successEnvelope(await enqueueAutonomyTask(parsed.projectId, body.data, actorFor(request)), getRequestId(request))); } + catch (error) { return sendAutonomyError(request, reply, error); } + }); + + app.post("/api/projects/:projectId/autonomy/resume", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + try { return successEnvelope(await resumeAutonomy(parsed.projectId, actorFor(request)), getRequestId(request)); } + catch (error) { return sendAutonomyError(request, reply, error); } + }); + + app.post("/api/projects/:projectId/autonomy/claim", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const body = claimSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues); + try { + if (env) { + const beforeClaim = await getAutonomyState(parsed.projectId); + const shouldRefresh = !body.data.runId || !beforeClaim.runs.some((run) => run.id === body.data.runId); + if (shouldRefresh) { + try { + const sourceSync = await syncGitSources(env, { projectId: parsed.projectId }); + if (sourceSync.errors.length) request.log.warn({ errors: sourceSync.errors }, "Auto Mode repository source refresh completed with warnings"); + } catch (error) { + request.log.warn({ error }, "Auto Mode repository source refresh failed; continuing with durable repository state"); + } + } + } + if (env) { + try { + await reconcileGiteaAutonomyExecution(env, parsed.projectId, "system:autonomy-claim"); + } catch (error) { + request.log.warn({ error }, "Gitea Auto execution reconciliation failed before claim; continuing with durable controller state"); + } + } + return successEnvelope(await claimAutonomyWork(parsed.projectId, actorFor(request, "projectbrain-agent"), body.data.runId), getRequestId(request)); + } + catch (error) { return sendAutonomyError(request, reply, error); } + }); + + app.post("/api/projects/:projectId/autonomy/report", async (request, reply) => { + const parsed = params(request, reply); if (!parsed) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const body = reportSchema.safeParse(request.body ?? {}); if (!body.success) return sendValidationError(request, reply, body.error.issues); + try { + if (env) { + try { + await reconcileGiteaAutonomyExecution(env, parsed.projectId, "system:autonomy-report"); + } catch (error) { + request.log.warn({ error }, "Gitea Auto execution reconciliation failed before compatibility report"); + } + } + return successEnvelope(await reportAutonomyWork(parsed.projectId, body.data, actorFor(request, "projectbrain-agent")), getRequestId(request)); + } + catch (error) { return sendAutonomyError(request, reply, error); } + }); +}; diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts new file mode 100644 index 0000000..4f16324 --- /dev/null +++ b/backend/src/routes/blueprints.ts @@ -0,0 +1,217 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { contextPackFixtures } from "../fixtures/contextPackFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { + createBlueprintNodeRunPreviewDto, + createBlueprintPlanDto, + type BlueprintDepthDto, +} from "../services/blueprintEngine.js"; +import { + createAndPersistBlueprintPlan, + getBlueprintImpactReport, + getBlueprintPlanById, + getBlueprintRepoExportBundle, + getLatestBlueprintPlan, + regenerateBlueprintNode, + regenerateStaleBlueprintNodes, + runBlueprintNode, +} from "../repositories/blueprintsRepository.js"; +import { successEnvelope, errorEnvelope } from "../utils/envelope.js"; +import { createBlueprintRepoExportBundleDto, createBlueprintRepoExportZipBufferDto } from "../services/blueprintEngine.js"; + +const depthSchema = z.enum(["auto", "light", "standard", "deep", "full"]).default("auto"); +const planBodySchema = z.object({ depth: depthSchema.optional(), persist: z.boolean().optional() }).optional(); +const nodeRunBodySchema = z.object({ + createDocumentation: z.boolean().default(true), + sendToReview: z.boolean().default(true), +}).optional(); +const exportQuerySchema = z.object({ includeDrafts: z.coerce.boolean().default(false) }); +const regenerateBodySchema = z.object({ limit: z.number().int().positive().max(50).optional(), createDocumentation: z.boolean().default(true), sendToReview: z.boolean().default(true) }).optional(); + +type BlueprintRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; + +const requestId = (request: FastifyRequest) => request.id; +const toDepth = (depth?: "auto" | BlueprintDepthDto): BlueprintDepthDto | undefined => depth === "auto" || !depth ? undefined : depth; + +const notFound = (reply: FastifyReply, request: FastifyRequest, message: string) => + reply.status(404).send(errorEnvelope("BLUEPRINT_NOT_FOUND", message, requestId(request))); + +export const registerBlueprintRoutes = async (app: FastifyInstance, options: BlueprintRouteOptions = {}) => { + const dataSource = options.dataSource ?? "fixture"; + + app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/blueprints/latest", async (request, reply) => { + if (dataSource !== "database") { + const project = findProject(request.params.projectId); + if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`); + const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined); + return successEnvelope(plan, requestId(request)); + } + + const latest = await getLatestBlueprintPlan(request.params.projectId); + if (!latest) return notFound(reply, request, `No persisted blueprint plan was found for project '${request.params.projectId}'.`); + return successEnvelope(latest, requestId(request)); + }); + + app.get<{ Params: { projectId: string; graphId: string } }>("/api/projects/:projectId/blueprints/:graphId", async (request, reply) => { + if (dataSource !== "database") { + const project = findProject(request.params.projectId); + if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`); + const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined); + if (plan.id !== request.params.graphId) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found in fixture mode.`); + return successEnvelope(plan, requestId(request)); + } + + const plan = await getBlueprintPlanById(request.params.projectId, request.params.graphId); + if (!plan) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found.`); + return successEnvelope(plan, requestId(request)); + }); + + app.post<{ + Params: { projectId: string }; + Body: { depth?: BlueprintDepthDto | "auto"; persist?: boolean }; + }>("/api/projects/:projectId/blueprints/plan", async (request, reply) => { + const parsed = planBodySchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_VALIDATION_FAILED", "Invalid blueprint plan request.", requestId(request))); + const requestedDepth = toDepth(parsed.data?.depth); + + if (dataSource === "database" && parsed.data?.persist !== false) { + const plan = await createAndPersistBlueprintPlan(request.params.projectId, requestedDepth); + if (!plan) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`); + return reply.status(201).send(successEnvelope(plan, requestId(request))); + } + + const project = findProject(request.params.projectId); + if (!project) { + return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${request.params.projectId}' was not found.`, requestId(request))); + } + + const plan = createBlueprintPlanDto(project, contextPackFixtures, requestedDepth); + return successEnvelope(plan, requestId(request)); + }); + + + app.get<{ + Params: { projectId: string; graphId: string }; + Querystring: { includeDrafts?: boolean }; + }>("/api/projects/:projectId/blueprints/:graphId/export", async (request, reply) => { + const parsed = exportQuerySchema.safeParse(request.query); + if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_EXPORT_VALIDATION_FAILED", "Invalid blueprint export request.", requestId(request))); + + if (dataSource !== "database") { + const project = findProject(request.params.projectId); + if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`); + const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined); + if (plan.id !== request.params.graphId) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found in fixture mode.`); + const { createBlueprintRepoExportBundleDto } = await import("../services/blueprintEngine.js"); + return successEnvelope(createBlueprintRepoExportBundleDto(plan, { includeDrafts: parsed.data.includeDrafts }), requestId(request)); + } + + const bundle = await getBlueprintRepoExportBundle(request.params.projectId, request.params.graphId, { includeDrafts: parsed.data.includeDrafts }); + if (!bundle) return notFound(reply, request, `Blueprint export '${request.params.graphId}' could not be prepared.`); + return successEnvelope(bundle, requestId(request)); + }); + + + + app.get<{ + Params: { projectId: string; graphId: string }; + Querystring: { includeDrafts?: boolean }; + }>("/api/projects/:projectId/blueprints/:graphId/export.zip", async (request, reply) => { + const parsed = exportQuerySchema.safeParse(request.query); + if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_EXPORT_VALIDATION_FAILED", "Invalid blueprint zip export request.", requestId(request))); + + let bundle: ReturnType | undefined; + if (dataSource !== "database") { + const project = findProject(request.params.projectId); + if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`); + const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined); + if (plan.id !== request.params.graphId) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found in fixture mode.`); + bundle = createBlueprintRepoExportBundleDto(plan, { includeDrafts: parsed.data.includeDrafts }); + } else { + bundle = await getBlueprintRepoExportBundle(request.params.projectId, request.params.graphId, { includeDrafts: parsed.data.includeDrafts }); + } + if (!bundle) return notFound(reply, request, `Blueprint export '${request.params.graphId}' could not be prepared.`); + + const zipBuffer = createBlueprintRepoExportZipBufferDto(bundle); + reply + .header("Content-Type", "application/zip") + .header("Content-Disposition", `attachment; filename="projectbrain-blueprint-${request.params.graphId}.zip"`) + .header("Content-Length", zipBuffer.length) + .send(zipBuffer); + }); + + + app.post<{ + Params: { projectId: string; graphId: string; nodeId: string }; + Body: { createDocumentation?: boolean; sendToReview?: boolean }; + }>("/api/projects/:projectId/blueprints/:graphId/nodes/:nodeId/regenerate", async (request, reply) => { + const parsed = nodeRunBodySchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_NODE_REGENERATION_VALIDATION_FAILED", "Invalid blueprint node regeneration request.", requestId(request))); + + if (dataSource !== "database") { + const project = findProject(request.params.projectId); + if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`); + const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined); + const node = plan.nodes.find((candidate) => candidate.id === request.params.nodeId); + if (!node) return notFound(reply, request, `Blueprint node '${request.params.nodeId}' was not found.`); + return successEnvelope({ graphId: plan.id, nodeId: node.id, previousStatus: node.status, run: createBlueprintNodeRunPreviewDto(plan, node), message: "Local preview generated; database mode is required to persist regeneration." }, requestId(request)); + } + + const result = await regenerateBlueprintNode(request.params.projectId, request.params.graphId, request.params.nodeId, parsed.data ?? {}); + if (!result) return notFound(reply, request, `Blueprint node '${request.params.nodeId}' could not be regenerated.`); + return reply.status(201).send(successEnvelope(result, requestId(request))); + }); + + app.post<{ + Params: { projectId: string; graphId: string }; + Body: { limit?: number; createDocumentation?: boolean; sendToReview?: boolean }; + }>("/api/projects/:projectId/blueprints/:graphId/regenerate-stale", async (request, reply) => { + const parsed = regenerateBodySchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_REGENERATION_VALIDATION_FAILED", "Invalid blueprint stale regeneration request.", requestId(request))); + + if (dataSource !== "database") { + return reply.status(409).send(errorEnvelope("BLUEPRINT_REGENERATION_REQUIRES_DATABASE", "Selective stale-node regeneration requires database mode so generated drafts can be review-gated.", requestId(request))); + } + + const result = await regenerateStaleBlueprintNodes(request.params.projectId, request.params.graphId, parsed.data ?? {}); + if (!result) return notFound(reply, request, `Blueprint '${request.params.graphId}' was not found.`); + return successEnvelope(result, requestId(request)); + }); + + app.get<{ Params: { projectId: string; graphId: string; nodeId: string } }>("/api/projects/:projectId/blueprints/:graphId/nodes/:nodeId/impact", async (request, reply) => { + if (dataSource !== "database") { + const project = findProject(request.params.projectId); + if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`); + const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined); + const { createBlueprintImpactReportDto } = await import("../services/blueprintEngine.js"); + const report = createBlueprintImpactReportDto(plan, request.params.nodeId); + return successEnvelope(report, requestId(request)); + } + const report = await getBlueprintImpactReport(request.params.projectId, request.params.graphId, request.params.nodeId); + if (!report) return notFound(reply, request, `Blueprint impact report for '${request.params.nodeId}' could not be prepared.`); + return successEnvelope(report, requestId(request)); + }); + + app.post<{ + Params: { projectId: string; graphId: string; nodeId: string }; + Body: { createDocumentation?: boolean; sendToReview?: boolean }; + }>("/api/projects/:projectId/blueprints/:graphId/nodes/:nodeId/runs", async (request, reply) => { + const parsed = nodeRunBodySchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("BLUEPRINT_RUN_VALIDATION_FAILED", "Invalid blueprint node run request.", requestId(request))); + + if (dataSource !== "database") { + const project = findProject(request.params.projectId); + if (!project) return notFound(reply, request, `Project '${request.params.projectId}' was not found.`); + const plan = createBlueprintPlanDto(project, contextPackFixtures, undefined); + const node = plan.nodes.find((candidate) => candidate.id === request.params.nodeId); + if (!node) return notFound(reply, request, `Blueprint node '${request.params.nodeId}' was not found.`); + return successEnvelope(createBlueprintNodeRunPreviewDto(plan, node), requestId(request)); + } + + const run = await runBlueprintNode(request.params.projectId, request.params.graphId, request.params.nodeId, parsed.data ?? {}); + if (!run) return notFound(reply, request, `Blueprint node '${request.params.nodeId}' could not be run.`); + return reply.status(201).send(successEnvelope(run, requestId(request))); + }); +}; diff --git a/backend/src/routes/brainstorms.ts b/backend/src/routes/brainstorms.ts new file mode 100644 index 0000000..b80928a --- /dev/null +++ b/backend/src/routes/brainstorms.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { convertBrainstormForProject, createBrainstormForProject, listBrainstormsForProject, updateBrainstormForProject } from "../repositories/brainstormRepository.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js"; + +type BrainstormRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const brainstormParamsSchema = z.object({ projectId: idSchema, brainstormId: idSchema }); +const bodySchema = z.object({ + title: z.string().min(2).max(200), + prompt: z.string().min(1).max(8000), + scope: z.string().min(1).max(80), + mode: z.string().min(1).max(80), + status: z.string().max(80).optional(), + targetEntityId: z.string().max(200).optional(), + targetEntityType: z.string().max(80).optional(), + generatedBy: z.string().max(80).optional(), + contextSummary: z.string().max(1000).optional(), + ideas: z.array(z.unknown()).optional(), + clusters: z.array(z.unknown()).optional(), + outcomes: z.array(z.unknown()).optional(), + selectedIdeaIds: z.array(z.string()).optional(), + nextActions: z.array(z.string()).optional(), +}); +const patchSchema = bodySchema.partial(); +const conversionTargets = ["feature", "documentation", "context_pack", "blueprint_node", "initiative"] as const; +const convertParamsSchema = z.object({ projectId: idSchema, brainstormId: idSchema }); +const convertBodySchema = z.object({ target: z.enum(conversionTargets) }); + +const sendValidationError = (request: FastifyRequest, reply: FastifyReply, validation: z.ZodIssue[]) => reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), validation.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); +const parseParams = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.params); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { const rawKey = request.headers["idempotency-key"]; const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; if (!idempotencyKey?.trim()) { reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); return undefined; } return idempotencyKey; }; + +export const registerBrainstormRoutes = async (app: FastifyInstance, options: BrainstormRouteOptions = {}) => { + const dataSource = options.dataSource ?? "fixture"; + + app.get("/api/projects/:projectId/brainstorms", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; + if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); + return successEnvelope({ items: await listBrainstormsForProject(params.projectId) }, getRequestId(request)); + }); + + app.post("/api/projects/:projectId/brainstorms", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; + if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); + const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); + const parsed = bodySchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues); + const body = successEnvelope(await createBrainstormForProject(params.projectId, parsed.data), getRequestId(request)); + setIdempotentResponse(cacheKey, { statusCode: 201, body }); + return reply.status(201).send(body); + }); + + app.patch("/api/projects/:projectId/brainstorms/:brainstormId", async (request, reply) => { + const params = parseParams(brainstormParamsSchema, request, reply); if (!params) return reply; + if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); + const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); + const parsed = patchSchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues); + const item = await updateBrainstormForProject(params.projectId, params.brainstormId, parsed.data); + if (!item) return reply.status(404).send(errorEnvelope("BRAINSTORM_NOT_FOUND", `Brainstorm '${params.brainstormId}' was not found.`, getRequestId(request))); + const body = successEnvelope(item, getRequestId(request)); + setIdempotentResponse(cacheKey, { statusCode: 200, body }); + return reply.status(200).send(body); + }); + + app.post("/api/projects/:projectId/brainstorms/:brainstormId/convert", async (request, reply) => { + const params = parseParams(convertParamsSchema, request, reply); if (!params) return reply; + if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm conversion persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); + const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); + const parsed = convertBodySchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues); + const result = await convertBrainstormForProject(params.projectId, params.brainstormId, parsed.data.target); + if (!result) return reply.status(404).send(errorEnvelope("BRAINSTORM_NOT_FOUND", `Brainstorm '${params.brainstormId}' was not found.`, getRequestId(request))); + const body = successEnvelope(result, getRequestId(request)); + setIdempotentResponse(cacheKey, { statusCode: 200, body }); + return reply.status(200).send(body); + }); +}; diff --git a/backend/src/routes/builds.ts b/backend/src/routes/builds.ts new file mode 100644 index 0000000..53b7039 --- /dev/null +++ b/backend/src/routes/builds.ts @@ -0,0 +1,286 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { + applyBuildFilters, + createBuild, + findBuild, + listBuildsForProject, +} from "../fixtures/buildFixtures.js"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { listContextPacksForProject } from "../fixtures/contextPackFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { selectContextPacks } from "../services/contextPackSelector.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + BuildListFilters, + BuildStatus, + BuildValidationResult, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + getIdempotencyKey, + getIdempotentResponse, + setIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const buildParamsSchema = z.object({ projectId: idSchema, buildId: idSchema }); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const buildQuerySchema = paginationQuerySchema.extend({ + agent: z.string().optional(), + result: z.string().optional(), + status: z.string().optional(), + featureId: z.string().optional(), + contextPackId: z.string().optional(), +}); +const createBuildBodySchema = z.object({ + goal: z.string().optional(), + agent: z.enum(["codex", "claude", "gpt", "cursor", "manual"]).optional(), + contextPackId: z.string().optional(), + promptId: z.string().optional(), + filesChanged: z.array(z.string()).optional(), + validationResult: z.string().optional(), + lessonsLearned: z.array(z.string()).optional(), + linkedFeatureIds: z.array(z.string()).optional(), + autoSelectContext: z.boolean().optional().default(true), + selectionIntent: z.string().max(2000).optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + sendProjectNotFound(request, reply, projectId); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const leftValue = left[field]; + const rightValue = right[field]; + if (leftValue === rightValue) return 0; + const comparison = String(leftValue ?? "").localeCompare( + String(rightValue ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +type BuildQuery = z.output; +type PaginationQuery = z.output; + +const toFilters = (query: BuildQuery): BuildListFilters => ({ + ...(query.agent ? { agent: splitCsv(query.agent) } : {}), + ...(query.result + ? { result: splitCsv(query.result) as BuildValidationResult[] } + : {}), + ...(query.status ? { status: splitCsv(query.status) as BuildStatus[] } : {}), + ...(query.featureId ? { featureId: query.featureId } : {}), + ...(query.contextPackId ? { contextPackId: query.contextPackId } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const toPaginationInput = ( + query: PaginationQuery, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + return idempotencyKey; +}; + +export const registerBuildRoutes = async (app: FastifyInstance) => { + app.get("/api/projects/:projectId/builds", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const query = parseQuery(buildQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + const builds = sortByField( + applyBuildFilters(listBuildsForProject(params.projectId), filters), + query.sort, + ); + + return successEnvelope( + paginate(builds, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get("/api/projects/:projectId/builds/:buildId", async (request, reply) => { + const params = parseParams(buildParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const build = findBuild(params.projectId, params.buildId); + if (!build) { + return reply.status(404).send( + errorEnvelope( + "BUILD_NOT_FOUND", + `Build '${params.buildId}' was not found.`, + getRequestId(request), + ), + ); + } + + return successEnvelope(build, getRequestId(request)); + }); + + app.post("/api/projects/:projectId/builds", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = createBuildBodySchema.safeParse(request.body); + if (!parsedBody.success || !parsedBody.data.goal?.trim()) { + return reply.status(400).send( + errorEnvelope( + "BUILD_VALIDATION_FAILED", + "goal is required to create a build.", + getRequestId(request), + ), + ); + } + + const project = findProject(params.projectId); + const contextSelection = parsedBody.data.autoSelectContext === false && parsedBody.data.contextPackId + ? { + primaryContextPackId: parsedBody.data.contextPackId, + selectedContextPackIds: [parsedBody.data.contextPackId], + decisionSummary: `Explicit context pack '${parsedBody.data.contextPackId}' was supplied for this build.`, + } + : selectContextPacks( + params.projectId, + listContextPacksForProject(params.projectId), + { + mode: parsedBody.data.agent === "codex" ? "codex" : "validation", + intent: parsedBody.data.selectionIntent?.trim() || parsedBody.data.goal, + targetType: "build", + maxPacks: 3, + }, + project?.activeContextPackId, + ); + + const body = successEnvelope( + createBuild(params.projectId, { + ...parsedBody.data, + goal: parsedBody.data.goal, + contextPackId: parsedBody.data.contextPackId ?? contextSelection.primaryContextPackId, + selectedContextPackIds: contextSelection.selectedContextPackIds, + contextSelectionSummary: contextSelection.decisionSummary, + }), + getRequestId(request), + ); + setIdempotentResponse(cacheKey, { statusCode: 201, body }); + return reply.status(201).send(body); + }); +}; diff --git a/backend/src/routes/capture.ts b/backend/src/routes/capture.ts new file mode 100644 index 0000000..802df4e --- /dev/null +++ b/backend/src/routes/capture.ts @@ -0,0 +1,442 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { + applyCaptureFilters, + classifyCaptureItem, + createCaptureItem, + findCaptureItem, + listCaptureItemsForProject, + markCaptureItemInReview, +} from "../fixtures/captureFixtures.js"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import type { AppEnv } from "../config/env.js"; +import { createReviewItemFromCapture } from "../fixtures/reviewQueueFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + CaptureClassificationType, + CaptureItemListFilters, + CaptureItemStatus, + CaptureItemType, + ReviewMutationResponseDto, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + classifyCaptureItemInDatabase, + createCaptureItemInDatabase, + getCaptureItemFromDatabase, + listCaptureItemsFromDatabase, +} from "../repositories/captureRepository.js"; +import { sendCaptureItemToReviewInDatabase } from "../repositories/reviewQueueRepository.js"; +import { + getIdempotencyKey, + getDurableIdempotentResponse, + setDurableIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const captureParamsSchema = z.object({ + projectId: idSchema, + captureItemId: idSchema, +}); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const captureQuerySchema = paginationQuerySchema.extend({ + status: z.string().optional(), + type: z.string().optional(), + source: z.string().optional(), + confidence: z.coerce.number().optional(), + classificationType: z.string().optional(), +}); +const captureTypes = [ + "note", + "idea", + "bug", + "build_summary", + "codex_output", + "chatgpt_output", + "error_log", + "docs_snippet", +] as const; +const captureBodySchema = z.object({ + type: z.enum(captureTypes).optional(), + content: z.string().optional(), + source: z.string().optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare( + String(right[field] ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +const toPaginationInput = ( + query: z.output, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const toFilters = ( + query: z.output, +): CaptureItemListFilters => ({ + ...(query.status ? { status: splitCsv(query.status) as CaptureItemStatus[] } : {}), + ...(query.type ? { type: splitCsv(query.type) as CaptureItemType[] } : {}), + ...(query.source ? { source: splitCsv(query.source) } : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.classificationType + ? { + classificationType: splitCsv( + query.classificationType, + ) as CaptureClassificationType[], + } + : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + return idempotencyKey; +}; + +const sendCaptureNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + captureItemId: string, +) => + reply.status(404).send( + errorEnvelope( + "CAPTURE_ITEM_NOT_FOUND", + `Capture item '${captureItemId}' was not found.`, + getRequestId(request), + ), + ); + +type CaptureRoutesOptions = { + dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; +}; + +export const registerCaptureRoutes = async ( + app: FastifyInstance, + options: CaptureRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + app.get("/api/projects/:projectId/capture-items", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(captureQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + + if (useDatabase) { + const result = await listCaptureItemsFromDatabase( + params.projectId, + filters, + { + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + }, + ); + if (!result) { + return reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${params.projectId}' was not found.`, + getRequestId(request), + ), + ); + } + return successEnvelope(result, getRequestId(request)); + } + + const items = sortByField( + applyCaptureFilters(listCaptureItemsForProject(params.projectId), filters), + query.sort, + ); + + return successEnvelope( + paginate(items, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get( + "/api/projects/:projectId/capture-items/:captureItemId", + async (request, reply) => { + const params = parseParams(captureParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const item = useDatabase + ? await getCaptureItemFromDatabase(params.projectId, params.captureItemId) + : findCaptureItem(params.projectId, params.captureItemId); + if (!item) return sendCaptureNotFound(request, reply, params.captureItemId); + return successEnvelope(item, getRequestId(request)); + }, + ); + + app.post("/api/projects/:projectId/capture-items", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = await getDurableIdempotentResponse(cacheKey, useDatabase); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = captureBodySchema.safeParse(request.body); + if ( + !parsedBody.success || + !parsedBody.data.type || + !parsedBody.data.content?.trim() + ) { + return reply.status(400).send( + errorEnvelope( + "CAPTURE_VALIDATION_FAILED", + "type and content are required to create a capture item.", + getRequestId(request), + ), + ); + } + + const created = useDatabase + ? await createCaptureItemInDatabase(params.projectId, { + type: parsedBody.data.type, + content: parsedBody.data.content, + source: parsedBody.data.source, + }) + : createCaptureItem(params.projectId, { + type: parsedBody.data.type, + content: parsedBody.data.content, + source: parsedBody.data.source, + }); + if (!created) { + return reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${params.projectId}' was not found.`, + getRequestId(request), + ), + ); + } + const body = successEnvelope(created, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body }, useDatabase); + return reply.status(201).send(body); + }); + + app.patch( + "/api/projects/:projectId/capture-items/:captureItemId/classify", + async (request, reply) => { + const params = parseParams(captureParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = await getDurableIdempotentResponse(cacheKey, useDatabase); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const classification = useDatabase + ? await classifyCaptureItemInDatabase(params.projectId, params.captureItemId) + : classifyCaptureItem( + params.projectId, + params.captureItemId, + ); + if (!classification) { + return sendCaptureNotFound(request, reply, params.captureItemId); + } + if (classification === "CAPTURE_ALREADY_IN_REVIEW") { + return reply.status(409).send(errorEnvelope( + "CAPTURE_ALREADY_IN_REVIEW", + "A capture that is already in review can no longer be reclassified.", + getRequestId(request), + )); + } + + const body = successEnvelope(classification, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + return reply.status(200).send(body); + }, + ); + + app.post( + "/api/projects/:projectId/capture-items/:captureItemId/send-to-review", + async (request, reply) => { + const params = parseParams(captureParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = await getDurableIdempotentResponse(cacheKey, useDatabase); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const item = useDatabase + ? await getCaptureItemFromDatabase(params.projectId, params.captureItemId) + : findCaptureItem(params.projectId, params.captureItemId); + if (!item) return sendCaptureNotFound(request, reply, params.captureItemId); + + if (!useDatabase && (item.status === "in_review" || item.linkedReviewItemId)) { + return reply.status(409).send(errorEnvelope( + "CAPTURE_ALREADY_IN_REVIEW", + "This capture is already linked to a review item.", + getRequestId(request), + )); + } + if (!useDatabase && (item.status !== "classified" || !item.classificationType)) { + return reply.status(409).send(errorEnvelope( + "CAPTURE_NOT_CLASSIFIED", + "Classify the capture before sending it to review.", + getRequestId(request), + )); + } + + const reviewItem = useDatabase + ? await sendCaptureItemToReviewInDatabase(params.projectId, params.captureItemId) + : createReviewItemFromCapture(item); + if (!reviewItem) { + return reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${params.projectId}' was not found.`, + getRequestId(request), + ), + ); + } + if (reviewItem === "CAPTURE_ALREADY_IN_REVIEW") { + return reply.status(409).send(errorEnvelope( + "CAPTURE_ALREADY_IN_REVIEW", + "This capture is already linked to a review item.", + getRequestId(request), + )); + } + if (reviewItem === "CAPTURE_NOT_CLASSIFIED") { + return reply.status(409).send(errorEnvelope( + "CAPTURE_NOT_CLASSIFIED", + "Classify the capture before sending it to review.", + getRequestId(request), + )); + } + if (!useDatabase) { + markCaptureItemInReview(params.projectId, params.captureItemId, reviewItem.id); + } + + const body = successEnvelope( + { + success: true, + message: `Capture item '${params.captureItemId}' sent to review.`, + updatedEntityId: params.captureItemId, + createdEntityId: reviewItem.id, + affectedEntities: [params.captureItemId, reviewItem.id], + }, + getRequestId(request), + ); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body }, useDatabase); + return reply.status(201).send(body); + }, + ); +}; diff --git a/backend/src/routes/checks.ts b/backend/src/routes/checks.ts new file mode 100644 index 0000000..c800583 --- /dev/null +++ b/backend/src/routes/checks.ts @@ -0,0 +1,465 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { + applyCheckFilters, + applyReviewAction, + checkFilterValues, + convertCheck, + findCheck, + listChecksForProject, + toReviewBoard, +} from "../fixtures/checkFixtures.js"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + ApprovalState, + CheckCategory, + CheckItemDto, + CheckListFilters, + CheckPipelineStage, + CheckSeverity, + CheckStatus, + ConvertTarget, + ReviewAction, + ReviewMutationResponseDto, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + getIdempotencyKey, + getIdempotentResponse, + setIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); + +const projectParamsSchema = z.object({ + projectId: idSchema, +}); + +const checkParamsSchema = z.object({ + projectId: idSchema, + checkId: idSchema, +}); + +const sourceParamsSchema = z.object({ + projectId: idSchema, + sourceId: idSchema, +}); + +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); + +const checksQuerySchema = paginationQuerySchema.extend({ + status: z.string().optional(), + severity: z.string().optional(), + source: z.string().optional(), + confidence: z.coerce.number().int().min(0).max(100).optional(), + approvalState: z.enum(["pending", "approved", "rejected", "blocked"]).optional(), + category: z.string().optional(), +}); + +const reviewBodySchema = z.object({ + action: z.string().optional(), + reason: z.string().optional(), + targetEntityId: z.string().optional(), +}); + +const convertBodySchema = z.object({ + target: z.string().optional(), + title: z.string().optional(), + reason: z.string().optional(), +}); + +const reviewActions = ["accept", "reject", "ignore", "merge"] as const; +const convertTargets = ["bug", "task", "memory_rule", "decision"] as const; + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + sendProjectNotFound(request, reply, projectId); + return undefined; + } + + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value + ?.split(",") + .map((item) => item.trim()) + .filter(Boolean) ?? []; + +const filterKnownValues = ( + values: string[], + allowed: readonly T[], +): T[] => values.filter((value): value is T => allowed.includes(value as T)); + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + + return [...items].sort((left, right) => { + const leftValue = + field === "confidence" + ? (left.trust as { confidence?: number })?.confidence + : left[field]; + const rightValue = + field === "confidence" + ? (right.trust as { confidence?: number })?.confidence + : right[field]; + if (leftValue === rightValue) return 0; + const comparison = String(leftValue ?? "").localeCompare( + String(rightValue ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +type ChecksQuery = z.output; +type PaginationQuery = z.output; + +const toFilters = (query: ChecksQuery): CheckListFilters => ({ + ...(query.status + ? { + status: filterKnownValues( + splitCsv(query.status), + checkFilterValues.status, + ) as CheckStatus[], + } + : {}), + ...(query.severity + ? { + severity: filterKnownValues( + splitCsv(query.severity), + checkFilterValues.severity, + ) as CheckSeverity[], + } + : {}), + ...(query.source + ? { + source: filterKnownValues( + splitCsv(query.source), + checkFilterValues.source, + ) as CheckPipelineStage[], + } + : {}), + ...(query.category + ? { + category: filterKnownValues( + splitCsv(query.category), + checkFilterValues.category, + ) as CheckCategory[], + } + : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.approvalState + ? { approvalState: query.approvalState as ApprovalState } + : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const toPaginationInput = ( + query: PaginationQuery, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const sendCheckNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + checkId: string, +) => + reply.status(404).send( + errorEnvelope( + "CHECK_NOT_FOUND", + `Check '${checkId}' was not found.`, + getRequestId(request), + ), + ); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + + return idempotencyKey; +}; + +const sendReplayOrUndefined = ( + reply: FastifyReply, + key: string, +): boolean => { + const replay = getIdempotentResponse(key); + if (!replay) return false; + + reply.status(replay.statusCode).send(replay.body); + return true; +}; + +export const registerCheckRoutes = async (app: FastifyInstance) => { + app.get("/api/projects/:projectId/checks", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const query = parseQuery(checksQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + const checks = sortByField( + applyCheckFilters(listChecksForProject(params.projectId), filters), + query.sort, + ); + + return successEnvelope( + paginate(checks, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get("/api/projects/:projectId/review-board", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + return successEnvelope( + toReviewBoard(listChecksForProject(params.projectId)), + getRequestId(request), + ); + }); + + app.get("/api/projects/:projectId/checks/:checkId", async (request, reply) => { + const params = parseParams(checkParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const check = findCheck(params.projectId, params.checkId); + if (!check) return sendCheckNotFound(request, reply, params.checkId); + + return successEnvelope(check, getRequestId(request)); + }); + + app.patch( + "/api/projects/:projectId/checks/:sourceId/review", + async (request, reply) => { + const params = parseParams(sourceParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + + const cacheKey = getIdempotencyKey( + request.method, + request.url, + idempotencyKey, + ); + if (sendReplayOrUndefined(reply, cacheKey)) return reply; + + const parsedBody = reviewBodySchema.safeParse(request.body); + if (!parsedBody.success) { + sendValidationError(request, reply, parsedBody.error.issues); + return reply; + } + + const action = parsedBody.data.action as ReviewAction | undefined; + if (!action || !reviewActions.includes(action)) { + return reply.status(400).send( + errorEnvelope( + "REVIEW_ACTION_INVALID", + "Review action must be accept, reject, ignore, or merge.", + getRequestId(request), + ), + ); + } + + if (action === "merge" && !parsedBody.data.targetEntityId?.trim()) { + return reply.status(409).send( + errorEnvelope( + "REVIEW_ACTION_CONFLICT", + "targetEntityId is required when action is merge.", + getRequestId(request), + ), + ); + } + + if (!findCheck(params.projectId, params.sourceId)) { + return sendCheckNotFound(request, reply, params.sourceId); + } + + const response = applyReviewAction( + params.projectId, + params.sourceId, + action, + parsedBody.data.reason, + parsedBody.data.targetEntityId, + ); + if (!response) return sendCheckNotFound(request, reply, params.sourceId); + + const body = successEnvelope( + response, + getRequestId(request), + ); + setIdempotentResponse(cacheKey, { statusCode: 200, body }); + return reply.status(200).send(body); + }, + ); + + app.post( + "/api/projects/:projectId/checks/:sourceId/convert", + async (request, reply) => { + const params = parseParams(sourceParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + + const cacheKey = getIdempotencyKey( + request.method, + request.url, + idempotencyKey, + ); + if (sendReplayOrUndefined(reply, cacheKey)) return reply; + + const parsedBody = convertBodySchema.safeParse(request.body); + if (!parsedBody.success) { + sendValidationError(request, reply, parsedBody.error.issues); + return reply; + } + + const target = parsedBody.data.target as ConvertTarget | undefined; + if (!target || !convertTargets.includes(target)) { + return reply.status(400).send( + errorEnvelope( + "CONVERT_TARGET_INVALID", + "Convert target must be bug, task, memory_rule, or decision.", + getRequestId(request), + ), + ); + } + + if (!findCheck(params.projectId, params.sourceId)) { + return sendCheckNotFound(request, reply, params.sourceId); + } + + const response = convertCheck( + params.projectId, + params.sourceId, + target, + parsedBody.data.title, + parsedBody.data.reason, + ); + if (!response) { + return reply.status(409).send( + errorEnvelope( + "CONVERSION_FAILED", + `Conversion failed for check '${params.sourceId}'.`, + getRequestId(request), + ), + ); + } + + const body = successEnvelope( + response, + getRequestId(request), + ); + setIdempotentResponse(cacheKey, { statusCode: 200, body }); + return reply.status(200).send(body); + }, + ); +}; diff --git a/backend/src/routes/contextPacks.ts b/backend/src/routes/contextPacks.ts new file mode 100644 index 0000000..135245b --- /dev/null +++ b/backend/src/routes/contextPacks.ts @@ -0,0 +1,415 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { + applyContextPackFilters, + createContextPack, + findContextPack, + findContextPackSummary, + listContextPacksForProject, +} from "../fixtures/contextPackFixtures.js"; +import { + createContextPackForProject as createDatabaseContextPack, + getContextPackBySlugOrId as getDatabaseContextPackBySlugOrId, + getContextPackSummaryBySlugOrId as getDatabaseContextPackSummaryBySlugOrId, + listContextPacksForProject as listDatabaseContextPacksForProject, + projectExists as databaseProjectExists, +} from "../repositories/contextPacksRepository.js"; +import { getProjectBySlugOrId as getDatabaseProjectBySlugOrId } from "../repositories/projectsRepository.js"; +import { selectContextPacks } from "../services/contextPackSelector.js"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findFeature } from "../fixtures/featureFixtures.js"; +import { getFeatureBySlugOrId as getDatabaseFeatureBySlugOrId } from "../repositories/featuresRepository.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + ContextPackCategory, + ContextPackGeneratedBy, + ContextPackListFilters, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); + +const projectParamsSchema = z.object({ + projectId: idSchema, +}); + +const contextPackParamsSchema = z.object({ + projectId: idSchema, + contextPackId: idSchema, +}); + +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); + +const contextPackQuerySchema = paginationQuerySchema.extend({ + category: z.string().optional(), + generatedBy: z.string().optional(), + source: z.string().optional(), + confidence: z.coerce.number().int().min(0).max(100).optional(), +}); + +const sourceTypeSchema = z.enum([ + "workspace", + "focus_area", + "feature", + "check", + "manual", +]); + +const createContextPackBodySchema = z.object({ + sourceType: z.string().optional(), + sourceId: z.string().optional(), + title: z.string().optional(), + includeDNA: z.boolean().optional(), + includeFeatures: z.boolean().optional(), + includeEvidence: z.boolean().optional(), + includeMemoryRules: z.boolean().optional(), + includeRisks: z.boolean().optional(), + includeRegressions: z.boolean().optional(), + includeArchitecture: z.boolean().optional(), +}); + +const selectionModeSchema = z.enum(["workspace", "ai", "codex", "documentation", "validation"]); +const selectionTargetTypeSchema = z.enum([ + "project", + "feature", + "bug", + "task", + "decision", + "memory-rule", + "knowledge", + "documentation", + "build", +]); + +const contextPackSelectionBodySchema = z.object({ + mode: selectionModeSchema.optional().default("workspace"), + intent: z.string().max(2000).optional(), + targetType: selectionTargetTypeSchema.optional(), + targetId: z.string().min(1).max(160).optional(), + maxPacks: z.coerce.number().int().min(1).max(8).optional().default(3), + includeUnapproved: z.boolean().optional().default(false), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const sendContextPackValidationFailed = ( + request: FastifyRequest, + reply: FastifyReply, + message: string, +) => + reply.status(400).send( + errorEnvelope( + "CONTEXT_PACK_VALIDATION_FAILED", + message, + getRequestId(request), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const ensureProject = async ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, + useDatabase: boolean, +) => { + const exists = useDatabase + ? await databaseProjectExists(projectId) + : Boolean(findProject(projectId)); + if (!exists) { + sendProjectNotFound(request, reply, projectId); + return false; + } + + return true; +}; + +const splitCsv = (value: string | undefined): string[] => + value + ?.split(",") + .map((item) => item.trim()) + .filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + + return [...items].sort((left, right) => { + const leftValue = + field === "confidence" ? (left.trust as { confidence?: number })?.confidence : left[field]; + const rightValue = + field === "confidence" ? (right.trust as { confidence?: number })?.confidence : right[field]; + if (leftValue === rightValue) return 0; + const comparison = String(leftValue ?? "").localeCompare( + String(rightValue ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +type ContextPackQuery = z.output; +type PaginationQuery = z.output; + +const toFilters = (query: ContextPackQuery): ContextPackListFilters => ({ + ...(query.category + ? { category: splitCsv(query.category) as ContextPackCategory[] } + : {}), + ...(query.generatedBy + ? { generatedBy: splitCsv(query.generatedBy) as ContextPackGeneratedBy[] } + : {}), + ...(query.source + ? { source: splitCsv(query.source) as ContextPackGeneratedBy[] } + : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const toPaginationInput = ( + query: PaginationQuery, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +type ContextPackRoutesOptions = { + dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; +}; + +export const registerContextPackRoutes = async ( + app: FastifyInstance, + options: ContextPackRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + app.get("/api/projects/:projectId/context-packs", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply; + + const query = parseQuery(contextPackQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + const contextPacks = useDatabase + ? await listDatabaseContextPacksForProject(params.projectId) + : listContextPacksForProject(params.projectId); + if (!contextPacks) return sendProjectNotFound(request, reply, params.projectId); + + const filteredContextPacks = sortByField( + applyContextPackFilters(contextPacks, filters), + query.sort, + ); + + return successEnvelope( + paginate(filteredContextPacks, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + + app.post("/api/projects/:projectId/context-packs/select", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply; + + const parsedBody = contextPackSelectionBodySchema.safeParse(request.body ?? {}); + if (!parsedBody.success) { + return sendValidationError(request, reply, parsedBody.error.issues); + } + + const [contextPacks, project] = await Promise.all([ + useDatabase + ? listDatabaseContextPacksForProject(params.projectId) + : Promise.resolve(listContextPacksForProject(params.projectId)), + useDatabase + ? getDatabaseProjectBySlugOrId(params.projectId) + : Promise.resolve(findProject(params.projectId)), + ]); + + const activeContextPackId = project?.activeContextPackId ?? project?.workspaceState?.activeContextPackId; + const selection = selectContextPacks( + params.projectId, + contextPacks ?? [], + parsedBody.data, + activeContextPackId, + ); + + return successEnvelope(selection, getRequestId(request)); + }); + + app.get( + "/api/projects/:projectId/context-packs/:contextPackId/summary", + async (request, reply) => { + const params = parseParams(contextPackParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply; + + const contextPack = useDatabase + ? await getDatabaseContextPackSummaryBySlugOrId(params.projectId, params.contextPackId) + : findContextPackSummary( + params.projectId, + params.contextPackId, + ); + if (!contextPack) { + return reply.status(404).send( + errorEnvelope( + "CONTEXT_PACK_NOT_FOUND", + `Context pack '${params.contextPackId}' was not found.`, + getRequestId(request), + ), + ); + } + + return successEnvelope(contextPack, getRequestId(request)); + }, + ); + + app.get( + "/api/projects/:projectId/context-packs/:contextPackId", + async (request, reply) => { + const params = parseParams(contextPackParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply; + + const contextPack = useDatabase + ? await getDatabaseContextPackBySlugOrId(params.projectId, params.contextPackId) + : findContextPack(params.projectId, params.contextPackId); + if (!contextPack) { + return reply.status(404).send( + errorEnvelope( + "CONTEXT_PACK_NOT_FOUND", + `Context pack '${params.contextPackId}' was not found.`, + getRequestId(request), + ), + ); + } + + return successEnvelope(contextPack, getRequestId(request)); + }, + ); + + app.post("/api/projects/:projectId/context-packs", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) return reply; + + const parsedBody = createContextPackBodySchema.safeParse(request.body); + if (!parsedBody.success) { + return sendContextPackValidationFailed( + request, + reply, + "Context pack request body is invalid.", + ); + } + + const parsedSourceType = sourceTypeSchema.safeParse(parsedBody.data.sourceType); + if (!parsedSourceType.success) { + return sendContextPackValidationFailed( + request, + reply, + "sourceType must be one of workspace, focus_area, feature, check, or manual.", + ); + } + + if ( + parsedSourceType.data === "feature" && + (!parsedBody.data.sourceId || + !(useDatabase + ? await getDatabaseFeatureBySlugOrId(params.projectId, parsedBody.data.sourceId) + : findFeature(params.projectId, parsedBody.data.sourceId))) + ) { + return sendContextPackValidationFailed( + request, + reply, + "sourceId must reference an existing feature when sourceType is feature.", + ); + } + + const contextPack = useDatabase + ? await createDatabaseContextPack(params.projectId, { + ...parsedBody.data, + sourceType: parsedSourceType.data, + }) + : createContextPack(params.projectId, { + ...parsedBody.data, + sourceType: parsedSourceType.data, + }); + + return reply + .status(201) + .send(successEnvelope(contextPack, getRequestId(request))); + }); +}; diff --git a/backend/src/routes/documentation.ts b/backend/src/routes/documentation.ts new file mode 100644 index 0000000..73254c4 --- /dev/null +++ b/backend/src/routes/documentation.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { createDocumentationItem, getDocumentationBySlugOrId, listDocumentationForProject, projectExists, updateDocumentationItem } from "../repositories/p8KnowledgeMemoryRepository.js"; +import { listContextPacksForProject } from "../repositories/contextPacksRepository.js"; +import { getProjectBySlugOrId } from "../repositories/projectsRepository.js"; +import { selectContextPacks } from "../services/contextPackSelector.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js"; + +type DocumentationRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const docParamsSchema = z.object({ projectId: idSchema, documentationId: idSchema }); +const querySchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(25), kind: z.string().optional(), status: z.string().optional(), approvalState: z.string().optional(), search: z.string().optional() }); +const bodySchema = z.object({ title: z.string().optional(), kind: z.string().optional(), status: z.string().optional(), linkedFeatureIds: z.array(z.string()).optional(), content: z.string().optional(), approvalState: z.enum(["pending", "approved", "rejected", "blocked"]).optional(), autoSelectContext: z.boolean().optional().default(true), selectionIntent: z.string().max(2000).optional(), contextPackId: z.string().optional() }); +const splitCsv = (value: string | undefined): string[] => value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; +const sendValidationError = (request: FastifyRequest, reply: FastifyReply, validation: z.ZodIssue[]) => reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), validation.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); +const parseParams = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.params); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const parseQuery = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.query); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { const rawKey = request.headers["idempotency-key"]; const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; if (!idempotencyKey?.trim()) { reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); return undefined; } return idempotencyKey; }; +const sendDocumentationNotFound = (request: FastifyRequest, reply: FastifyReply, documentationId: string) => reply.status(404).send(errorEnvelope("DOCUMENTATION_NOT_FOUND", `Documentation item '${documentationId}' was not found.`, getRequestId(request))); +export const registerDocumentationRoutes = async (app: FastifyInstance, options: DocumentationRouteOptions = {}) => { + const dataSource = options.dataSource ?? "fixture"; + app.get("/api/projects/:projectId/documentation-items", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Documentation write persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); if (!(await projectExists(params.projectId))) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); const query = parseQuery(querySchema, request, reply); if (!query) return reply; const docs = await listDocumentationForProject(params.projectId, { kind: splitCsv(query.kind), status: splitCsv(query.status), approvalState: splitCsv(query.approvalState), search: query.search }); const start = (query.page - 1) * query.pageSize; return successEnvelope({ items: docs.slice(start, start + query.pageSize), total: docs.length, page: query.page, pageSize: query.pageSize }, getRequestId(request)); }); + app.get("/api/projects/:projectId/documentation-items/:documentationId", async (request, reply) => { const params = parseParams(docParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Documentation write persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const item = await getDocumentationBySlugOrId(params.projectId, params.documentationId); if (!item) return sendDocumentationNotFound(request, reply, params.documentationId); return successEnvelope(item, getRequestId(request)); }); + app.post("/api/projects/:projectId/documentation-items", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Documentation write persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsed = bodySchema.safeParse(request.body); if (!parsed.success || !parsed.data.title?.trim()) return reply.status(400).send(errorEnvelope("DOCUMENTATION_VALIDATION_FAILED", "title is required to create documentation.", getRequestId(request))); const project = await getProjectBySlugOrId(params.projectId); const contextPacks = await listContextPacksForProject(params.projectId) ?? []; const contextSelection = parsed.data.autoSelectContext === false && parsed.data.contextPackId ? { primaryContextPackId: parsed.data.contextPackId, selectedContextPackIds: [parsed.data.contextPackId], decisionSummary: `Explicit context pack '${parsed.data.contextPackId}' was supplied for documentation generation.` } : selectContextPacks(params.projectId, contextPacks, { mode: "documentation", intent: parsed.data.selectionIntent?.trim() || `${parsed.data.title}. ${parsed.data.content ?? ""}`, targetType: "documentation", maxPacks: 3 }, project?.activeContextPackId); const body = successEnvelope(await createDocumentationItem(params.projectId, { ...parsed.data, title: parsed.data.title, primaryContextPackId: parsed.data.contextPackId ?? contextSelection.primaryContextPackId, selectedContextPackIds: contextSelection.selectedContextPackIds, contextSelectionSummary: contextSelection.decisionSummary }), getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 201, body }); return reply.status(201).send(body); }); + app.patch("/api/projects/:projectId/documentation-items/:documentationId", async (request, reply) => { const params = parseParams(docParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Documentation write persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsed = bodySchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues); const project = await getProjectBySlugOrId(params.projectId); const contextPacks = await listContextPacksForProject(params.projectId) ?? []; const contextSelection = parsed.data.autoSelectContext === false && parsed.data.contextPackId ? { primaryContextPackId: parsed.data.contextPackId, selectedContextPackIds: [parsed.data.contextPackId], decisionSummary: `Explicit context pack '${parsed.data.contextPackId}' was supplied for documentation update.` } : selectContextPacks(params.projectId, contextPacks, { mode: "documentation", intent: parsed.data.selectionIntent?.trim() || `${parsed.data.title ?? params.documentationId}. ${parsed.data.content ?? ""}`, targetType: "documentation", targetId: params.documentationId, maxPacks: 3 }, project?.activeContextPackId); const item = await updateDocumentationItem(params.projectId, params.documentationId, { ...parsed.data, primaryContextPackId: parsed.data.contextPackId ?? contextSelection.primaryContextPackId, selectedContextPackIds: contextSelection.selectedContextPackIds, contextSelectionSummary: contextSelection.decisionSummary }); if (!item) return sendDocumentationNotFound(request, reply, params.documentationId); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); }); +}; diff --git a/backend/src/routes/entraAuth.ts b/backend/src/routes/entraAuth.ts new file mode 100644 index 0000000..0679e96 --- /dev/null +++ b/backend/src/routes/entraAuth.ts @@ -0,0 +1,74 @@ +import type { FastifyInstance } from "fastify"; +import { apiPaths } from "@projectbrain/contracts"; +import type { AppEnv } from "../config/env.js"; +import { SESSION_COOKIE, sessionCookieOptions } from "../plugins/authentication.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { authenticate, createSession } from "../security/authStore.js"; +import { consumeEntraAttempt, createEntraAuthorization, exchangeEntraCode, getEntraLinkStatus, isEntraConfigured, linkEntraIdentity, resolveEntraUser, unlinkEntraIdentity } from "../security/entraOidc.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +const callbackQuery = (query: unknown) => { + const value = query as Record | undefined; + return { code: typeof value?.code === "string" ? value.code : undefined, state: typeof value?.state === "string" ? value.state : undefined, error: typeof value?.error === "string" ? value.error : undefined }; +}; +const requestedReturnTo = (query: unknown) => { + const value = (query as Record | undefined)?.returnTo; + return typeof value === "string" ? value : undefined; +}; +const errorRedirect = (env: AppEnv, code: string) => new URL(`/login?entraError=${encodeURIComponent(code)}`, env.PROJECTBRAIN_PUBLIC_URL).toString(); + +export const registerEntraAuthRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + const { env } = options; + app.get(apiPaths.auth.entraConfig, async (request) => successEnvelope({ enabled: isEntraConfigured(env), provider: "microsoft-entra" }, getRequestId(request))); + + app.get(apiPaths.auth.entraStart, { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (request, reply) => { + if (!isEntraConfigured(env)) return reply.status(503).send(errorEnvelope("ENTRA_NOT_CONFIGURED", "Microsoft Entra login is not configured", getRequestId(request))); + const authorizationUrl = await createEntraAuthorization(env, { purpose: "login", returnTo: requestedReturnTo(request.query) }); + return reply.redirect(authorizationUrl); + }); + + app.get(apiPaths.auth.entraCallback, { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, async (request, reply) => { + if (!isEntraConfigured(env)) return reply.status(503).send(errorEnvelope("ENTRA_NOT_CONFIGURED", "Microsoft Entra login is not configured", getRequestId(request))); + const query = callbackQuery(request.query); + if (query.error || !query.code || !query.state) return reply.redirect(errorRedirect(env, query.error === "access_denied" ? "cancelled" : "invalid_callback")); + try { + const attempt = await consumeEntraAttempt(env, query.state); + if (!attempt) return reply.redirect(errorRedirect(env, "expired_attempt")); + const profile = await exchangeEntraCode(env, query.code, attempt); + const user = attempt.purpose === "link" && attempt.userId + ? await linkEntraIdentity(attempt.userId, profile) + : await resolveEntraUser(profile); + if (!user) return reply.redirect(errorRedirect(env, "not_linked")); + const session = await createSession(env, user); + reply.setCookie(SESSION_COOKIE, session.token, sessionCookieOptions(env)); + request.log.info({ requestId: getRequestId(request), userId: user.id, provider: "microsoft-entra", purpose: attempt.purpose }, "Entra authentication succeeded"); + return reply.redirect(new URL(attempt.returnTo, env.PROJECTBRAIN_PUBLIC_URL).toString()); + } catch (error) { + request.log.warn({ requestId: getRequestId(request), provider: "microsoft-entra", errorCode: error instanceof Error ? error.message : "ENTRA_UNKNOWN_ERROR" }, "Entra authentication failed"); + return reply.redirect(errorRedirect(env, "authentication_failed")); + } + }); + + app.get(apiPaths.auth.entraStatus, async (request) => { + const status = request.authUser && env.PROJECTBRAIN_DATA_SOURCE === "database" ? await getEntraLinkStatus(request.authUser.id) : { linked: false }; + return successEnvelope({ enabled: isEntraConfigured(env), ...status }, getRequestId(request)); + }); + + app.post(apiPaths.auth.entraLinkStart, async (request, reply) => { + if (!request.authUser) return reply.status(401).send(errorEnvelope("AUTHENTICATION_REQUIRED", "Authentication required", getRequestId(request))); + if (!isEntraConfigured(env)) return reply.status(503).send(errorEnvelope("ENTRA_NOT_CONFIGURED", "Microsoft Entra login is not configured", getRequestId(request))); + const authorizationUrl = await createEntraAuthorization(env, { purpose: "link", userId: request.authUser.id, returnTo: "/settings" }); + return successEnvelope({ authorizationUrl }, getRequestId(request)); + }); + + app.delete(apiPaths.auth.entraIdentity, async (request, reply) => { + if (!request.authUser) return reply.status(401).send(errorEnvelope("AUTHENTICATION_REQUIRED", "Authentication required", getRequestId(request))); + if (!isEntraConfigured(env)) return reply.status(503).send(errorEnvelope("ENTRA_NOT_CONFIGURED", "Microsoft Entra login is not configured", getRequestId(request))); + const currentPassword = (request.body as { currentPassword?: unknown } | undefined)?.currentPassword; + if (typeof currentPassword !== "string" || currentPassword.length < 12) return reply.status(400).send(errorEnvelope("PASSWORD_REQUIRED", "The local owner password is required to unlink Microsoft Entra", getRequestId(request))); + const verified = await authenticate(env, request.authUser.username, currentPassword); + if (!verified) return reply.status(403).send(errorEnvelope("CURRENT_PASSWORD_INVALID", "Current password is invalid", getRequestId(request))); + await unlinkEntraIdentity(request.authUser.id); + return successEnvelope({ unlinked: true }, getRequestId(request)); + }); +}; diff --git a/backend/src/routes/evidence.ts b/backend/src/routes/evidence.ts new file mode 100644 index 0000000..8b5feb7 --- /dev/null +++ b/backend/src/routes/evidence.ts @@ -0,0 +1,306 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { + findEvidence, + findFeature, + listEvidenceForFeature as listFixtureEvidenceForFeature, + listEvidenceForProject as listFixtureEvidenceForProject, + paginate, + type PaginationInput, +} from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { + getFeatureBySlugOrId, + projectExists as databaseFeatureProjectExists, +} from "../repositories/featuresRepository.js"; +import { + getEvidenceBySlugOrId, + listEvidenceForFeature as listDatabaseEvidenceForFeature, + listEvidenceForProject as listDatabaseEvidenceForProject, + projectExists as databaseEvidenceProjectExists, +} from "../repositories/evidenceRepository.js"; +import type { EvidenceDto, EvidenceType } from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); + +const projectParamsSchema = z.object({ + projectId: idSchema, +}); + +const featureParamsSchema = z.object({ + projectId: idSchema, + featureId: idSchema, +}); + +const evidenceParamsSchema = z.object({ + projectId: idSchema, + evidenceId: idSchema, +}); + +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); + +const evidenceQuerySchema = paginationQuerySchema.extend({ + type: z.string().optional(), + featureId: idSchema.optional(), + confidence: z.coerce.number().int().min(0).max(100).optional(), + search: z.string().optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const splitCsv = (value: string | undefined): string[] => + value + ?.split(",") + .map((item) => item.trim()) + .filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + + return [...items].sort((left, right) => { + const leftValue = left[field]; + const rightValue = right[field]; + if (leftValue === rightValue) return 0; + const comparison = String(leftValue ?? "").localeCompare( + String(rightValue ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +type EvidenceQuery = z.output; +type PaginationQuery = z.output; + +const applyEvidenceFilters = ( + evidenceItems: EvidenceDto[], + query: EvidenceQuery, +): EvidenceDto[] => { + const types = splitCsv(query.type) as EvidenceType[]; + const normalizedSearch = query.search?.trim().toLowerCase(); + + return evidenceItems.filter((evidence) => { + if (types.length > 0 && !types.includes(evidence.type)) return false; + if ( + query.featureId !== undefined && + !evidence.featureIds.includes(query.featureId) + ) { + return false; + } + if ( + query.confidence !== undefined && + evidence.confidence !== query.confidence + ) { + return false; + } + if ( + normalizedSearch && + !`${evidence.source} ${evidence.reason} ${evidence.linkedEntity.label}` + .toLowerCase() + .includes(normalizedSearch) + ) { + return false; + } + return true; + }); +}; + +const toPaginationInput = ( + query: PaginationQuery, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const ensureProject = async ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, + useDatabase: boolean, +) => { + const exists = useDatabase + ? await databaseEvidenceProjectExists(projectId) + : Boolean(findProject(projectId)); + + if (!exists) { + sendProjectNotFound(request, reply, projectId); + return false; + } + + return true; +}; + +type EvidenceRoutesOptions = { + dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; +}; + +export const registerEvidenceRoutes = async ( + app: FastifyInstance, + options: EvidenceRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + + app.get( + "/api/projects/:projectId/features/:featureId/evidence", + async (request, reply) => { + const params = parseParams(featureParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { + return reply; + } + + const feature = useDatabase + ? await getFeatureBySlugOrId(params.projectId, params.featureId) + : findFeature(params.projectId, params.featureId); + if (!feature) { + return reply.status(404).send( + errorEnvelope( + "FEATURE_NOT_FOUND", + `Feature '${params.featureId}' was not found.`, + getRequestId(request), + ), + ); + } + + const evidence = useDatabase + ? await listDatabaseEvidenceForFeature(params.projectId, params.featureId) + : listFixtureEvidenceForFeature(params.projectId, params.featureId); + + return successEnvelope(evidence ?? [], getRequestId(request)); + }, + ); + + app.get("/api/projects/:projectId/evidence", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { + return reply; + } + + const query = parseQuery(evidenceQuerySchema, request, reply); + if (!query) return reply; + + const filters = { + ...(query.type ? { type: splitCsv(query.type) } : {}), + ...(query.featureId ? { featureId: query.featureId } : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.search ? { search: query.search } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), + }; + const sourceEvidence = useDatabase + ? await listDatabaseEvidenceForProject(params.projectId) + : listFixtureEvidenceForProject(params.projectId); + const filteredEvidence = sortByField( + applyEvidenceFilters(sourceEvidence ?? [], query), + query.sort, + ); + + return successEnvelope( + paginate(filteredEvidence, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get( + "/api/projects/:projectId/evidence/:evidenceId", + async (request, reply) => { + const params = parseParams(evidenceParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { + return reply; + } + + const evidence = useDatabase + ? await getEvidenceBySlugOrId(params.projectId, params.evidenceId) + : findEvidence(params.projectId, params.evidenceId); + if (!evidence) { + return reply.status(404).send( + errorEnvelope( + "EVIDENCE_NOT_FOUND", + `Evidence '${params.evidenceId}' was not found.`, + getRequestId(request), + ), + ); + } + + return successEnvelope(evidence, getRequestId(request)); + }, + ); +}; diff --git a/backend/src/routes/features.ts b/backend/src/routes/features.ts new file mode 100644 index 0000000..b72f9bb --- /dev/null +++ b/backend/src/routes/features.ts @@ -0,0 +1,293 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { + findFeature, + listFeatureGroupsForProject as listFixtureFeatureGroupsForProject, + listFeaturesForProject as listFixtureFeaturesForProject, + paginate, + type PaginationInput, +} from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { + listFeatureGroupsForProject as listDatabaseFeatureGroupsForProject, +} from "../repositories/featureGroupsRepository.js"; +import { + getFeatureBySlugOrId, + listFeaturesForProject as listDatabaseFeaturesForProject, + projectExists as databaseProjectExists, +} from "../repositories/featuresRepository.js"; +import type { FeatureDto, FeatureStatus } from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); + +const projectParamsSchema = z.object({ + projectId: idSchema, +}); + +const featureParamsSchema = z.object({ + projectId: idSchema, + featureId: idSchema, +}); + +const booleanQuerySchema = z.preprocess( + (value) => { + if (value === undefined || typeof value === "boolean") return value; + if (typeof value !== "string") return undefined; + const normalized = value.toLowerCase(); + if (["true", "1", "yes", "on"].includes(normalized)) return true; + if (["false", "0", "no", "off"].includes(normalized)) return false; + return undefined; + }, + z.boolean().optional(), +); + +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); + +const featuresQuerySchema = paginationQuerySchema.extend({ + status: z.string().optional(), + confidence: z.coerce.number().int().min(0).max(100).optional(), + hasEvidence: booleanQuerySchema, + groupId: idSchema.optional(), + search: z.string().optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const splitCsv = (value: string | undefined): string[] => + value + ?.split(",") + .map((item) => item.trim()) + .filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + + return [...items].sort((left, right) => { + const leftValue = left[field]; + const rightValue = right[field]; + if (leftValue === rightValue) return 0; + const comparison = String(leftValue ?? "").localeCompare( + String(rightValue ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +type FeatureQuery = z.output; +type PaginationQuery = z.output; + +const applyFeatureFilters = ( + features: FeatureDto[], + query: FeatureQuery, +): FeatureDto[] => { + const statuses = splitCsv(query.status) as FeatureStatus[]; + const normalizedSearch = query.search?.trim().toLowerCase(); + + return features.filter((feature) => { + if (statuses.length > 0 && !statuses.includes(feature.status)) return false; + if (query.confidence !== undefined && feature.confidence !== query.confidence) { + return false; + } + if (query.groupId !== undefined && feature.groupId !== query.groupId) { + return false; + } + if ( + query.hasEvidence !== undefined && + (feature.evidenceIds.length > 0) !== query.hasEvidence + ) { + return false; + } + if ( + normalizedSearch && + !`${feature.name} ${feature.description}` + .toLowerCase() + .includes(normalizedSearch) + ) { + return false; + } + return true; + }); +}; + +const toPaginationInput = ( + query: PaginationQuery, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const ensureProject = async ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, + useDatabase: boolean, +) => { + const exists = useDatabase + ? await databaseProjectExists(projectId) + : Boolean(findProject(projectId)); + + if (!exists) { + sendProjectNotFound(request, reply, projectId); + return false; + } + + return true; +}; + +type FeatureRoutesOptions = { + dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; +}; + +export const registerFeatureRoutes = async ( + app: FastifyInstance, + options: FeatureRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + + app.get("/api/projects/:projectId/feature-groups", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { + return reply; + } + + const groups = useDatabase + ? await listDatabaseFeatureGroupsForProject(params.projectId) + : listFixtureFeatureGroupsForProject(params.projectId); + + return successEnvelope(groups ?? [], getRequestId(request)); + }); + + app.get("/api/projects/:projectId/features", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { + return reply; + } + + const query = parseQuery(featuresQuerySchema, request, reply); + if (!query) return reply; + + const filters = { + ...(query.status ? { status: splitCsv(query.status) } : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.hasEvidence !== undefined ? { hasEvidence: query.hasEvidence } : {}), + ...(query.groupId ? { groupId: query.groupId } : {}), + ...(query.search ? { search: query.search } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), + }; + const sourceFeatures = useDatabase + ? await listDatabaseFeaturesForProject(params.projectId) + : listFixtureFeaturesForProject(params.projectId); + const filteredFeatures = sortByField( + applyFeatureFilters(sourceFeatures ?? [], query), + query.sort, + ); + + return successEnvelope( + paginate(filteredFeatures, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get("/api/projects/:projectId/features/:featureId", async (request, reply) => { + const params = parseParams(featureParamsSchema, request, reply); + if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { + return reply; + } + + const feature = useDatabase + ? await getFeatureBySlugOrId(params.projectId, params.featureId) + : findFeature(params.projectId, params.featureId); + if (!feature) { + return reply.status(404).send( + errorEnvelope( + "FEATURE_NOT_FOUND", + `Feature '${params.featureId}' was not found.`, + getRequestId(request), + ), + ); + } + + return successEnvelope(feature, getRequestId(request)); + }); +}; diff --git a/backend/src/routes/findings.ts b/backend/src/routes/findings.ts new file mode 100644 index 0000000..3f5a686 --- /dev/null +++ b/backend/src/routes/findings.ts @@ -0,0 +1,106 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { FindingSeverity, FindingStatus, IssueSyncMode } from "@prisma/client"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { + getFindingAutomationPolicy, + getRepositoryFinding, + listRepositoryFindings, + startFindingInvestigation, + suppressRepositoryFinding, + syncFindingIssue, + updateFindingAutomationPolicy, +} from "../repositories/findingRepository.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +const projectParams = z.object({ projectId: z.string().min(1) }); +const findingParams = projectParams.extend({ findingId: z.string().min(1) }); +const listQuery = z.object({ + status: z.string().optional(), severity: z.string().optional(), cursor: z.string().regex(/^\d+$/).optional(), limit: z.coerce.number().int().min(1).max(50).default(20), +}); +const suppressBody = z.object({ reason: z.string().trim().min(3).max(2_000), until: z.string().datetime().optional() }); +const policyBody = z.object({ + enabled: z.boolean(), issueSyncMode: z.nativeEnum(IssueSyncMode), minimumSeverity: z.nativeEnum(FindingSeverity), + minimumOccurrences: z.number().int().min(1).max(20), maxIssuesPerRun: z.number().int().min(1).max(25), + autoCloseResolved: z.boolean(), autoReopenRegressed: z.boolean(), scheduledAnalysis: z.boolean(), scheduleMinutes: z.number().int().min(60).max(43_200), +}); + +const ownerOnly = (request: FastifyRequest, reply: FastifyReply) => request.authUser?.role === "owner" ? false : Boolean(reply.status(403).send(errorEnvelope("OWNER_ACCESS_REQUIRED", "Owner access is required for finding mutations.", getRequestId(request)))); +const parseCsvEnum = (value: string | undefined, allowed: readonly T[]) => value?.split(",").map((part) => part.trim()).filter((part): part is T => allowed.includes(part as T)); +const findingError = (error: unknown, request: FastifyRequest, reply: FastifyReply) => { + const code = error instanceof Error ? error.message : "FINDING_OPERATION_FAILED"; + const status = code.endsWith("NOT_FOUND") ? 404 : code.includes("WRITE_LINK_REQUIRED") || code.includes("FORBIDDEN") ? 403 : 409; + return reply.status(status).send(errorEnvelope(code, "The finding operation could not be completed.", getRequestId(request))); +}; + +const requireProjectFinding = async (projectId: string, findingId: string) => { + const finding = await getRepositoryFinding(findingId); + if (!finding.project || ![finding.project.id, finding.project.slug].includes(projectId)) throw new Error("FINDING_NOT_FOUND"); + return finding; +}; + +export const registerFindingRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + app.get("/api/projects/:projectId/findings", async (request, reply) => { + const { projectId } = projectParams.parse(request.params); + const query = listQuery.parse(request.query ?? {}); + try { + const result = await listRepositoryFindings({ + projectId, + statuses: parseCsvEnum(query.status, Object.values(FindingStatus)), + severities: parseCsvEnum(query.severity, Object.values(FindingSeverity)), + cursor: query.cursor, + limit: query.limit, + }); + return successEnvelope(result, getRequestId(request)); + } catch (error) { return findingError(error, request, reply); } + }); + + app.get("/api/projects/:projectId/findings/:findingId", async (request, reply) => { + const { projectId, findingId } = findingParams.parse(request.params); + try { return successEnvelope(await requireProjectFinding(projectId, findingId), getRequestId(request)); } + catch (error) { return findingError(error, request, reply); } + }); + + app.post("/api/projects/:projectId/findings/:findingId/issue", async (request, reply) => { + if (ownerOnly(request, reply)) return reply; + const { projectId, findingId } = findingParams.parse(request.params); + try { + await requireProjectFinding(projectId, findingId); + return reply.status(201).send(successEnvelope(await syncFindingIssue(options.env, findingId, request.authUser?.username ?? "owner"), getRequestId(request))); + } catch (error) { return findingError(error, request, reply); } + }); + + app.post("/api/projects/:projectId/findings/:findingId/investigations", async (request, reply) => { + if (ownerOnly(request, reply)) return reply; + const { projectId, findingId } = findingParams.parse(request.params); + try { + await requireProjectFinding(projectId, findingId); + return reply.status(201).send(successEnvelope(await startFindingInvestigation(options.env, findingId, request.authUser?.username ?? "owner"), getRequestId(request))); + } catch (error) { return findingError(error, request, reply); } + }); + + app.post("/api/projects/:projectId/findings/:findingId/suppress", async (request, reply) => { + if (ownerOnly(request, reply)) return reply; + const { projectId, findingId } = findingParams.parse(request.params); + const body = suppressBody.parse(request.body ?? {}); + try { + await requireProjectFinding(projectId, findingId); + return successEnvelope(await suppressRepositoryFinding(findingId, body.reason, body.until ? new Date(body.until) : undefined), getRequestId(request)); + } catch (error) { return findingError(error, request, reply); } + }); + + app.get("/api/projects/:projectId/finding-automation-policy", async (request, reply) => { + const { projectId } = projectParams.parse(request.params); + try { return successEnvelope(await getFindingAutomationPolicy(projectId), getRequestId(request)); } + catch (error) { return findingError(error, request, reply); } + }); + + app.put("/api/projects/:projectId/finding-automation-policy", async (request, reply) => { + if (ownerOnly(request, reply)) return reply; + const { projectId } = projectParams.parse(request.params); + const body = policyBody.parse(request.body ?? {}); + try { return successEnvelope(await updateFindingAutomationPolicy(projectId, { ...body, updatedBy: request.authUser?.username ?? "owner" }), getRequestId(request)); } + catch (error) { return findingError(error, request, reply); } + }); +}; diff --git a/backend/src/routes/giteaIntegration.ts b/backend/src/routes/giteaIntegration.ts new file mode 100644 index 0000000..afc0f35 --- /dev/null +++ b/backend/src/routes/giteaIntegration.ts @@ -0,0 +1,492 @@ +import { assertDeliveryWriteAllowed } from "../services/featureDeliveryFlow.js"; +import type { FastifyInstance } from "fastify"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { GiteaApiError } from "../integrations/gitea/client.js"; +import { assertExpectedFileSha, assertFileSize, assertProjectBrainBranch, assertReadablePath, assertSuccessfulActionRuns, assertWritablePath, findReusableDeliveryPullRequest } from "../integrations/gitea/policy.js"; +import { finishExternalOperation, getGiteaClient, getLinkedRepository, GiteaRepositoryOwnershipConflictError, GiteaRepositoryTransferBlockedError, linkRepository, listLinkedRepositories, listProjectRepositories, markExternalOperationExecuting, prepareExternalOperation, recordMcpAudit, unlinkRepository, unlinkRepositoryById, type LinkedRepository } from "../integrations/gitea/service.js"; +import type { GiteaContent } from "../integrations/gitea/types.js"; +import { assertFeatureDeliveryActionEvidence, getFeatureDelivery, openFeatureDeliveryPullRequest, reconcileFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery } from "../repositories/featureDeliveryRepository.js"; +import { queueFindingVerificationForDelivery, reconcileFindingIssueWebhook } from "../repositories/findingRepository.js"; +import { createPortfolioAnalysisJob } from "../repositories/analysisJobRepository.js"; +import { syncGitSources } from "../services/gitSourceSync.js"; +import { reconcileGiteaAutonomyExecution } from "../services/giteaAutonomyExecution.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +declare module "fastify" { interface FastifyRequest { rawBody?: Buffer } } + +export const hasGiteaAdminAccess = (role?: string) => role === "owner"; +const ownerOnly = (request: { authUser?: { role: string } }, reply: { status: (code: number) => { send: (body: unknown) => unknown } }, requestId: string) => + hasGiteaAdminAccess(request.authUser?.role) ? undefined : reply.status(403).send(errorEnvelope("OWNER_ACCESS_REQUIRED", "Owner access is required for Gitea integration administration", requestId)); + +const linkSchema = z.object({ + repository: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), + accessMode: z.enum(["read_only", "read_write"]).default("read_write"), + role: z.enum(["primary", "frontend", "backend", "infra", "docs", "shared", "other"]).optional(), + isPrimary: z.boolean().optional(), + transfer: z.boolean().optional().default(false), +}); + +const treeQuerySchema = z.object({ path: z.string().max(1000).optional().default(""), ref: z.string().max(200).optional(), repositoryId: z.string().min(1).optional() }); +const fileQuerySchema = z.object({ path: z.string().min(1).max(1000), ref: z.string().max(200).optional(), repositoryId: z.string().min(1).optional() }); +const branchSchema = z.object({ deliveryId: z.string().min(1), repositoryId: z.string().min(1).optional(), branch: z.string().min(1).max(300), sourceBranch: z.string().min(1).max(300).optional() }); +const fileWriteSchema = z.object({ + deliveryId: z.string().min(1), + branch: z.string().min(1).max(300), path: z.string().min(1).max(1000), content: z.string(), + message: z.string().trim().min(3).max(200), expectedSha: z.string().min(1).max(200).optional(), +}); +const pullRequestSchema = z.object({ + deliveryId: z.string().min(1), + branch: z.string().min(1).max(300), title: z.string().trim().min(3).max(200), body: z.string().max(20_000).default(""), +}); +const deleteFileSchema = z.object({ deliveryId: z.string().min(1), branch: z.string().min(1).max(300), path: z.string().min(1).max(1000), message: z.string().trim().min(3).max(200), expectedSha: z.string().min(1).max(200) }); +const mergePullRequestSchema = z.object({ + deliveryId: z.string().min(1), expectedHeadSha: z.string().regex(/^[0-9a-f]{40,64}$/i), + method: z.enum(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]).default("squash"), deleteBranch: z.boolean().default(true), +}); +const pullRequestWebhookSchema = z.object({ + action: z.string(), number: z.number().int().positive(), + repository: z.object({ full_name: z.string().regex(/^[^/]+\/[^/]+$/) }), + pull_request: z.object({ number: z.number().int().positive(), state: z.string(), merged: z.boolean(), merged_at: z.string().optional().nullable(), merge_commit_sha: z.string().optional().nullable(), head: z.object({ sha: z.string() }) }), + sender: z.object({ login: z.string() }).optional(), +}); +const issueWebhookSchema = z.object({ + action: z.string(), + repository: z.object({ full_name: z.string().regex(/^[^/]+\/[^/]+$/) }), + issue: z.object({ number: z.number().int().positive(), state: z.string(), title: z.string(), html_url: z.string().url() }), + sender: z.object({ login: z.string() }).optional(), +}); +const pushWebhookSchema = z.object({ + ref: z.string().min(1), + after: z.string().optional(), + repository: z.object({ full_name: z.string().regex(/^[^/]+\/[^/]+$/) }), + sender: z.object({ login: z.string() }).optional(), +}); + +export const verifyGiteaWebhookSignature = (body: Buffer, signature: string | undefined, secret: string) => { + if (!signature || !/^[a-f0-9]{64}$/i.test(signature)) return false; + const expected = Buffer.from(createHmac("sha256", secret).update(body).digest("hex"), "utf8"); + const supplied = Buffer.from(signature.toLowerCase(), "utf8"); + return expected.length === supplied.length && timingSafeEqual(expected, supplied); +}; + +export const integrationError = (error: unknown): { status: number; code: string; message: string } => { + if (error instanceof GiteaRepositoryOwnershipConflictError) { + return { + status: 409, + code: error.code, + message: `${error.repositoryFullName} is al gekoppeld aan project '${error.sourceProjectName}'. Bevestig expliciet dat je de repository naar dit project wilt verplaatsen.`, + }; + } + if (error instanceof GiteaRepositoryTransferBlockedError) { + return { + status: 409, + code: error.code, + message: `${error.repositoryFullName} heeft ${error.deliveryRunCount} gekoppelde deliveryrun(s). Verplaatsen is geblokkeerd om projectgeschiedenis niet te beschadigen.`, + }; + } + if (error instanceof GiteaApiError) return { status: error.status === 404 ? 404 : 502, code: error.code, message: error.message }; + const code = error instanceof Error ? error.message : "GITEA_INTEGRATION_FAILED"; + if (code === "PROJECT_NOT_FOUND") return { status: 404, code, message: "Project not found" }; + if (code === "GITEA_LINK_NOT_FOUND") return { status: 404, code, message: "No Gitea repository is linked to this project" }; + if (code === "GITEA_CONTENT_NOT_FILE") return { status: 400, code, message: "The selected repository entry is not a readable text file" }; + if (code === "GITEA_NOT_CONFIGURED") return { status: 503, code, message: "Gitea is not configured on this server" }; + if (code === "GITEA_WRITE_ACCESS_REQUIRED") return { status: 403, code, message: "The configured Gitea identity has no write access" }; + if (["PROJECTBRAIN_BRANCH_FORBIDDEN", "DEFAULT_BRANCH_WRITE_FORBIDDEN", "SENSITIVE_PATH_FORBIDDEN", "REPOSITORY_PATH_FORBIDDEN"].includes(code)) return { status: 403, code, message: "The requested repository operation is outside the ProjectBrain safety policy" }; + if (code === "MCP_FILE_TOO_LARGE") return { status: 413, code, message: "The file is larger than the ProjectBrain editing limit" }; + if (code === "GITEA_FILE_CONFLICT") return { status: 409, code, message: "This file changed after you opened it. Reload it before committing your changes" }; + if (code === "GITEA_REPOSITORY_READ_ONLY") return { status: 403, code, message: "This ProjectBrain project has read-only repository access" }; + if (code.startsWith("FEATURE_DELIVERY_")) return { status: code.endsWith("NOT_FOUND") ? 404 : 409, code, message: "This repository change is not allowed by the selected delivery plan" }; + return { status: 400, code, message: "The Gitea repository link could not be saved" }; +}; + +const decodeFile = (content: GiteaContent) => { + if (content.type !== "file" || content.encoding !== "base64" || typeof content.content !== "string") throw new Error("GITEA_CONTENT_NOT_FILE"); + return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8"); +}; + +const requireWorkbenchLink = async (projectId: string, write = false, repositoryId?: string): Promise => { + const link = repositoryId + ? (await listProjectRepositories(projectId)).find((candidate) => candidate.id === repositoryId) + : await getLinkedRepository(projectId); + if (!link) throw new Error("GITEA_LINK_NOT_FOUND"); + if (write && link.accessMode !== "read_write") throw new Error("GITEA_REPOSITORY_READ_ONLY"); + return link; +}; + +type WorkbenchAuditInput = { link?: LinkedRepository; actor: string; toolName: string; branch?: string; outcome: "succeeded" | "failed" | "denied"; errorCode?: string; started: number }; + +const recordWorkbenchAudit = (input: WorkbenchAuditInput) => + recordMcpAudit({ + projectId: input.link?.projectId, actor: input.actor, toolName: `workbench.${input.toolName}`, + repository: input.link?.fullName, branch: input.branch, outcome: input.outcome, errorCode: input.errorCode, + durationMs: Date.now() - input.started, + }); + +const beginWorkbenchMutation = async (input: Omit) => { + if (!input.link) throw new Error("GITEA_LINK_NOT_FOUND"); + const operation = await prepareExternalOperation({ projectId: input.link.projectId, actor: input.actor, action: `workbench.${input.toolName}`, target: { repository: input.link.fullName, branch: input.branch } }); + await markExternalOperationExecuting(operation.operationId); + return operation; +}; + +const finishWorkbenchMutation = async (operationId: string, outcome: "succeeded" | "failed" | "denied", started: number, errorCode?: string) => { + const operation = await finishExternalOperation(operationId, { status: outcome === "succeeded" ? "succeeded" : "failed", errorCode }); + const target = operation.targetJson && typeof operation.targetJson === "object" && !Array.isArray(operation.targetJson) ? operation.targetJson as Record : {}; + await recordMcpAudit({ operationId, projectId: operation.projectId, actor: operation.actor, toolName: operation.action, repository: typeof target.repository === "string" ? target.repository : undefined, branch: typeof target.branch === "string" ? target.branch : undefined, outcome, errorCode, durationMs: Date.now() - started }); +}; + +export const registerGiteaIntegrationRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + const { env } = options; + + app.post("/api/integrations/gitea/webhook", async (request, reply) => { + if (!env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET) return reply.status(503).send(errorEnvelope("GITEA_WEBHOOK_NOT_CONFIGURED", "Gitea webhook processing is not configured", getRequestId(request))); + const signature = Array.isArray(request.headers["x-gitea-signature"]) ? request.headers["x-gitea-signature"][0] : request.headers["x-gitea-signature"]; + if (!request.rawBody || !verifyGiteaWebhookSignature(request.rawBody, signature, env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET)) return reply.status(401).send(errorEnvelope("GITEA_WEBHOOK_SIGNATURE_INVALID", "Webhook signature is invalid", getRequestId(request))); + const event = Array.isArray(request.headers["x-gitea-event"]) ? request.headers["x-gitea-event"][0] : request.headers["x-gitea-event"]; + const deliveryHeader = Array.isArray(request.headers["x-gitea-delivery"]) ? request.headers["x-gitea-delivery"][0] : request.headers["x-gitea-delivery"]; + const operationId = deliveryHeader ? `gitea-webhook:${deliveryHeader}` : undefined; + if (operationId && await prisma.mcpAuditEvent.findUnique({ where: { operationId } })) return successEnvelope({ accepted: true, duplicate: true }, getRequestId(request)); + if (event === "push") { + const parsed = pushWebhookSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("GITEA_WEBHOOK_PAYLOAD_INVALID", "Webhook payload is invalid", getRequestId(request))); + const [owner, repository] = parsed.data.repository.full_name.split("/"); + const actor = parsed.data.sender?.login ?? "gitea-webhook"; + const link = await prisma.giteaRepositoryLink.findUnique({ where: { owner_repository: { owner, repository } }, select: { id: true, projectId: true } }); + const recent = link ? await prisma.analysisJob.findFirst({ + where: { actor: "system:webhook:push", createdAt: { gte: new Date(Date.now() - 5 * 60_000) }, items: { some: { repositoryLinkId: link.id } } }, + orderBy: { createdAt: "desc" }, + select: { id: true, status: true }, + }) : undefined; + const analysis = link && !recent + ? await createPortfolioAnalysisJob(env, "system:webhook:push", { projectIds: [link.projectId], repositoryIds: [link.id], includeBranches: true, includePullRequests: true, includeActions: true }) + : recent ? { job: recent, deduplicated: true } : undefined; + const sourceSync = link ? await syncGitSources(env, { repositoryLinkId: link.id }).catch(() => undefined) : undefined; + const autonomy = link ? await reconcileGiteaAutonomyExecution(env, link.projectId, actor).catch(() => undefined) : undefined; + await recordMcpAudit({ operationId, projectId: link?.projectId, actor, toolName: "webhook.push", repository: parsed.data.repository.full_name, branch: parsed.data.ref.replace(/^refs\/heads\//, ""), outcome: "succeeded", durationMs: 0, metadata: { matched: Boolean(link), analysisQueued: Boolean(analysis), sourceSync, autonomyReconciled: Boolean(autonomy?.changed), deduplicated: analysis?.deduplicated ?? false } }); + return successEnvelope({ accepted: true, matched: Boolean(link), analysis, autonomy }, getRequestId(request)); + } + if (event === "pull_request") { + const parsed = pullRequestWebhookSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("GITEA_WEBHOOK_PAYLOAD_INVALID", "Webhook payload is invalid", getRequestId(request))); + const [owner, repository] = parsed.data.repository.full_name.split("/"); + const actor = parsed.data.sender?.login ?? "gitea-webhook"; + const repositoryLink = await prisma.giteaRepositoryLink.findUnique({ + where: { owner_repository: { owner, repository } }, + select: { id: true, projectId: true }, + }); + const reconciled = await reconcileFeatureDeliveryPullRequest({ owner, repository, number: parsed.data.pull_request.number, state: parsed.data.pull_request.state === "open" ? "open" : "closed", merged: parsed.data.pull_request.merged, mergedAt: parsed.data.pull_request.merged_at ?? undefined, mergeCommitSha: parsed.data.pull_request.merge_commit_sha ?? undefined, headSha: parsed.data.pull_request.head.sha, actor }); + const verification = reconciled && parsed.data.pull_request.merged ? await queueFindingVerificationForDelivery(env, reconciled.projectId, reconciled.id).catch(() => undefined) : undefined; + const autonomy = repositoryLink ? await reconcileGiteaAutonomyExecution(env, repositoryLink.projectId, actor).catch(() => undefined) : undefined; + await recordMcpAudit({ operationId, projectId: repositoryLink?.projectId ?? reconciled?.projectId, actor, toolName: "webhook.pull_request", repository: parsed.data.repository.full_name, outcome: "succeeded", durationMs: 0, metadata: { action: parsed.data.action, pullRequestNumber: parsed.data.pull_request.number, matchedRepository: Boolean(repositoryLink), matchedDelivery: Boolean(reconciled), autonomyReconciled: Boolean(autonomy?.changed), verificationQueued: Boolean(verification) } }); + return successEnvelope({ accepted: true, matched: Boolean(repositoryLink ?? reconciled), delivery: reconciled, autonomy, verification }, getRequestId(request)); + } + if (event === "issues" || event === "issue_comment") { + const parsed = issueWebhookSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("GITEA_WEBHOOK_PAYLOAD_INVALID", "Webhook payload is invalid", getRequestId(request))); + const [owner, repository] = parsed.data.repository.full_name.split("/"); + const actor = parsed.data.sender?.login ?? "gitea-webhook"; + const finding = await reconcileFindingIssueWebhook({ owner, repository, issueNumber: parsed.data.issue.number, state: parsed.data.issue.state, title: parsed.data.issue.title, url: parsed.data.issue.html_url, actor }); + await recordMcpAudit({ operationId, projectId: finding?.projectId ?? undefined, actor, toolName: `webhook.${event}`, repository: parsed.data.repository.full_name, outcome: "succeeded", durationMs: 0, metadata: { action: parsed.data.action, issueNumber: parsed.data.issue.number, matched: Boolean(finding) } }); + return successEnvelope({ accepted: true, matched: Boolean(finding), finding }, getRequestId(request)); + } + return successEnvelope({ accepted: true, ignored: true, event }, getRequestId(request)); + }); + + app.get("/api/integrations/gitea/status", async (request) => { + const configured = Boolean(env.PROJECTBRAIN_GITEA_URL && env.PROJECTBRAIN_GITEA_TOKEN); + if (!configured) return successEnvelope({ configured: false, reachable: false, mcpEnabled: env.PROJECTBRAIN_MCP_ENABLED, publicUrl: env.PROJECTBRAIN_PUBLIC_URL, linkedRepositories: 0 }, getRequestId(request)); + try { + const [user, linkedRepositories] = await Promise.all([getGiteaClient(env).getCurrentUser(), prisma.giteaRepositoryLink.count()]); + return successEnvelope({ configured: true, reachable: true, baseUrl: env.PROJECTBRAIN_GITEA_URL, identity: user.login, mcpEnabled: env.PROJECTBRAIN_MCP_ENABLED, mcpUrl: env.PROJECTBRAIN_PUBLIC_URL ? `${env.PROJECTBRAIN_PUBLIC_URL.replace(/\/$/, "")}/mcp` : undefined, linkedRepositories }, getRequestId(request)); + } catch (error) { + return successEnvelope({ configured: true, reachable: false, baseUrl: env.PROJECTBRAIN_GITEA_URL, mcpEnabled: env.PROJECTBRAIN_MCP_ENABLED, error: integrationError(error).message, linkedRepositories: await prisma.giteaRepositoryLink.count() }, getRequestId(request)); + } + }); + + app.get("/api/integrations/gitea/repositories", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + try { + const repositories = await getGiteaClient(env).listRepositories(); + return successEnvelope(repositories.map((repo) => ({ id: repo.id, name: repo.name, fullName: repo.full_name, description: repo.description, private: repo.private, archived: repo.archived, defaultBranch: repo.default_branch, htmlUrl: repo.html_url, updatedAt: repo.updated_at, canPush: Boolean(repo.permissions?.push || repo.permissions?.admin) })), getRequestId(request)); + } catch (error) { + const failure = integrationError(error); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.get("/api/integrations/gitea/links", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + return successEnvelope(await listLinkedRepositories(), getRequestId(request)); + }); + + app.get("/api/projects/:projectId/gitea", async (request, reply) => { + const projectId = (request.params as { projectId: string }).projectId; + const link = await getLinkedRepository(projectId); + if (!link) return reply.status(404).send(errorEnvelope("GITEA_LINK_NOT_FOUND", "No Gitea repository is linked to this project", getRequestId(request))); + return successEnvelope(link, getRequestId(request)); + }); + + app.get("/api/projects/:projectId/gitea/repositories", async (request) => + successEnvelope(await listProjectRepositories((request.params as { projectId: string }).projectId), getRequestId(request))); + + app.get("/api/projects/:projectId/gitea/workspace", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const parsed = treeQuerySchema.safeParse(request.query); + if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A valid repository path and ref are required", getRequestId(request))); + try { + const link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, false, parsed.data.repositoryId); + const client = getGiteaClient(env); + const ref = parsed.data.ref ?? link.defaultBranch; + const path = parsed.data.path ? assertReadablePath(parsed.data.path) : ""; + const [contents, branches, pullRequests] = await Promise.all([ + client.getContents(link.owner, link.repository, path, ref), + client.listBranches(link.owner, link.repository), + client.listPullRequests(link.owner, link.repository, "open"), + ]); + const entries = (Array.isArray(contents) ? contents : [contents]).map(({ content: _content, ...entry }) => entry); + return successEnvelope({ link, path, ref, entries, branches, pullRequests }, getRequestId(request)); + } catch (error) { + const failure = integrationError(error); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.get("/api/projects/:projectId/gitea/file", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const parsed = fileQuerySchema.safeParse(request.query); + if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A valid file path and ref are required", getRequestId(request))); + try { + const link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, false, parsed.data.repositoryId); + const path = assertReadablePath(parsed.data.path); + const ref = parsed.data.ref ?? link.defaultBranch; + const file = await getGiteaClient(env).getContents(link.owner, link.repository, path, ref); + if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE"); + return successEnvelope({ path, ref, sha: file.sha, size: file.size, content: decodeFile(file), htmlUrl: file.html_url }, getRequestId(request)); + } catch (error) { + const failure = integrationError(error); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.post("/api/projects/:projectId/gitea/branches", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const parsed = branchSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A valid ChatGPT or ProjectBrain delivery branch is required", getRequestId(request))); + const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined; + try { + link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, true, parsed.data.repositoryId); + const branch = assertProjectBrainBranch(parsed.data.branch, link.defaultBranch); + const delivery = await getFeatureDelivery(link.projectId, parsed.data.deliveryId); + if (delivery.approvalState !== "approved" || delivery.spec.status !== "approved") throw new Error("FEATURE_DELIVERY_APPROVAL_REQUIRED"); + auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "create_branch", branch, started })).operationId; + const client = getGiteaClient(env); + const base = await client.getBranch(link.owner, link.repository, parsed.data.sourceBranch ?? link.defaultBranch); + const created = await client.createBranch(link.owner, link.repository, branch, parsed.data.sourceBranch ?? link.defaultBranch); + const updatedDelivery = await startFeatureDelivery(link.projectId, parsed.data.deliveryId, branch, request.authUser?.username ?? "owner", { repositoryLinkId: link.id, baseCommitSha: base.commit.id, headSha: created.commit.id }); + await finishWorkbenchMutation(auditId, "succeeded", started); + return reply.status(201).send(successEnvelope({ branch: created, repository: link.fullName, delivery: updatedDelivery }, getRequestId(request))); + } catch (error) { + const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed"; + if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined); + else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "create_branch", branch: parsed.data.branch, outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.put("/api/projects/:projectId/gitea/file", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const parsed = fileWriteSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A branch, safe path, content and commit message are required", getRequestId(request))); + const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined; + try { + const delivery = await getFeatureDelivery((request.params as { projectId: string }).projectId, parsed.data.deliveryId); + link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, true, delivery.spec.repositoryId); + const branch = assertProjectBrainBranch(parsed.data.branch, link.defaultBranch); + const path = assertWritablePath(parsed.data.path); assertFileSize(parsed.data.content); + const client = getGiteaClient(env); await client.getBranch(link.owner, link.repository, branch); + await assertDeliveryWriteAllowed(delivery, branch, client, link); + let existing: GiteaContent | undefined; + try { const current = await client.getContents(link.owner, link.repository, path, branch); if (!Array.isArray(current)) existing = current; } + catch (error) { if (!(error instanceof GiteaApiError && error.status === 404)) throw error; } + assertExpectedFileSha(parsed.data.expectedSha, existing?.sha); + auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "upsert_file", branch, started })).operationId; + const result = existing + ? await client.updateFile(link.owner, link.repository, path, branch, parsed.data.expectedSha!, parsed.data.content, parsed.data.message) + : await client.createFile(link.owner, link.repository, path, branch, parsed.data.content, parsed.data.message); + const [head, current] = await Promise.all([client.getBranch(link.owner, link.repository, branch), client.getContents(link.owner, link.repository, path, branch)]); + const written = Array.isArray(current) ? undefined : current; + const updatedDelivery = await recordFeatureDeliveryProgress(link.projectId, parsed.data.deliveryId, { changes: [{ path, operation: existing ? "update" : "create", beforeSha: existing?.sha, afterSha: written?.sha, commitSha: head.commit.id }], headSha: head.commit.id }, request.authUser?.username ?? "owner"); + await finishWorkbenchMutation(auditId, "succeeded", started); + return successEnvelope({ repository: link.fullName, branch, path, action: existing ? "updated" : "created", result, delivery: updatedDelivery }, getRequestId(request)); + } catch (error) { + const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed"; + if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined); + else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "upsert_file", branch: parsed.data.branch, outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.delete("/api/projects/:projectId/gitea/file", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const parsed = deleteFileSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A delivery, branch, path, SHA and commit message are required", getRequestId(request))); + const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined; + try { + const delivery = await getFeatureDelivery((request.params as { projectId: string }).projectId, parsed.data.deliveryId); + link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, true, delivery.spec.repositoryId); + const branch = assertProjectBrainBranch(parsed.data.branch, link.defaultBranch); + const path = assertWritablePath(parsed.data.path); const client = getGiteaClient(env); + await assertDeliveryWriteAllowed(delivery, branch, client, link); + const file = await client.getContents(link.owner, link.repository, path, branch); if (Array.isArray(file)) throw new Error("GITEA_CONTENT_NOT_FILE"); + assertExpectedFileSha(parsed.data.expectedSha, file.sha); + auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "delete_file", branch, started })).operationId; + const result = await client.deleteFile(link.owner, link.repository, path, branch, file.sha, parsed.data.message); + const head = await client.getBranch(link.owner, link.repository, branch); + const updatedDelivery = await recordFeatureDeliveryProgress(link.projectId, parsed.data.deliveryId, { changes: [{ path, operation: "delete", beforeSha: file.sha, commitSha: head.commit.id }], headSha: head.commit.id }, request.authUser?.username ?? "owner"); + await finishWorkbenchMutation(auditId, "succeeded", started); + return successEnvelope({ repository: link.fullName, branch, path, result, delivery: updatedDelivery }, getRequestId(request)); + } catch (error) { + const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed"; + if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined); + else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "delete_file", branch: parsed.data.branch, outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.post("/api/projects/:projectId/gitea/pull-requests", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const parsed = pullRequestSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A branch, title and optional description are required", getRequestId(request))); + const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined; + try { + const delivery = await getFeatureDelivery((request.params as { projectId: string }).projectId, parsed.data.deliveryId); + link = await requireWorkbenchLink((request.params as { projectId: string }).projectId, true, delivery.spec.repositoryId); + const branch = assertProjectBrainBranch(parsed.data.branch, link.defaultBranch); + if (delivery.approvalState !== "approved" || delivery.spec.branch !== branch || delivery.spec.status !== "validating") throw new Error("FEATURE_DELIVERY_COMPLETION_FORBIDDEN"); + if (delivery.spec.validationResults.some((result) => result.status === "failed")) throw new Error("FEATURE_DELIVERY_VALIDATION_FAILED"); + const client = getGiteaClient(env); const head = await client.getBranch(link.owner, link.repository, branch); + assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, head.commit.id); + const actionRuns = await client.listActionRuns(link.owner, link.repository, { branch, headSha: head.commit.id, limit: 30 }); + assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, head.commit.id); + auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "create_pull_request", branch, started })).operationId; + const existing = findReusableDeliveryPullRequest(await client.listPullRequests(link.owner, link.repository, "all"), branch, link.defaultBranch, head.commit.id); + const pullRequest = existing ?? await client.createPullRequest(link.owner, link.repository, { title: parsed.data.title, body: parsed.data.body, head: branch, base: link.defaultBranch }); + let updatedDelivery = await openFeatureDeliveryPullRequest(link.projectId, parsed.data.deliveryId, { number: pullRequest.number, url: pullRequest.html_url, title: pullRequest.title }, request.authUser?.username ?? "owner"); + if (pullRequest.merged) updatedDelivery = await reconcileFeatureDeliveryPullRequest({ owner: link.owner, repository: link.repository, number: pullRequest.number, state: "closed", merged: true, mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, headSha: pullRequest.head.sha, actor: request.authUser?.username ?? "owner" }) ?? updatedDelivery; + await finishWorkbenchMutation(auditId, "succeeded", started); + return reply.status(201).send(successEnvelope({ repository: link.fullName, pullRequest, delivery: updatedDelivery }, getRequestId(request))); + } catch (error) { + const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed"; + if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined); + else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "create_pull_request", branch: parsed.data.branch, outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.post("/api/projects/:projectId/gitea/pull-requests/:number/merge", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const parsed = mergePullRequestSchema.safeParse(request.body); + const number = Number((request.params as { number: string }).number); + if (!parsed.success || !Number.isInteger(number) || number < 1) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A delivery, pull request, exact head SHA and supported merge method are required", getRequestId(request))); + const projectId = (request.params as { projectId: string }).projectId; + const started = Date.now(); let link: LinkedRepository | undefined; let auditId: string | undefined; + try { + const delivery = await getFeatureDelivery(projectId, parsed.data.deliveryId); + link = await requireWorkbenchLink(projectId, true, delivery.spec.repositoryId); + if (!delivery.spec.branch || delivery.approvalState !== "approved" || delivery.spec.status !== "pull_request_open" || delivery.spec.pullRequest?.number !== number) throw new Error("FEATURE_DELIVERY_MERGE_FORBIDDEN"); + const branch = assertProjectBrainBranch(delivery.spec.branch, link.defaultBranch); + const client = getGiteaClient(env); const pullRequest = await client.getPullRequest(link.owner, link.repository, number); + if (pullRequest.head.ref !== branch || pullRequest.base.ref !== link.defaultBranch || pullRequest.head.sha !== parsed.data.expectedHeadSha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT"); + if (pullRequest.merged || pullRequest.state !== "open" || pullRequest.draft === true || pullRequest.mergeable === false) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_READY"); + const head = await client.getBranch(link.owner, link.repository, branch); + if (head.commit.id !== parsed.data.expectedHeadSha) throw new Error("FEATURE_DELIVERY_MERGE_HEAD_CONFLICT"); + assertFeatureDeliveryActionEvidence(delivery.spec.validationEvidence, parsed.data.expectedHeadSha); + const actionRuns = await client.listActionRuns(link.owner, link.repository, { branch, headSha: parsed.data.expectedHeadSha, limit: 30 }); + assertSuccessfulActionRuns(actionRuns.workflow_runs, branch, parsed.data.expectedHeadSha); + auditId = (await beginWorkbenchMutation({ link, actor: request.authUser?.username ?? "owner", toolName: "merge_pull_request", branch, started })).operationId; + await client.mergePullRequest(link.owner, link.repository, number, { method: parsed.data.method, headCommitId: parsed.data.expectedHeadSha }); + const merged = await client.getPullRequest(link.owner, link.repository, number); + if (!merged.merged) throw new Error("FEATURE_DELIVERY_MERGE_UNCONFIRMED"); + const reconciled = await reconcileFeatureDeliveryPullRequest({ owner: link.owner, repository: link.repository, number, state: "closed", merged: true, mergedAt: merged.merged_at, mergeCommitSha: merged.merge_commit_sha, headSha: merged.head.sha, actor: request.authUser?.username ?? "owner" }); + const verification = await queueFindingVerificationForDelivery(env, projectId, parsed.data.deliveryId).catch(() => undefined); + let branchDeleted = false; let branchCleanupError: string | undefined; + if (parsed.data.deleteBranch) { + try { await client.deleteBranch(link.owner, link.repository, branch); branchDeleted = true; } + catch (error) { if (error instanceof GiteaApiError && error.status === 404) branchDeleted = true; else branchCleanupError = error instanceof Error ? error.message : "GITEA_BRANCH_DELETE_FAILED"; } + } + await finishWorkbenchMutation(auditId, "succeeded", started); + return successEnvelope({ repository: link.fullName, pullRequest: merged, delivery: reconciled, branchDeleted, branchCleanupError, verificationQueued: Boolean(verification) }, getRequestId(request)); + } catch (error) { + const failure = integrationError(error); const deniedOutcome = failure.status === 403 ? "denied" : "failed"; + if (auditId) await finishWorkbenchMutation(auditId, deniedOutcome, started, failure.code).catch(() => undefined); + else await recordWorkbenchAudit({ link, actor: request.authUser?.username ?? "owner", toolName: "merge_pull_request", outcome: deniedOutcome, errorCode: failure.code, started }).catch(() => undefined); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.put("/api/projects/:projectId/gitea", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const parsed = linkSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "A valid owner/repository and access mode are required", getRequestId(request))); + try { + const projectId = (request.params as { projectId: string }).projectId; + const link = await linkRepository(env, projectId, parsed.data.repository, parsed.data.accessMode, request.authUser?.username ?? "owner", { role: parsed.data.role, isPrimary: parsed.data.isPrimary, transfer: parsed.data.transfer }); + return successEnvelope(link, getRequestId(request)); + } catch (error) { + const failure = integrationError(error); + return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); + } + }); + + app.post("/api/projects/:projectId/feature-deliveries/:deliveryId/reconcile", async (request, reply) => { + try { + const projectId = (request.params as { projectId: string; deliveryId: string }).projectId; + const deliveryId = (request.params as { projectId: string; deliveryId: string }).deliveryId; + const delivery = await getFeatureDelivery(projectId, deliveryId); const pullRequestNumber = delivery.spec.pullRequest?.number; + if (!pullRequestNumber) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_FOUND"); + const link = await requireWorkbenchLink(projectId, false, delivery.spec.repositoryId); const pullRequest = (await getGiteaClient(env).listPullRequests(link.owner, link.repository, "all")).find((item) => item.number === pullRequestNumber); + if (!pullRequest) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_FOUND"); + const reconciled = await reconcileFeatureDeliveryPullRequest({ owner: link.owner, repository: link.repository, number: pullRequest.number, state: pullRequest.state === "open" ? "open" : "closed", merged: pullRequest.merged, mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, headSha: pullRequest.head.sha, actor: request.authUser?.username ?? "owner" }); + return successEnvelope({ delivery: reconciled, pullRequest }, getRequestId(request)); + } catch (error) { const failure = integrationError(error); return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); } + }); + + app.post("/api/projects/:projectId/feature-deliveries/:deliveryId/validation/sync", async (request, reply) => { + try { + const { projectId, deliveryId } = request.params as { projectId: string; deliveryId: string }; + const delivery = await getFeatureDelivery(projectId, deliveryId); + if (!delivery.spec.branch || delivery.approvalState !== "approved" || !["executing", "validating", "pull_request_open"].includes(delivery.spec.status)) throw new Error("FEATURE_DELIVERY_PROGRESS_FORBIDDEN"); + const link = await requireWorkbenchLink(projectId, false, delivery.spec.repositoryId); const client = getGiteaClient(env); + const branch = assertProjectBrainBranch(delivery.spec.branch, link.defaultBranch); const head = await client.getBranch(link.owner, link.repository, branch); + const response = await client.listActionRuns(link.owner, link.repository, { branch, headSha: head.commit.id, limit: 30 }); + const runs = assertSuccessfulActionRuns(response.workflow_runs, branch, head.commit.id); + const run = runs.reduce((latest, candidate) => candidate.run_number > latest.run_number ? candidate : latest); + const evidence = { provider: "gitea_actions" as const, runId: run.id, runNumber: run.run_number, url: run.html_url, workflowPath: run.path, headSha: run.head_sha, conclusion: "success" as const, verifiedAt: new Date().toISOString() }; + return successEnvelope({ run, delivery: await recordFeatureDeliveryActionValidation(projectId, deliveryId, evidence, request.authUser?.username ?? "owner") }, getRequestId(request)); + } catch (error) { const failure = integrationError(error); return reply.status(failure.status).send(errorEnvelope(failure.code, failure.message, getRequestId(request))); } + }); + + app.delete("/api/projects/:projectId/gitea", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const removed = await unlinkRepository((request.params as { projectId: string }).projectId); + if (!removed) return reply.status(404).send(errorEnvelope("GITEA_LINK_NOT_FOUND", "No Gitea repository is linked to this project", getRequestId(request))); + return successEnvelope({ removed: true }, getRequestId(request)); + }); + + app.delete("/api/projects/:projectId/gitea/repositories/:repositoryLinkId", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + const { projectId, repositoryLinkId } = request.params as { projectId: string; repositoryLinkId: string }; + const removed = await unlinkRepositoryById(projectId, repositoryLinkId); + if (!removed) return reply.status(404).send(errorEnvelope("GITEA_LINK_NOT_FOUND", "Repository link not found", getRequestId(request))); + return successEnvelope({ removed: true }, getRequestId(request)); + }); + + app.get("/api/integrations/gitea/audit", async (request, reply) => { + const denied = ownerOnly(request, reply, getRequestId(request)); if (denied) return denied; + return successEnvelope(await prisma.mcpAuditEvent.findMany({ orderBy: { createdAt: "desc" }, take: 100 }), getRequestId(request)); + }); +}; diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts new file mode 100644 index 0000000..958f3d9 --- /dev/null +++ b/backend/src/routes/health.ts @@ -0,0 +1,26 @@ +import type { FastifyInstance } from "fastify"; +import type { AppEnv } from "../config/env.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { HealthResponse } from "../types/api.js"; +import { successEnvelope } from "../utils/envelope.js"; + +type HealthRouteOptions = { + env: AppEnv; + version: string; +}; + +export const registerHealthRoutes = async ( + app: FastifyInstance, + options: HealthRouteOptions, +) => { + app.get("/api/health", async (request) => { + const data: HealthResponse = { + status: "ok", + service: "projectbrain-api", + version: options.version, + environment: options.env.NODE_ENV, + }; + + return successEnvelope(data, getRequestId(request)); + }); +}; diff --git a/backend/src/routes/initiatives.ts b/backend/src/routes/initiatives.ts new file mode 100644 index 0000000..5b6d25b --- /dev/null +++ b/backend/src/routes/initiatives.ts @@ -0,0 +1,148 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { + createFeatureCandidatesForInitiative, + createInitiativeForProject, + getInitiativeForProject, + listInitiativesForProject, + updateInitiativeForProject, +} from "../repositories/initiativesRepository.js"; +import { approveFeatureDelivery, createFeatureDelivery, getFeatureDelivery, listFeatureDeliveries, rejectFeatureDelivery, updateFeatureDelivery } from "../repositories/featureDeliveryRepository.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +type InitiativeRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const initiativeParamsSchema = z.object({ projectId: idSchema, initiativeId: idSchema }); +const initiativeStatusSchema = z.enum(["proposed", "planned", "active", "complete", "paused"]); +const initiativePrioritySchema = z.enum(["critical", "important", "normal", "low"]); +const createInitiativeBodySchema = z.object({ + title: z.string().min(3).max(160), + intent: z.string().min(8).max(4000), + priority: initiativePrioritySchema.optional(), + status: initiativeStatusSchema.optional(), + confidence: z.number().int().min(0).max(100).optional(), + featureIds: z.array(z.string()).optional(), + sourceType: z.string().max(80).optional(), +}); +const updateInitiativeBodySchema = createInitiativeBodySchema.partial().extend({ + approvalState: z.enum(["pending", "approved", "rejected"]).optional(), +}); +const featureDeliveryBodySchema = z.object({ + title: z.string().min(3).max(160), intent: z.string().min(8).max(4000), priority: initiativePrioritySchema.optional(), + problem: z.string().min(8).max(8000), desiredOutcome: z.string().min(8).max(8000), users: z.array(z.string().min(1).max(300)).max(30).optional(), + goals: z.array(z.string().min(1).max(1000)).min(1).max(50), nonGoals: z.array(z.string().min(1).max(1000)).max(50).optional(), + acceptanceCriteria: z.array(z.string().min(1).max(1000)).min(1).max(100), implementationPlan: z.array(z.string().min(1).max(2000)).min(1).max(100), + risks: z.array(z.string().min(1).max(1000)).max(50).optional(), validationCommands: z.array(z.string().min(1).max(500)).min(1).max(30), +}); +const rejectDeliveryBodySchema = z.object({ reason: z.string().trim().max(2000).optional() }); +const sendValidationError = (request: FastifyRequest, reply: FastifyReply, validation: z.ZodIssue[]) => reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), validation.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); +const parseParams = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.params); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const parseBody = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.body ?? {}); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const requireDatabase = (dataSource: InitiativeRouteOptions["dataSource"], request: FastifyRequest, reply: FastifyReply) => { + if (dataSource === "database") return false; + reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Initiative persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); + return true; +}; + +export const registerInitiativeRoutes = async (app: FastifyInstance, options: InitiativeRouteOptions = {}) => { + const dataSource = options.dataSource ?? "fixture"; + + app.get("/api/projects/:projectId/initiatives", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + return successEnvelope({ items: await listInitiativesForProject(params.projectId) }, getRequestId(request)); + }); + + app.post("/api/projects/:projectId/initiatives", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; + const body = parseBody(createInitiativeBodySchema, request, reply); if (!body) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + return reply.status(201).send(successEnvelope(await createInitiativeForProject(params.projectId, body), getRequestId(request))); + }); + + app.get("/api/projects/:projectId/initiatives/:initiativeId", async (request, reply) => { + const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const initiative = await getInitiativeForProject(params.projectId, params.initiativeId); + if (!initiative) return reply.status(404).send(errorEnvelope("INITIATIVE_NOT_FOUND", "Initiative not found.", getRequestId(request))); + if (initiative.sourceType === "chatgpt_feature_delivery") { + try { + const delivery = await getFeatureDelivery(params.projectId, params.initiativeId); + return successEnvelope({ ...initiative, approvalState: delivery.approvalState, metadata: delivery.spec }, getRequestId(request)); + } catch { /* Preserve the initiative even if legacy data has no delivery plan. */ } + } + return successEnvelope(initiative, getRequestId(request)); + }); + + app.patch("/api/projects/:projectId/initiatives/:initiativeId", async (request, reply) => { + const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply; + const body = parseBody(updateInitiativeBodySchema, request, reply); if (!body) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const initiative = await updateInitiativeForProject(params.projectId, params.initiativeId, body); + if (!initiative) return reply.status(404).send(errorEnvelope("INITIATIVE_NOT_FOUND", "Initiative not found.", getRequestId(request))); + return successEnvelope(initiative, getRequestId(request)); + }); + + app.post("/api/projects/:projectId/initiatives/:initiativeId/feature-candidates", async (request, reply) => { + const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + const result = await createFeatureCandidatesForInitiative(params.projectId, params.initiativeId); + if (!result) return reply.status(404).send(errorEnvelope("INITIATIVE_NOT_FOUND", "Initiative not found.", getRequestId(request))); + return reply.status(201).send(successEnvelope(result, getRequestId(request))); + }); + + app.get("/api/projects/:projectId/feature-deliveries", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + return successEnvelope({ items: await listFeatureDeliveries(params.projectId) }, getRequestId(request)); + }); + + app.post("/api/projects/:projectId/feature-deliveries", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; + const body = parseBody(featureDeliveryBodySchema, request, reply); if (!body) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + return reply.status(201).send(successEnvelope(await createFeatureDelivery(params.projectId, body, request.authUser?.username ?? "owner"), getRequestId(request))); + }); + + app.get("/api/projects/:projectId/feature-deliveries/:initiativeId", async (request, reply) => { + const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + try { return successEnvelope(await getFeatureDelivery(params.projectId, params.initiativeId), getRequestId(request)); } + catch { return reply.status(404).send(errorEnvelope("FEATURE_DELIVERY_NOT_FOUND", "Feature delivery not found.", getRequestId(request))); } + }); + + app.patch("/api/projects/:projectId/feature-deliveries/:initiativeId", async (request, reply) => { + const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply; + const body = parseBody(featureDeliveryBodySchema.partial(), request, reply); if (!body) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + try { return successEnvelope(await updateFeatureDelivery(params.projectId, params.initiativeId, body, request.authUser?.username ?? "owner"), getRequestId(request)); } + catch (error) { + const code = error instanceof Error ? error.message : "FEATURE_DELIVERY_UPDATE_FAILED"; + return reply.status(code === "FEATURE_DELIVERY_PLAN_LOCKED" ? 409 : 404).send(errorEnvelope(code, code === "FEATURE_DELIVERY_PLAN_LOCKED" ? "An active delivery plan can no longer be edited." : "Feature delivery not found.", getRequestId(request))); + } + }); + + app.post("/api/projects/:projectId/feature-deliveries/:initiativeId/approve", async (request, reply) => { + const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + try { return successEnvelope(await approveFeatureDelivery(params.projectId, params.initiativeId, request.authUser?.username ?? "owner"), getRequestId(request)); } + catch (error) { + const code = error instanceof Error ? error.message : "FEATURE_DELIVERY_APPROVAL_FAILED"; + return reply.status(code === "FEATURE_DELIVERY_PLAN_INCOMPLETE" ? 400 : 404).send(errorEnvelope(code, code === "FEATURE_DELIVERY_PLAN_INCOMPLETE" ? "The plan needs goals, acceptance criteria, implementation steps and validation commands." : "Feature delivery not found.", getRequestId(request))); + } + }); + + app.post("/api/projects/:projectId/feature-deliveries/:initiativeId/reject", async (request, reply) => { + const params = parseParams(initiativeParamsSchema, request, reply); if (!params) return reply; + const body = parseBody(rejectDeliveryBodySchema, request, reply); if (!body) return reply; + if (requireDatabase(dataSource, request, reply)) return reply; + try { return successEnvelope(await rejectFeatureDelivery(params.projectId, params.initiativeId, request.authUser?.username ?? "owner", body.reason), getRequestId(request)); } + catch (error) { + const code = error instanceof Error ? error.message : "FEATURE_DELIVERY_REJECTION_FAILED"; + return reply.status(code === "FEATURE_DELIVERY_REJECTION_LOCKED" ? 409 : 404).send(errorEnvelope(code, code === "FEATURE_DELIVERY_REJECTION_LOCKED" ? "An active delivery can no longer be rejected." : "Feature delivery not found.", getRequestId(request))); + } + }); +}; diff --git a/backend/src/routes/insights.ts b/backend/src/routes/insights.ts new file mode 100644 index 0000000..48ea383 --- /dev/null +++ b/backend/src/routes/insights.ts @@ -0,0 +1,268 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { + applyInsightFilters, + findInsight, + listInsightsForProject, + reviewInsight, +} from "../fixtures/insightFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + InsightListFilters, + InsightReviewAction, + InsightSeverity, + InsightStatus, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + getIdempotencyKey, + getIdempotentResponse, + setIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const insightParamsSchema = z.object({ projectId: idSchema, insightId: idSchema }); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const insightQuerySchema = paginationQuerySchema.extend({ + category: z.string().optional(), + severity: z.string().optional(), + status: z.string().optional(), + confidence: z.coerce.number().optional(), + reviewed: z.coerce.boolean().optional(), +}); +const reviewBodySchema = z.object({ + action: z.string().optional(), + reason: z.string().optional(), +}); +const reviewActions = ["accept", "reject", "ignore"] as const; + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + sendProjectNotFound(request, reply, projectId); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare( + String(right[field] ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +const toPaginationInput = ( + query: z.output, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const toFilters = ( + query: z.output, +): InsightListFilters => ({ + ...(query.category ? { category: splitCsv(query.category) } : {}), + ...(query.severity + ? { severity: splitCsv(query.severity) as InsightSeverity[] } + : {}), + ...(query.status ? { status: splitCsv(query.status) as InsightStatus[] } : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.reviewed !== undefined ? { reviewed: query.reviewed } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + return idempotencyKey; +}; + +const sendInsightNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + insightId: string, +) => + reply.status(404).send( + errorEnvelope( + "INSIGHT_NOT_FOUND", + `Insight '${insightId}' was not found.`, + getRequestId(request), + ), + ); + +export const registerInsightRoutes = async (app: FastifyInstance) => { + app.get("/api/projects/:projectId/insights", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(insightQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + const insights = sortByField( + applyInsightFilters(listInsightsForProject(params.projectId), filters), + query.sort, + ); + + return successEnvelope( + paginate(insights, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get( + "/api/projects/:projectId/insights/:insightId", + async (request, reply) => { + const params = parseParams(insightParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const insight = findInsight(params.projectId, params.insightId); + if (!insight) return sendInsightNotFound(request, reply, params.insightId); + return successEnvelope(insight, getRequestId(request)); + }, + ); + + app.patch( + "/api/projects/:projectId/insights/:insightId/review", + async (request, reply) => { + const params = parseParams(insightParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = reviewBodySchema.safeParse(request.body); + if (!parsedBody.success) { + sendValidationError(request, reply, parsedBody.error.issues); + return reply; + } + + const action = parsedBody.data.action as InsightReviewAction | undefined; + if (!action || !reviewActions.includes(action)) { + return reply.status(400).send( + errorEnvelope( + "REVIEW_ACTION_INVALID", + "Review action must be accept, reject, or ignore.", + getRequestId(request), + ), + ); + } + + const insight = reviewInsight( + params.projectId, + params.insightId, + action, + parsedBody.data.reason, + ); + if (!insight) return sendInsightNotFound(request, reply, params.insightId); + + const body = successEnvelope(insight, getRequestId(request)); + setIdempotentResponse(cacheKey, { statusCode: 200, body }); + return reply.status(200).send(body); + }, + ); +}; diff --git a/backend/src/routes/knowledge.ts b/backend/src/routes/knowledge.ts new file mode 100644 index 0000000..2b0dbd3 --- /dev/null +++ b/backend/src/routes/knowledge.ts @@ -0,0 +1,99 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { findContextPack } from "../fixtures/contextPackFixtures.js"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { + applyKnowledgeFilters, + createKnowledge, + findKnowledge, + includeKnowledgeInContextPack, + listKnowledgeForProject, + updateKnowledge, +} from "../fixtures/knowledgeFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { + createKnowledgeItem as createDatabaseKnowledge, + getKnowledgeBySlugOrId as getDatabaseKnowledgeBySlugOrId, + includeKnowledgeInContextPack as includeDatabaseKnowledgeInContextPack, + listKnowledgeForProject as listDatabaseKnowledgeForProject, + projectExists as databaseProjectExists, + updateKnowledgeItem as updateDatabaseKnowledge, +} from "../repositories/p8KnowledgeMemoryRepository.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { KnowledgeListFilters } from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js"; + +type KnowledgeRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const knowledgeParamsSchema = z.object({ projectId: idSchema, knowledgeId: idSchema }); +const paginationQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(25), cursor: z.string().optional(), sort: z.string().optional() }); +const knowledgeQuerySchema = paginationQuerySchema.extend({ category: z.string().optional(), featureId: z.string().optional(), source: z.string().optional(), confidence: z.coerce.number().optional(), search: z.string().optional() }); +const knowledgeBodySchema = z.object({ title: z.string().optional(), category: z.string().optional(), content: z.string().optional(), linkedFeatureIds: z.array(z.string()).optional(), source: z.string().optional(), confidence: z.number().min(0).max(100).optional() }); +const includeBodySchema = z.object({ contextPackId: z.string().optional() }); + +const sendValidationError = (request: FastifyRequest, reply: FastifyReply, validation: z.ZodIssue[]) => reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), validation.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); +const parseParams = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.params); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const parseQuery = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.query); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const ensureProject = async (request: FastifyRequest, reply: FastifyReply, projectId: string, dataSource: AppEnv["PROJECTBRAIN_DATA_SOURCE"]) => { const exists = dataSource === "database" ? await databaseProjectExists(projectId) : Boolean(findProject(projectId)); if (!exists) { reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request))); return false; } return true; }; +const splitCsv = (value: string | undefined): string[] => value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; +const sortByField = >(items: T[], sort: string | undefined): T[] => { if (!sort) return items; const [field, direction = "asc"] = sort.split(":"); if (!field) return items; return [...items].sort((left, right) => { const comparison = String(left[field] ?? "").localeCompare(String(right[field] ?? ""), undefined, { numeric: true }); return direction === "desc" ? -comparison : comparison; }); }; +const toPaginationInput = (query: z.output, filters: PaginationInput["filters"]): PaginationInput => ({ page: query.page, pageSize: query.pageSize, cursor: query.cursor, sort: query.sort, filters }); +const toFilters = (query: z.output): KnowledgeListFilters => ({ ...(query.category ? { category: splitCsv(query.category) } : {}), ...(query.featureId ? { featureId: query.featureId } : {}), ...(query.source ? { source: splitCsv(query.source) } : {}), ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), ...(query.search ? { search: query.search } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) }); +const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { const rawKey = request.headers["idempotency-key"]; const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; if (!idempotencyKey?.trim()) { reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); return undefined; } return idempotencyKey; }; +const sendKnowledgeNotFound = (request: FastifyRequest, reply: FastifyReply, knowledgeId: string) => reply.status(404).send(errorEnvelope("KNOWLEDGE_NOT_FOUND", `Knowledge item '${knowledgeId}' was not found.`, getRequestId(request))); +const sendContextPackNotFound = (request: FastifyRequest, reply: FastifyReply, contextPackId: string) => reply.status(404).send(errorEnvelope("CONTEXT_PACK_NOT_FOUND", `Context pack '${contextPackId}' was not found.`, getRequestId(request))); + +export const registerKnowledgeRoutes = async (app: FastifyInstance, options: KnowledgeRouteOptions = {}) => { + const dataSource = options.dataSource ?? "fixture"; + app.get("/api/projects/:projectId/knowledge", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; + const query = parseQuery(knowledgeQuerySchema, request, reply); if (!query) return reply; + const filters = toFilters(query); + const knowledge = dataSource === "database" ? await listDatabaseKnowledgeForProject(params.projectId, filters) : applyKnowledgeFilters(listKnowledgeForProject(params.projectId), filters); + return successEnvelope(paginate(sortByField(knowledge, query.sort), toPaginationInput(query, filters)), getRequestId(request)); + }); + app.get("/api/projects/:projectId/knowledge/:knowledgeId", async (request, reply) => { + const params = parseParams(knowledgeParamsSchema, request, reply); if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; + const item = dataSource === "database" ? await getDatabaseKnowledgeBySlugOrId(params.projectId, params.knowledgeId) : findKnowledge(params.projectId, params.knowledgeId); + if (!item) return sendKnowledgeNotFound(request, reply, params.knowledgeId); + return successEnvelope(item, getRequestId(request)); + }); + app.post("/api/projects/:projectId/knowledge", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); + const parsedBody = knowledgeBodySchema.safeParse(request.body); + if (!parsedBody.success || !parsedBody.data.title?.trim() || !parsedBody.data.content?.trim()) return reply.status(400).send(errorEnvelope("KNOWLEDGE_VALIDATION_FAILED", "title and content are required to create knowledge.", getRequestId(request))); + const item = dataSource === "database" ? await createDatabaseKnowledge(params.projectId, { ...parsedBody.data, title: parsedBody.data.title, content: parsedBody.data.content }) : createKnowledge(params.projectId, { ...parsedBody.data, title: parsedBody.data.title, content: parsedBody.data.content }); + const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 201, body }); return reply.status(201).send(body); + }); + app.patch("/api/projects/:projectId/knowledge/:knowledgeId", async (request, reply) => { + const params = parseParams(knowledgeParamsSchema, request, reply); if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); + const parsedBody = knowledgeBodySchema.safeParse(request.body); + if (!parsedBody.success || (parsedBody.data.title !== undefined && !parsedBody.data.title.trim()) || (parsedBody.data.content !== undefined && !parsedBody.data.content.trim())) return reply.status(400).send(errorEnvelope("KNOWLEDGE_VALIDATION_FAILED", "Knowledge update body is invalid.", getRequestId(request))); + const item = dataSource === "database" ? await updateDatabaseKnowledge(params.projectId, params.knowledgeId, parsedBody.data) : updateKnowledge(params.projectId, params.knowledgeId, parsedBody.data); + if (!item) return sendKnowledgeNotFound(request, reply, params.knowledgeId); + const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); + }); + app.post("/api/projects/:projectId/knowledge/:knowledgeId/include-in-context-pack", async (request, reply) => { + const params = parseParams(knowledgeParamsSchema, request, reply); if (!params) return reply; + if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); + const parsedBody = includeBodySchema.safeParse(request.body); if (!parsedBody.success || !parsedBody.data.contextPackId?.trim()) return reply.status(400).send(errorEnvelope("INCLUDE_IN_CONTEXT_PACK_FAILED", "contextPackId is required.", getRequestId(request))); + let item; + if (dataSource === "database") { try { item = await includeDatabaseKnowledgeInContextPack(params.projectId, params.knowledgeId, parsedBody.data.contextPackId); } catch (error) { if ((error as Error).message === "CONTEXT_PACK_NOT_FOUND") return sendContextPackNotFound(request, reply, parsedBody.data.contextPackId); throw error; } } + else { if (!findKnowledge(params.projectId, params.knowledgeId)) return sendKnowledgeNotFound(request, reply, params.knowledgeId); if (!findContextPack(params.projectId, parsedBody.data.contextPackId)) return sendContextPackNotFound(request, reply, parsedBody.data.contextPackId); item = includeKnowledgeInContextPack(params.projectId, params.knowledgeId, parsedBody.data.contextPackId); } + if (!item) return sendKnowledgeNotFound(request, reply, params.knowledgeId); + const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); + }); +}; diff --git a/backend/src/routes/mcp.ts b/backend/src/routes/mcp.ts new file mode 100644 index 0000000..e3554ca --- /dev/null +++ b/backend/src/routes/mcp.ts @@ -0,0 +1,72 @@ +import type { FastifyInstance } from "fastify"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import type { AppEnv } from "../config/env.js"; +import { createProjectBrainMcpServer } from "../mcp/autonomyServer.js"; +import { registerPortfolioMcpTools } from "../mcp/portfolioTools.js"; +import { verifyMcpAccessToken } from "../security/mcpOAuth.js"; +import { prisma } from "../db/prisma.js"; +import { isMcpHubServiceToken, mcpHubActor } from "../security/mcpServiceAuth.js"; +import { augmentProjectBrainInitialize, handleProjectBrainSkillMethod } from "../mcp/skills.js"; + +const bearer = (value: string | undefined) => value?.match(/^Bearer\s+(.+)$/i)?.[1]; + +export const registerMcpRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + const { env } = options; + app.route({ method: ["GET", "POST", "DELETE"], url: "/mcp", config: { rateLimit: { max: env.PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE, timeWindow: "1 minute" } }, handler: async (request, reply) => { + if (!env.PROJECTBRAIN_MCP_ENABLED || !env.PROJECTBRAIN_PUBLIC_URL) return reply.status(404).send({ error: "MCP_DISABLED" }); + const token = bearer(request.headers.authorization); + const serviceAuthenticated = isMcpHubServiceToken(env.PROJECTBRAIN_HUB_SERVICE_TOKEN, token); + let auth; + if (!serviceAuthenticated) { + try { auth = token ? verifyMcpAccessToken(env, token) : undefined; } catch { auth = undefined; } + } + if (!serviceAuthenticated && !auth) { + const metadata = `${env.PROJECTBRAIN_PUBLIC_URL.replace(/\/$/, "")}/.well-known/oauth-protected-resource/mcp`; + return reply.header("WWW-Authenticate", `Bearer resource_metadata="${metadata}"`).status(401).send({ error: "invalid_token", error_description: "A valid ProjectBrain MCP OAuth token is required" }); + } + const user = auth ? await prisma.user.findUnique({ where: { username: auth.subject }, select: { credentialVersion: true, disabledAt: true, role: true } }) : undefined; + if (auth && (!user || user.disabledAt || user.role !== "owner" || user.credentialVersion !== auth.credentialVersion)) { + const metadata = `${env.PROJECTBRAIN_PUBLIC_URL.replace(/\/$/, "")}/.well-known/oauth-protected-resource/mcp`; + return reply.header("WWW-Authenticate", `Bearer error="invalid_token", resource_metadata="${metadata}"`).status(401).send({ error: "invalid_token", error_description: "The ProjectBrain identity or credentials changed; authorize the connector again" }); + } + + const actor = serviceAuthenticated ? mcpHubActor(request.headers["x-itworx-client-id"]) : auth!.subject; + const rpc = request.method === "POST" && request.body && typeof request.body === "object" && !Array.isArray(request.body) + ? request.body as { jsonrpc?: string; id?: string | number | null; method?: string; params?: Record } + : undefined; + if (rpc?.method) { + try { + const extensionResult = handleProjectBrainSkillMethod(rpc.method, rpc.params ?? {}); + if (extensionResult !== undefined) return reply.send({ jsonrpc: "2.0", id: rpc.id ?? null, result: extensionResult }); + } catch (error) { + return reply.status(400).send({ jsonrpc: "2.0", id: rpc.id ?? null, error: { code: -32602, message: error instanceof Error ? error.message : "Invalid MCP Skills request" } }); + } + } + + const target = new URL(request.url, env.PROJECTBRAIN_PUBLIC_URL); + const headers = new Headers(); + for (const [key, value] of Object.entries(request.headers)) if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(", ") : String(value)); + const webRequest = new Request(target, { method: request.method, headers, ...(request.method === "POST" ? { body: JSON.stringify(request.body) } : {}) }); + const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true }); + const server = createProjectBrainMcpServer(env, actor); + registerPortfolioMcpTools(server, env, actor); + try { + await server.connect(transport); + const response = await transport.handleRequest(webRequest, { parsedBody: request.body, authInfo: { + token: token!, clientId: serviceAuthenticated ? "itworx-mcp-hub" : auth!.clientId, + scopes: serviceAuthenticated ? ["mcp:tools", "mcp:skills", "mcp:resources"] : auth!.scope.split(" "), + expiresAt: serviceAuthenticated ? Math.floor(Date.now() / 1000) + 300 : auth!.exp, + } }); + reply.status(response.status); + response.headers.forEach((value, key) => { if (!["content-length", "transfer-encoding", "content-encoding"].includes(key.toLowerCase())) reply.header(key, value); }); + let bytes = Buffer.from(await response.arrayBuffer()); + if (rpc?.method === "initialize" && response.headers.get("content-type")?.toLowerCase().includes("application/json")) { + try { bytes = Buffer.from(JSON.stringify(augmentProjectBrainInitialize(JSON.parse(bytes.toString("utf8"))))); } catch { /* Preserve the valid upstream response. */ } + } + return reply.send(bytes); + } finally { + await transport.close().catch(() => undefined); + await server.close().catch(() => undefined); + } + } }); +}; diff --git a/backend/src/routes/mcpOAuth.ts b/backend/src/routes/mcpOAuth.ts new file mode 100644 index 0000000..937011d --- /dev/null +++ b/backend/src/routes/mcpOAuth.ts @@ -0,0 +1,76 @@ +import type { FastifyInstance, FastifyReply } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { authenticate } from "../security/authStore.js"; +import { exchangeAuthorizationCode, exchangeRefreshToken, issueAuthorizationCode, registerOAuthClient, verifyOAuthClient } from "../security/mcpOAuth.js"; + +const escapeHtml = (value: string) => value.replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char] ?? char)); +const oauthError = (reply: FastifyReply, error: string, description: string, status = 400) => reply.status(status).send({ error, error_description: description }); + +const authorizeSchema = z.object({ + response_type: z.literal("code"), client_id: z.string().min(1), redirect_uri: z.string().url(), + state: z.string().optional().default(""), scope: z.string().optional().default("mcp:tools"), + code_challenge: z.string().min(43).max(128), code_challenge_method: z.literal("S256"), + resource: z.string().url().optional(), username: z.string().optional(), password: z.string().optional(), +}); + +export const registerMcpOAuthRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + const { env } = options; + const base = () => env.PROJECTBRAIN_PUBLIC_URL?.replace(/\/$/, "") ?? ""; + + app.get("/.well-known/oauth-authorization-server", async () => ({ + issuer: base(), authorization_endpoint: `${base()}/oauth/authorize`, token_endpoint: `${base()}/oauth/token`, registration_endpoint: `${base()}/oauth/register`, + response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["none"], code_challenge_methods_supported: ["S256"], scopes_supported: ["mcp:tools"], + })); + app.get("/.well-known/oauth-protected-resource", async () => ({ resource: `${base()}/mcp`, authorization_servers: [base()], scopes_supported: ["mcp:tools"], bearer_methods_supported: ["header"] })); + app.get("/.well-known/oauth-protected-resource/mcp", async () => ({ resource: `${base()}/mcp`, authorization_servers: [base()], scopes_supported: ["mcp:tools"], bearer_methods_supported: ["header"] })); + + app.post("/oauth/register", async (request, reply) => { + const parsed = z.object({ redirect_uris: z.array(z.string().url()).min(1).max(10), client_name: z.string().optional(), token_endpoint_auth_method: z.literal("none").optional() }).safeParse(request.body); + if (!parsed.success) return oauthError(reply, "invalid_client_metadata", "Valid redirect_uris are required"); + try { + const client = registerOAuthClient(env, { redirectUris: parsed.data.redirect_uris, clientName: parsed.data.client_name }); + return reply.status(201).send({ client_id: client.clientId, client_id_issued_at: Math.floor(Date.now() / 1000), client_name: parsed.data.client_name ?? "ChatGPT", redirect_uris: client.redirectUris, token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"] }); + } catch { return oauthError(reply, "invalid_redirect_uri", "Only HTTPS and local development redirect URIs are allowed"); } + }); + + const renderAuthorization = (query: Record, message?: string) => { + const hidden = Object.entries(query).filter(([key]) => !["username", "password"].includes(key)).map(([key, value]) => ``).join(""); + return `ProjectBrain koppelen

ProjectBrain koppelen met ChatGPT

Geef ChatGPT toegang tot de repositories die je expliciet aan ProjectBrain-projecten hebt gekoppeld. Wijzigingen gebeuren op geldige niet-standaardbranches en gaan via pull requests.

${message ? `

${escapeHtml(message)}

` : ""}
${hidden}

`; + }; + + app.get("/oauth/authorize", async (request, reply) => { + const parsed = authorizeSchema.safeParse(request.query); + if (!parsed.success || parsed.data.scope.split(" ").some((scope) => scope !== "mcp:tools")) return oauthError(reply, "invalid_request", "OAuth authorization request is invalid"); + try { verifyOAuthClient(env, parsed.data.client_id, parsed.data.redirect_uri); } catch { return oauthError(reply, "invalid_client", "OAuth client or redirect URI is invalid", 401); } + return reply.type("text/html; charset=utf-8").send(renderAuthorization(parsed.data)); + }); + + app.post("/oauth/authorize", { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, async (request, reply) => { + const parsed = authorizeSchema.safeParse(request.body); + if (!parsed.success || !parsed.data.username || !parsed.data.password) return oauthError(reply, "invalid_request", "OAuth authorization request is invalid"); + try { verifyOAuthClient(env, parsed.data.client_id, parsed.data.redirect_uri); } catch { return oauthError(reply, "invalid_client", "OAuth client or redirect URI is invalid", 401); } + const user = await authenticate(env, parsed.data.username, parsed.data.password); + if (!user || user.role !== "owner") return reply.status(401).type("text/html; charset=utf-8").send(renderAuthorization(parsed.data, "Ongeldige ProjectBrain-inloggegevens.")); + const audience = parsed.data.resource ?? `${base()}/mcp`; + if (audience !== `${base()}/mcp`) return oauthError(reply, "invalid_target", "Requested MCP resource is not available"); + const code = issueAuthorizationCode(env, { clientId: parsed.data.client_id, redirectUri: parsed.data.redirect_uri, codeChallenge: parsed.data.code_challenge, scope: "mcp:tools", subject: user.username, credentialVersion: user.credentialVersion, audience }); + const redirect = new URL(parsed.data.redirect_uri); redirect.searchParams.set("code", code); if (parsed.data.state) redirect.searchParams.set("state", parsed.data.state); + return reply.redirect(redirect.toString()); + }); + + app.post("/oauth/token", async (request, reply) => { + const body = request.body as Record | undefined; + if (!body?.grant_type || !body.client_id) return oauthError(reply, "invalid_request", "grant_type and client_id are required"); + try { + const pair = body.grant_type === "authorization_code" + ? exchangeAuthorizationCode(env, { code: body.code, clientId: body.client_id, redirectUri: body.redirect_uri, codeVerifier: body.code_verifier }) + : body.grant_type === "refresh_token" + ? exchangeRefreshToken(env, body.refresh_token, body.client_id) + : undefined; + if (!pair) return oauthError(reply, "unsupported_grant_type", "Only authorization_code and refresh_token are supported"); + return { access_token: pair.accessToken, token_type: "Bearer", expires_in: pair.expiresIn, refresh_token: pair.refreshToken, scope: "mcp:tools" }; + } catch { return oauthError(reply, "invalid_grant", "The authorization grant is invalid or expired", 401); } + }); +}; diff --git a/backend/src/routes/memoryRules.ts b/backend/src/routes/memoryRules.ts new file mode 100644 index 0000000..4899fa3 --- /dev/null +++ b/backend/src/routes/memoryRules.ts @@ -0,0 +1,43 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { findContextPack } from "../fixtures/contextPackFixtures.js"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { applyMemoryRuleFilters, createMemoryRule, findMemoryRule, includeMemoryRuleInContextPack, listMemoryRulesForProject, updateMemoryRule } from "../fixtures/memoryRuleFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { createMemoryRuleItem as createDatabaseMemoryRule, getMemoryRuleBySlugOrId as getDatabaseMemoryRuleBySlugOrId, includeMemoryRuleInContextPack as includeDatabaseMemoryRuleInContextPack, listMemoryRulesForProject as listDatabaseMemoryRulesForProject, projectExists as databaseProjectExists, updateMemoryRuleItem as updateDatabaseMemoryRule } from "../repositories/p8KnowledgeMemoryRepository.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { MemoryRuleCategory, MemoryRuleListFilters, MemoryRuleScope, MemoryRuleSeverity } from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js"; + +type MemoryRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const ruleParamsSchema = z.object({ projectId: idSchema, ruleId: idSchema }); +const paginationQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(25), cursor: z.string().optional(), sort: z.string().optional() }); +const scopes = ["global", "module", "feature", "prompt_only"] as const; +const categories = ["UX", "Technical", "Architecture", "Do Not Break", "Naming", "Workflow", "Security"] as const; +const severities = ["info", "important", "critical", "do_not_break"] as const; +const memoryRuleQuerySchema = paginationQuerySchema.extend({ scope: z.string().optional(), category: z.string().optional(), severity: z.string().optional(), includeInContextPacks: z.coerce.boolean().optional(), featureId: z.string().optional(), search: z.string().optional() }); +const memoryRuleBodySchema = z.object({ title: z.string().optional(), rule: z.string().optional(), scope: z.enum(scopes).optional(), category: z.enum(categories).optional(), severity: z.enum(severities).optional(), linkedFeatureIds: z.array(z.string()).optional(), includeInContextPacks: z.boolean().optional() }); +const includeBodySchema = z.object({ contextPackId: z.string().optional() }); +const sendValidationError = (request: FastifyRequest, reply: FastifyReply, validation: z.ZodIssue[]) => reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), validation.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); +const parseParams = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.params); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const parseQuery = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.query); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; +const ensureProject = async (request: FastifyRequest, reply: FastifyReply, projectId: string, dataSource: AppEnv["PROJECTBRAIN_DATA_SOURCE"]) => { const exists = dataSource === "database" ? await databaseProjectExists(projectId) : Boolean(findProject(projectId)); if (!exists) { reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request))); return false; } return true; }; +const splitCsv = (value: string | undefined): string[] => value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; +const sortByField = >(items: T[], sort: string | undefined): T[] => { if (!sort) return items; const [field, direction = "asc"] = sort.split(":"); if (!field) return items; return [...items].sort((left, right) => { const comparison = String(left[field] ?? "").localeCompare(String(right[field] ?? ""), undefined, { numeric: true }); return direction === "desc" ? -comparison : comparison; }); }; +const toPaginationInput = (query: z.output, filters: PaginationInput["filters"]): PaginationInput => ({ page: query.page, pageSize: query.pageSize, cursor: query.cursor, sort: query.sort, filters }); +const toFilters = (query: z.output): MemoryRuleListFilters => ({ ...(query.scope ? { scope: splitCsv(query.scope) as MemoryRuleScope[] } : {}), ...(query.category ? { category: splitCsv(query.category) as MemoryRuleCategory[] } : {}), ...(query.severity ? { severity: splitCsv(query.severity) as MemoryRuleSeverity[] } : {}), ...(query.includeInContextPacks !== undefined ? { includeInContextPacks: query.includeInContextPacks } : {}), ...(query.featureId ? { featureId: query.featureId } : {}), ...(query.search ? { search: query.search } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) }); +const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { const rawKey = request.headers["idempotency-key"]; const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; if (!idempotencyKey?.trim()) { reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); return undefined; } return idempotencyKey; }; +const sendMemoryRuleNotFound = (request: FastifyRequest, reply: FastifyReply, ruleId: string) => reply.status(404).send(errorEnvelope("MEMORY_RULE_NOT_FOUND", `Memory rule '${ruleId}' was not found.`, getRequestId(request))); +const sendContextPackNotFound = (request: FastifyRequest, reply: FastifyReply, contextPackId: string) => reply.status(404).send(errorEnvelope("CONTEXT_PACK_NOT_FOUND", `Context pack '${contextPackId}' was not found.`, getRequestId(request))); +export const registerMemoryRuleRoutes = async (app: FastifyInstance, options: MemoryRouteOptions = {}) => { + const dataSource = options.dataSource ?? "fixture"; + app.get("/api/projects/:projectId/memory-rules", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const query = parseQuery(memoryRuleQuerySchema, request, reply); if (!query) return reply; const filters = toFilters(query); const rules = dataSource === "database" ? await listDatabaseMemoryRulesForProject(params.projectId, filters) : applyMemoryRuleFilters(listMemoryRulesForProject(params.projectId), filters); return successEnvelope(paginate(sortByField(rules, query.sort), toPaginationInput(query, filters)), getRequestId(request)); }); + app.get("/api/projects/:projectId/memory-rules/:ruleId", async (request, reply) => { const params = parseParams(ruleParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const rule = dataSource === "database" ? await getDatabaseMemoryRuleBySlugOrId(params.projectId, params.ruleId) : findMemoryRule(params.projectId, params.ruleId); if (!rule) return sendMemoryRuleNotFound(request, reply, params.ruleId); return successEnvelope(rule, getRequestId(request)); }); + app.post("/api/projects/:projectId/memory-rules", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsedBody = memoryRuleBodySchema.safeParse(request.body); if (!parsedBody.success || !parsedBody.data.rule?.trim()) return reply.status(400).send(errorEnvelope("MEMORY_RULE_VALIDATION_FAILED", "rule is required to create a memory rule.", getRequestId(request))); const item = dataSource === "database" ? await createDatabaseMemoryRule(params.projectId, { ...parsedBody.data, rule: parsedBody.data.rule }) : createMemoryRule(params.projectId, { ...parsedBody.data, rule: parsedBody.data.rule }); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 201, body }); return reply.status(201).send(body); }); + app.patch("/api/projects/:projectId/memory-rules/:ruleId", async (request, reply) => { const params = parseParams(ruleParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsedBody = memoryRuleBodySchema.safeParse(request.body); if (!parsedBody.success || (parsedBody.data.rule !== undefined && !parsedBody.data.rule.trim())) return reply.status(400).send(errorEnvelope("MEMORY_RULE_VALIDATION_FAILED", "Memory rule update body is invalid.", getRequestId(request))); const item = dataSource === "database" ? await updateDatabaseMemoryRule(params.projectId, params.ruleId, parsedBody.data) : updateMemoryRule(params.projectId, params.ruleId, parsedBody.data); if (!item) return sendMemoryRuleNotFound(request, reply, params.ruleId); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); }); + app.post("/api/projects/:projectId/memory-rules/:ruleId/include-in-context-pack", async (request, reply) => { const params = parseParams(ruleParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, dataSource))) return reply; const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsedBody = includeBodySchema.safeParse(request.body); if (!parsedBody.success || !parsedBody.data.contextPackId?.trim()) return reply.status(400).send(errorEnvelope("INCLUDE_IN_CONTEXT_PACK_FAILED", "contextPackId is required.", getRequestId(request))); let item; if (dataSource === "database") { try { item = await includeDatabaseMemoryRuleInContextPack(params.projectId, params.ruleId, parsedBody.data.contextPackId); } catch (error) { if ((error as Error).message === "CONTEXT_PACK_NOT_FOUND") return sendContextPackNotFound(request, reply, parsedBody.data.contextPackId); throw error; } } else { if (!findMemoryRule(params.projectId, params.ruleId)) return sendMemoryRuleNotFound(request, reply, params.ruleId); if (!findContextPack(params.projectId, parsedBody.data.contextPackId)) return sendContextPackNotFound(request, reply, parsedBody.data.contextPackId); item = includeMemoryRuleInContextPack(params.projectId, params.ruleId, parsedBody.data.contextPackId); } if (!item) return sendMemoryRuleNotFound(request, reply, params.ruleId); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); }); +}; diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts new file mode 100644 index 0000000..43edf8f --- /dev/null +++ b/backend/src/routes/metrics.ts @@ -0,0 +1,45 @@ +import type { FastifyInstance } from "fastify"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { renderHttpMetrics } from "../plugins/observability.js"; + +export const registerMetricsRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + app.get("/api/metrics", async (_request, reply) => { + let databaseReady = options.env.PROJECTBRAIN_DATA_SOURCE === "fixture" ? 1 : 0; + let active = 0; + let idle = 0; + let maximum = 0; + let probeDurationMs = 0; + if (options.env.PROJECTBRAIN_DATA_SOURCE === "database") { + const started = performance.now(); + try { + const [connections, settings] = await Promise.all([ + prisma.$queryRaw>`SELECT state, COUNT(*)::bigint AS count FROM pg_stat_activity WHERE datname = current_database() GROUP BY state`, + prisma.$queryRaw>`SELECT setting FROM pg_settings WHERE name = 'max_connections'`, + ]); + active = Number(connections.find((item) => item.state === "active")?.count ?? 0); + idle = Number(connections.find((item) => item.state === "idle")?.count ?? 0); + maximum = Number(settings[0]?.setting ?? 0); + databaseReady = 1; + } catch { + databaseReady = 0; + } finally { + probeDurationMs = performance.now() - started; + } + } + const databaseMetrics = [ + "# HELP projectbrain_database_ready Whether the configured persistence dependency is reachable.", + "# TYPE projectbrain_database_ready gauge", + `projectbrain_database_ready ${databaseReady}`, + "# HELP projectbrain_database_connections PostgreSQL connections for the current database.", + "# TYPE projectbrain_database_connections gauge", + `projectbrain_database_connections{state="active"} ${active}`, + `projectbrain_database_connections{state="idle"} ${idle}`, + `projectbrain_database_connections{state="maximum"} ${maximum}`, + "# HELP projectbrain_database_probe_duration_ms Latest database observation duration.", + "# TYPE projectbrain_database_probe_duration_ms gauge", + `projectbrain_database_probe_duration_ms ${probeDurationMs.toFixed(3)}`, + ].join("\n"); + return reply.type("text/plain; version=0.0.4; charset=utf-8").send(`${renderHttpMetrics()}${databaseMetrics}\n`); + }); +}; diff --git a/backend/src/routes/operatingSystem.ts b/backend/src/routes/operatingSystem.ts new file mode 100644 index 0000000..dde726d --- /dev/null +++ b/backend/src/routes/operatingSystem.ts @@ -0,0 +1,193 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { Prisma } from "@prisma/client"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { claimReadyWork, getProjectWorkGraph, listWorkGraphHistory, recordWorkCompletion, reclaimExpiredWork, releaseWork, updateWorkDependencies } from "../services/workGraph.js"; +import { evaluateProjectCompletion, saveProjectDefinitionOfDone, simulateAutonomy, type CompletionRequirementKey } from "../services/projectCompletion.js"; +import { listDiagnosticHistory, runDiagnostics } from "../services/diagnostics.js"; +import { getProjectAttention } from "../services/attention.js"; +import { applyCommandIntent, parseCommandIntent } from "../services/commandIntent.js"; +import { buildPulse, type PulseEvent } from "../services/pulse.js"; +import { getProjectKnowledge } from "../services/projectKnowledge.js"; +import { getProjectEvidenceGraph } from "../services/evidenceGraph.js"; +import { listPortfolioProjectPins, setPortfolioProjectPin } from "../services/portfolioControlPlane.js"; + +const id = z.string().trim().min(1).max(200); +const projectParams = z.object({ projectId: id }); +const nodeParams = z.object({ projectId: id, nodeId: id }); +const viewParams = z.object({ viewId: id }); +const pinParams = z.object({ projectId: id }); +// The authenticated identity, never a caller-controlled request field, owns +// leases and audit events. Otherwise a project member could forge a trusted +// executor or owner in durable work-history evidence. +const leaseBody = z.object({ leaseMinutes: z.number().int().min(5).max(120).optional(), leaseToken: id.optional() }); +const completionBody = z.object({ leaseToken: id, summary: z.string().trim().min(3).max(12_000), evidence: z.array(z.object({ kind: id, referenceType: id, referenceId: id, status: id.optional() })).max(30).optional() }); +const dependencyBody = z.object({ dependencyNodeIds: z.array(id).max(100) }); +export const parseWorkGraphLeaseInput = (input: unknown) => leaseBody.parse(input); +const doneBody = z.object({ requirements: z.array(z.object({ key: z.enum(["committed_scope", "validations_green", "ux_verification", "documentation_current", "release_candidate", "deployment_completed", "runtime_verified", "backup_rollback_evidence"] as [CompletionRequirementKey, ...CompletionRequirementKey[]]), label: z.string().trim().min(2).max(160).optional(), description: z.string().trim().min(2).max(500).optional(), required: z.boolean().optional() })).min(1).max(20) }); +const viewBody = z.object({ name: z.string().trim().min(2).max(80), scope: z.enum(["portfolio", "project"]).default("portfolio"), projectId: id.optional(), filters: z.record(z.unknown()).default({}), isPinned: z.boolean().default(false) }); +const viewPatch = viewBody.partial(); +const pinBody = z.object({ pinned: z.boolean() }); +const commandBody = z.object({ command: z.string().trim().min(2).max(2_000), apply: z.boolean().default(false) }); +const pulseQuery = z.object({ window: z.enum(["24h", "7d", "30d"]).default("7d") }); +const diagnosticsQuery = z.object({ projectId: id.optional(), limit: z.coerce.number().int().min(1).max(50).default(10) }); +const pulseWindowMs = (window: "24h" | "7d" | "30d") => window === "24h" ? 24 * 60 * 60_000 : window === "30d" ? 30 * 24 * 60 * 60_000 : 7 * 24 * 60 * 60_000; +const actorFor = (request: FastifyRequest, fallback = "owner") => request.authUser?.username ?? fallback; +const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +export const isOwnerRequest = (request: Pick) => request.authUser?.role === "owner"; + +const requireDatabase = (env: AppEnv, request: FastifyRequest, reply: FastifyReply) => { + if (env.PROJECTBRAIN_DATA_SOURCE === "database") return false; + reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "This operating-system capability requires durable database mode.", getRequestId(request))); + return true; +}; + +// Portfolio-wide snapshots and diagnostics include every project and can +// trigger safe repair. They must not inherit the looser project-member access +// that applies to URLs scoped by /api/projects/:projectId. +const requireOwner = (request: FastifyRequest, reply: FastifyReply) => isOwnerRequest(request) + ? false + : Boolean(reply.status(403).send(errorEnvelope("OWNER_ACCESS_REQUIRED", "Owner access is required for portfolio-wide operating-system data.", getRequestId(request)))); + +const operationError = (request: FastifyRequest, reply: FastifyReply, error: unknown) => { + const code = error instanceof Error ? error.message : "OPERATING_SYSTEM_OPERATION_FAILED"; + const status = code === "PROJECT_NOT_FOUND" || code.endsWith("NOT_FOUND") ? 404 : code.includes("STALE") || code.includes("CYCLE") ? 409 : 400; + return reply.status(status).send(errorEnvelope(code, "ProjectBrain could not complete this operating-system action.", getRequestId(request))); +}; + +const pulseEventsForProject = async (projectId: string): Promise => { + const [activity, logs, graphEvents] = await Promise.all([ + prisma.activityEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 200 }), + prisma.operatingLogEntry.findMany({ where: { projectId, deletedAt: null }, orderBy: { timestamp: "desc" }, take: 200 }), + prisma.workGraphAuditEvent.findMany({ where: { projectId }, orderBy: { createdAt: "desc" }, take: 200 }), + ]); + return [ + ...activity.map((event) => ({ at: event.createdAt.toISOString(), kind: event.eventType, title: event.eventType, summary: JSON.stringify(event.payloadJson ?? {}), meaningful: true })), + ...logs.map((event) => ({ at: event.timestamp.toISOString(), kind: event.type, title: event.title, summary: event.summary, meaningful: event.importance !== "low" })), + ...graphEvents.map((event) => ({ at: event.createdAt.toISOString(), kind: event.eventType, title: event.eventType, summary: event.summary, meaningful: !["work.claimed"].includes(event.eventType) })), + ]; +}; + +export const registerOperatingSystemRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + app.get("/api/projects/:projectId/work-graph", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { return successEnvelope(await getProjectWorkGraph(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/work-graph/ready", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const graph = await getProjectWorkGraph(projectParams.parse(request.params).projectId); return successEnvelope({ generatedAt: graph.generatedAt, items: graph.ready, parallelGroups: graph.parallelGroups }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/work-graph/blockers", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const graph = await getProjectWorkGraph(projectParams.parse(request.params).projectId); return successEnvelope({ generatedAt: graph.generatedAt, blockers: graph.blockers }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/work-graph/history", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const projectId = projectParams.parse(request.params).projectId; return successEnvelope({ items: await listWorkGraphHistory(projectId) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.post("/api/projects/:projectId/work-graph/claim", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const projectId = projectParams.parse(request.params).projectId; const body = parseWorkGraphLeaseInput(request.body ?? {}); return successEnvelope({ work: await claimReadyWork(projectId, actorFor(request, "projectbrain-agent"), body.leaseMinutes) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.post("/api/projects/:projectId/work-graph/reclaim", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { return successEnvelope(await reclaimExpiredWork(projectParams.parse(request.params).projectId, actorFor(request, "system")), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.post("/api/projects/:projectId/work-graph/:nodeId/release", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const params = nodeParams.parse(request.params); const body = leaseBody.parse(request.body ?? {}); if (!body.leaseToken) throw new Error("WORK_GRAPH_LEASE_TOKEN_REQUIRED"); return successEnvelope(await releaseWork(params.projectId, params.nodeId, actorFor(request), body.leaseToken), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.post("/api/projects/:projectId/work-graph/:nodeId/complete", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const params = nodeParams.parse(request.params); const body = completionBody.parse(request.body ?? {}); return successEnvelope(await recordWorkCompletion(params.projectId, params.nodeId, actorFor(request), body.leaseToken, body), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.put("/api/projects/:projectId/work-graph/:nodeId/dependencies", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const params = nodeParams.parse(request.params); const body = dependencyBody.parse(request.body ?? {}); return successEnvelope(await updateWorkDependencies(params.projectId, params.nodeId, body.dependencyNodeIds, actorFor(request)), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + + app.get("/api/projects/:projectId/definition-of-done", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { return successEnvelope(await evaluateProjectCompletion(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.put("/api/projects/:projectId/definition-of-done", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const body = doneBody.parse(request.body ?? {}); return successEnvelope(await saveProjectDefinitionOfDone(projectParams.parse(request.params).projectId, body, actorFor(request)), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/autonomy/simulation", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { return successEnvelope(await simulateAutonomy(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/attention", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { return successEnvelope(await getProjectAttention(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/project-knowledge", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { return successEnvelope(await getProjectKnowledge(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/project-knowledge/conflicts", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const knowledge = await getProjectKnowledge(projectParams.parse(request.params).projectId); return successEnvelope({ generatedAt: knowledge.generatedAt, conflicts: knowledge.conflicts, unresolvedConflictCount: knowledge.unresolvedConflictCount }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/evidence-graph", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { return successEnvelope(await getProjectEvidenceGraph(projectParams.parse(request.params).projectId), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.post("/api/diagnostics", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + try { const body = z.object({ projectId: id.optional(), repairSafe: z.boolean().default(true) }).parse(request.body ?? {}); return successEnvelope(await runDiagnostics(options.env, { ...body, actor: actorFor(request, "diagnostics") }), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/diagnostics/history", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + try { const query = diagnosticsQuery.parse(request.query ?? {}); return successEnvelope({ items: await listDiagnosticHistory(query.projectId, query.limit) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.post("/api/projects/:projectId/diagnostics", async (request, reply) => { + try { const body = z.object({ repairSafe: z.boolean().default(true) }).parse(request.body ?? {}); return successEnvelope(await runDiagnostics(options.env, { projectId: projectParams.parse(request.params).projectId, ...body, actor: actorFor(request, "diagnostics") }), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/projects/:projectId/pulse", async (request, reply) => { + if (requireDatabase(options.env, request, reply)) return reply; + try { const projectId = projectParams.parse(request.params).projectId; const { window } = pulseQuery.parse(request.query ?? {}); const events = await pulseEventsForProject(projectId); return successEnvelope(buildPulse(events, Date.now(), pulseWindowMs(window)), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/portfolio/pulse", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + if (requireDatabase(options.env, request, reply)) return reply; + try { const projects = await prisma.project.findMany({ where: { deletedAt: null }, select: { id: true, name: true } }); const { window } = pulseQuery.parse(request.query ?? {}); const pulses = await Promise.all(projects.map(async (project) => ({ projectId: project.id, projectName: project.name, pulse: buildPulse(await pulseEventsForProject(project.id), Date.now(), pulseWindowMs(window)) }))); return successEnvelope({ generatedAt: new Date().toISOString(), window, projects: pulses, significantCompletions: pulses.flatMap((item) => item.pulse.completed.slice(0, 2).map((event) => ({ ...event, projectId: item.projectId, projectName: item.projectName }))) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.post("/api/commands/interpret", async (request, reply) => { try { const body = commandBody.parse(request.body ?? {}); return successEnvelope(parseCommandIntent(body.command), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } }); + app.post("/api/projects/:projectId/commands/interpret", async (request, reply) => { try { const projectId = projectParams.parse(request.params).projectId; const body = commandBody.parse(request.body ?? {}); const intent = parseCommandIntent(body.command); if (!body.apply) return successEnvelope({ projectId, intent, applied: false }, getRequestId(request)); if (requireDatabase(options.env, request, reply)) return reply; return successEnvelope(await applyCommandIntent(projectId, intent, actorFor(request)), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } }); + + app.get("/api/portfolio/views", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + if (requireDatabase(options.env, request, reply)) return reply; + try { const ownerKey = actorFor(request); return successEnvelope({ items: await prisma.projectSavedView.findMany({ where: { ownerKey }, orderBy: [{ isPinned: "desc" }, { updatedAt: "desc" }] }) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.post("/api/portfolio/views", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + if (requireDatabase(options.env, request, reply)) return reply; + try { const body = viewBody.parse(request.body ?? {}); const ownerKey = actorFor(request); const view = await prisma.projectSavedView.create({ data: { ownerKey, name: body.name, scope: body.scope, filtersJson: json(body.filters), isPinned: body.isPinned, ...(body.projectId ? { project: { connect: { id: body.projectId } } } : {}) } }); return reply.status(201).send(successEnvelope(view, getRequestId(request))); } catch (error) { return operationError(request, reply, error); } + }); + app.patch("/api/portfolio/views/:viewId", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + if (requireDatabase(options.env, request, reply)) return reply; + try { const { viewId } = viewParams.parse(request.params); const body = viewPatch.parse(request.body ?? {}); const existing = await prisma.projectSavedView.findFirst({ where: { id: viewId, ownerKey: actorFor(request) } }); if (!existing) throw new Error("SAVED_VIEW_NOT_FOUND"); const view = await prisma.projectSavedView.update({ where: { id: viewId }, data: { ...(body.name ? { name: body.name } : {}), ...(body.scope ? { scope: body.scope } : {}), ...(body.projectId ? { project: { connect: { id: body.projectId } } } : {}), ...(body.filters ? { filtersJson: json(body.filters) } : {}), ...(body.isPinned !== undefined ? { isPinned: body.isPinned } : {}) } }); return successEnvelope(view, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.delete("/api/portfolio/views/:viewId", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + if (requireDatabase(options.env, request, reply)) return reply; + try { const { viewId } = viewParams.parse(request.params); const existing = await prisma.projectSavedView.findFirst({ where: { id: viewId, ownerKey: actorFor(request) } }); if (!existing) throw new Error("SAVED_VIEW_NOT_FOUND"); await prisma.projectSavedView.delete({ where: { id: viewId } }); return successEnvelope({ deleted: true, id: viewId }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.get("/api/portfolio/pins", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + if (requireDatabase(options.env, request, reply)) return reply; + try { return successEnvelope({ projectIds: await listPortfolioProjectPins(actorFor(request)) }, getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); + app.put("/api/portfolio/pins/:projectId", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + if (requireDatabase(options.env, request, reply)) return reply; + try { const { projectId } = pinParams.parse(request.params); const { pinned } = pinBody.parse(request.body ?? {}); return successEnvelope(await setPortfolioProjectPin(actorFor(request), projectId, pinned), getRequestId(request)); } catch (error) { return operationError(request, reply, error); } + }); +}; diff --git a/backend/src/routes/p7Runtime.ts b/backend/src/routes/p7Runtime.ts new file mode 100644 index 0000000..a639331 --- /dev/null +++ b/backend/src/routes/p7Runtime.ts @@ -0,0 +1,331 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import type { AppEnv } from "../config/env.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + ApprovalState, + CodexRunImportDto, + CodexTaskDto, + CreateCodexTaskFromHandoffRequestDto, + CreateHandoffBundleRequestDto, + CurrentStateReportDto, + GenerateCurrentStateReportRequestDto, + HandoffBundleDto, + HandoffTarget, + ImportCodexOutputRequestDto, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { getDurableIdempotentResponse, getIdempotencyKey, setDurableIdempotentResponse } from "../utils/idempotency.js"; +import { + createCodexTaskFromHandoffInDatabase, + createHandoffBundleInDatabase, + generateCurrentStateReportInDatabase, + importCodexOutputInDatabase, + listCodexTasksFromDatabase, + listCurrentStateReportsFromDatabase, + listHandoffBundlesFromDatabase, +} from "../repositories/p7RuntimeRepository.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const handoffParamsSchema = z.object({ projectId: idSchema, handoffBundleId: idSchema }); +const codexParamsSchema = z.object({ projectId: idSchema, codexTaskId: idSchema }); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const currentStateQuerySchema = paginationQuerySchema.extend({ + approvalState: z.string().optional(), + source: z.string().optional(), +}); +const handoffQuerySchema = paginationQuerySchema.extend({ + target: z.string().optional(), + status: z.string().optional(), + approvalState: z.string().optional(), +}); +const codexQuerySchema = paginationQuerySchema.extend({ + status: z.string().optional(), + approvalState: z.string().optional(), + handoffBundleId: z.string().optional(), +}); +const generateCurrentStateBodySchema = z.object({ + source: z.string().optional(), + includeOperatingLog: z.boolean().optional(), + includeProjectUpdates: z.boolean().optional(), + includeReviewQueue: z.boolean().optional(), +}).default({}); +const createHandoffBodySchema = z.object({ + target: z.enum(["context-pack", "codex-task", "current-state"]), + title: z.string().optional(), + includeAcceptedTruth: z.boolean().optional(), + includeProjectUpdates: z.boolean().optional(), + includeOperatingLog: z.boolean().optional(), + autoSelectContext: z.boolean().optional(), + selectionIntent: z.string().optional(), + contextPackId: z.string().optional(), +}); +const createCodexTaskBodySchema = z.object({ + goal: z.string().optional(), + contextPackId: z.string().optional(), + validationCommands: z.array(z.string()).optional(), + autoSelectContext: z.boolean().optional(), + selectionIntent: z.string().optional(), +}).default({}); +const importCodexBodySchema = z.object({ + summary: z.string().min(1), + outputText: z.string().optional(), + sourceRun: z.string().optional(), +}); + +const nowIso = () => new Date().toISOString(); +const fixtureCurrentStates: CurrentStateReportDto[] = []; +const fixtureHandoffs: HandoffBundleDto[] = []; +const fixtureCodexTasks: CodexTaskDto[] = []; + +const splitCsv = (value: string | undefined): string[] => value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sendValidationError = (request: FastifyRequest, reply: FastifyReply, validation: z.ZodIssue[]) => + reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), validation.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); + +const parseParams = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseBody = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { + const parsed = schema.safeParse(request.body ?? {}); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const ensureProject = (request: FastifyRequest, reply: FastifyReply, projectId: string) => { + const project = findProject(projectId); + if (!project) { + reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request))); + return undefined; + } + return project; +}; + +const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); + return undefined; + } + return idempotencyKey; +}; + +const withMutation = async (request: FastifyRequest, reply: FastifyReply, useDatabase: boolean, callback: (cacheKey: string) => Promise | void) => { + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = await getDurableIdempotentResponse(cacheKey, useDatabase); + if (replay) { + reply.status(replay.statusCode).send(replay.body); + return; + } + await callback(cacheKey); +}; + +const toPaginationInput = (query: z.output, filters: PaginationInput["filters"]): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const sortByField = >(items: T[], sort: string | undefined): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare(String(right[field] ?? ""), undefined, { numeric: true }); + return direction === "desc" ? -comparison : comparison; + }); +}; + +const fixturePaginate = >(items: T[], query: z.output, filters: PaginationInput["filters"]) => + paginate(sortByField(items, query.sort), toPaginationInput(query, filters)); + +type P7RuntimeRoutesOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; + +export const registerP7RuntimeRoutes = async (app: FastifyInstance, options: P7RuntimeRoutesOptions = {}) => { + const isDatabaseMode = options.dataSource === "database"; + + app.get("/api/projects/:projectId/current-state", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + const query = parseQuery(currentStateQuerySchema, request, reply); + if (!params || !query) return; + const filters = { ...(query.approvalState ? { approvalState: splitCsv(query.approvalState) as ApprovalState[] } : {}), ...(query.source ? { source: splitCsv(query.source) } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) }; + if (isDatabaseMode) { + const result = await listCurrentStateReportsFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + return reply.send(successEnvelope(result, getRequestId(request))); + } + const project = ensureProject(request, reply, params.projectId); + if (!project) return; + return reply.send(successEnvelope(fixturePaginate(fixtureCurrentStates.filter((item) => item.projectId === project.id), query, filters), getRequestId(request))); + }); + + app.post("/api/projects/:projectId/current-state/generate", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + const body = parseBody(generateCurrentStateBodySchema, request, reply) as GenerateCurrentStateReportRequestDto | undefined; + if (!params || !body) return; + await withMutation(request, reply, isDatabaseMode, async (cacheKey) => { + if (isDatabaseMode) { + const report = await generateCurrentStateReportInDatabase(params.projectId, body); + if (!report) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + const envelope = successEnvelope(report, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); + return reply.status(201).send(envelope); + } + const project = ensureProject(request, reply, params.projectId); + if (!project) return; + const report: CurrentStateReportDto = { id: `csr-${Date.now()}`, projectId: project.id, generatedAt: nowIso(), exists: ["Fixture runtime"], works: ["Fixture Current State generated"], uncertain: [], broken: [], currentRisks: ["Fixture report is not persistent."], pendingReviewItemIds: [], activeContextPackId: project.activeContextPackId, recommendedNextActions: ["Switch to database mode for persistence."], source: body.source ?? "fixture", approvalState: "pending", createdAt: nowIso(), updatedAt: nowIso() }; + fixtureCurrentStates.unshift(report); + const envelope = successEnvelope(report, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); + return reply.status(201).send(envelope); + }); + }); + + app.get("/api/projects/:projectId/handoff-bundles", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + const query = parseQuery(handoffQuerySchema, request, reply); + if (!params || !query) return; + const filters = { ...(query.target ? { target: splitCsv(query.target) as HandoffTarget[] } : {}), ...(query.status ? { status: splitCsv(query.status) as HandoffBundleDto["status"][] } : {}), ...(query.approvalState ? { approvalState: splitCsv(query.approvalState) as ApprovalState[] } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) }; + if (isDatabaseMode) { + const result = await listHandoffBundlesFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + return reply.send(successEnvelope(result, getRequestId(request))); + } + const project = ensureProject(request, reply, params.projectId); + if (!project) return; + return reply.send(successEnvelope(fixturePaginate(fixtureHandoffs.filter((item) => item.projectId === project.id), query, filters), getRequestId(request))); + }); + + app.post("/api/projects/:projectId/handoff-bundles", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + const body = parseBody(createHandoffBodySchema, request, reply) as CreateHandoffBundleRequestDto | undefined; + if (!params || !body) return; + await withMutation(request, reply, isDatabaseMode, async (cacheKey) => { + if (isDatabaseMode) { + const bundle = await createHandoffBundleInDatabase(params.projectId, body); + if (!bundle) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + const envelope = successEnvelope(bundle, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); + return reply.status(201).send(envelope); + } + const project = ensureProject(request, reply, params.projectId); + if (!project) return; + const selectedContextPackIds = [body.contextPackId ?? project.activeContextPackId].filter(Boolean) as string[]; + const bundle: HandoffBundleDto = { id: `hb-${Date.now()}`, projectId: project.id, target: body.target, title: body.title ?? "Fixture handoff", markdown: `# Fixture handoff\n\n## Automatic context pack selection\nPrimary pack: ${selectedContextPackIds[0] ?? "none"}\nSelected packs: ${selectedContextPackIds.join(", ") || "none"}\n\n## Accepted truth\n- Fixture mode preview only.\n\nSwitch to database mode for persistent handoffs.`, qualityScore: 55, primaryContextPackId: selectedContextPackIds[0], selectedContextPackIds, sourceTruthItemIds: [], sourceReviewItemIds: [], sourceCaptureItemIds: [], sourceProjectUpdateIds: [], sourceOperatingLogEntryIds: [], status: "ready", approvalState: "pending", createdAt: nowIso(), updatedAt: nowIso() }; + fixtureHandoffs.unshift(bundle); + const envelope = successEnvelope(bundle, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); + return reply.status(201).send(envelope); + }); + }); + + app.get("/api/projects/:projectId/codex-tasks", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + const query = parseQuery(codexQuerySchema, request, reply); + if (!params || !query) return; + const filters = { ...(query.status ? { status: splitCsv(query.status) as CodexTaskDto["status"][] } : {}), ...(query.approvalState ? { approvalState: splitCsv(query.approvalState) as ApprovalState[] } : {}), ...(query.handoffBundleId ? { handoffBundleId: query.handoffBundleId } : {}), ...(query.cursor ? { cursor: query.cursor } : {}) }; + if (isDatabaseMode) { + const result = await listCodexTasksFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + return reply.send(successEnvelope(result, getRequestId(request))); + } + const project = ensureProject(request, reply, params.projectId); + if (!project) return; + return reply.send(successEnvelope(fixturePaginate(fixtureCodexTasks.filter((item) => item.projectId === project.id), query, filters), getRequestId(request))); + }); + + app.post("/api/projects/:projectId/handoff-bundles/:handoffBundleId/codex-task", async (request, reply) => { + const params = parseParams(handoffParamsSchema, request, reply); + const body = parseBody(createCodexTaskBodySchema, request, reply) as CreateCodexTaskFromHandoffRequestDto | undefined; + if (!params || !body) return; + await withMutation(request, reply, isDatabaseMode, async (cacheKey) => { + if (isDatabaseMode) { + const task = await createCodexTaskFromHandoffInDatabase(params.projectId, params.handoffBundleId, body); + if (!task) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + if (task === "HANDOFF_NOT_FOUND") return reply.status(404).send(errorEnvelope("HANDOFF_BUNDLE_NOT_FOUND", `Handoff bundle '${params.handoffBundleId}' was not found.`, getRequestId(request))); + const envelope = successEnvelope(task, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); + return reply.status(201).send(envelope); + } + const project = ensureProject(request, reply, params.projectId); + const handoff = fixtureHandoffs.find((item) => item.id === params.handoffBundleId); + if (!project) return; + if (!handoff) return reply.status(404).send(errorEnvelope("HANDOFF_BUNDLE_NOT_FOUND", `Handoff bundle '${params.handoffBundleId}' was not found.`, getRequestId(request))); + const contextPackId = body.contextPackId ?? handoff.primaryContextPackId ?? project.activeContextPackId; + const task: CodexTaskDto = { id: `ct-${Date.now()}`, projectId: project.id, handoffBundleId: handoff.id, goal: body.goal ?? `Implement ${handoff.title}`, contextPackId, selectedContextPackIds: [contextPackId].filter(Boolean) as string[], contextSelectionSummary: contextPackId ? `Fixture task uses context pack ${contextPackId}.` : "No context pack selected in fixture mode.", constraints: ["Fixture mode only"], relevantFiles: ["src", "backend", "docs"], doNotBreakRules: ["No silent truth mutation"], validationCommands: body.validationCommands ?? ["pnpm run validate:all"], expectedReportFormat: "Changed files and checks", outputImportInstructions: "Import as Capture and Review Queue item.", approvalState: "pending", status: "ready", createdAt: nowIso(), updatedAt: nowIso() }; + fixtureCodexTasks.unshift(task); + const envelope = successEnvelope(task, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); + return reply.status(201).send(envelope); + }); + }); + + app.post("/api/projects/:projectId/codex-tasks/:codexTaskId/import-output", async (request, reply) => { + const params = parseParams(codexParamsSchema, request, reply); + const body = parseBody(importCodexBodySchema, request, reply) as ImportCodexOutputRequestDto | undefined; + if (!params || !body) return; + await withMutation(request, reply, isDatabaseMode, async (cacheKey) => { + if (isDatabaseMode) { + const result = await importCodexOutputInDatabase(params.projectId, params.codexTaskId, body); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + if (result === "CODEX_TASK_NOT_FOUND") return reply.status(404).send(errorEnvelope("CODEX_TASK_NOT_FOUND", `Codex task '${params.codexTaskId}' was not found.`, getRequestId(request))); + const envelope = successEnvelope(result, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); + return reply.status(201).send(envelope); + } + const project = ensureProject(request, reply, params.projectId); + if (!project) return; + const task = fixtureCodexTasks.find((item) => item.id === params.codexTaskId); + if (!task) return reply.status(404).send(errorEnvelope("CODEX_TASK_NOT_FOUND", `Codex task '${params.codexTaskId}' was not found.`, getRequestId(request))); + task.status = "imported"; + const importDto: CodexRunImportDto = { + id: `cri-${Date.now()}`, + projectId: project.id, + codexTaskId: task.id, + sourceRun: body.sourceRun ?? "fixture-run", + summary: body.summary, + contextPackId: task.contextPackId, + selectedContextPackIds: task.selectedContextPackIds ?? [task.contextPackId].filter(Boolean) as string[], + contextSelectionSummary: task.contextSelectionSummary, + extractedReviewItemIds: [`rq-${Date.now()}`], + status: "imported", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + const envelope = successEnvelope({ importDto, mutation: { success: true, message: "Fixture Codex output imported.", createdEntityId: importDto.extractedReviewItemIds[0], updatedEntityId: task.id, affectedEntities: [task.id, importDto.id, importDto.extractedReviewItemIds[0]] } }, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body: envelope }, isDatabaseMode); + return reply.status(201).send(envelope); + }); + }); +}; diff --git a/backend/src/routes/portfolio.ts b/backend/src/routes/portfolio.ts new file mode 100644 index 0000000..5ade462 --- /dev/null +++ b/backend/src/routes/portfolio.ts @@ -0,0 +1,116 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { excludePortfolioRepository, getPortfolioControlPlane, listPortfolioRepositories, reconcilePortfolioRepositories, restorePortfolioRepository } from "../services/portfolioControlPlane.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { cancelPortfolioAnalysisJob, createPortfolioAnalysisJob, getPortfolioAnalysisJob, getPortfolioAnalysisJobResults } from "../repositories/analysisJobRepository.js"; +import { syncGitSources } from "../services/gitSourceSync.js"; + +const reconcileBody = z.object({ + dryRun: z.boolean().default(true), + repositoryFullNames: z.array(z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/)).max(50).optional(), +}); +const repositoryParams = z.object({ repositoryId: z.string().min(1) }); +const jobParams = z.object({ jobId: z.string().min(1) }); +const resultPageQuery = z.object({ + cursor: z.string().regex(/^\d+$/).optional(), + pageSize: z.coerce.number().int().min(1).max(10).default(8), + characterBudget: z.coerce.number().int().min(4_000).max(32_000).default(16_000), +}); +const exclusionBody = z.object({ reason: z.string().trim().min(3).max(2_000) }); + +const requireOwner = (request: FastifyRequest, reply: FastifyReply) => request.authUser?.role === "owner" + ? false + : Boolean(reply.status(403).send(errorEnvelope("OWNER_ACCESS_REQUIRED", "Owner access is required for portfolio administration.", getRequestId(request)))); + +const portfolioError = (error: unknown, request: FastifyRequest, reply: FastifyReply) => { + const code = error instanceof Error ? error.message : "PORTFOLIO_OPERATION_FAILED"; + const status = code.endsWith("NOT_FOUND") ? 404 : code.includes("ALREADY_ONBOARDED") ? 409 : 502; + return reply.status(status).send(errorEnvelope(code, "The portfolio operation could not be completed.", getRequestId(request))); +}; + +export const registerPortfolioRoutes = async (app: FastifyInstance, options: { env: AppEnv }) => { + app.post("/api/portfolio/sources/sync-git", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + try { return reply.status(202).send(successEnvelope(await syncGitSources(options.env), getRequestId(request))); } + catch (error) { return portfolioError(error, request, reply); } + }); + + app.post("/api/projects/:projectId/sources/sync-git", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + const { projectId } = request.params as { projectId: string }; + try { return reply.status(202).send(successEnvelope(await syncGitSources(options.env, { projectId }), getRequestId(request))); } + catch (error) { return portfolioError(error, request, reply); } + }); + app.get("/api/portfolio/control-plane", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + try { return successEnvelope(await getPortfolioControlPlane(options.env, request.authUser?.username ?? "owner"), getRequestId(request)); } + catch (error) { return portfolioError(error, request, reply); } + }); + + app.get("/api/portfolio/repositories", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + try { return successEnvelope(await listPortfolioRepositories(options.env), getRequestId(request)); } + catch (error) { return portfolioError(error, request, reply); } + }); + + app.post("/api/portfolio/reconcile", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + const body = reconcileBody.parse(request.body ?? {}); + try { + const result = await reconcilePortfolioRepositories(options.env, `web:${request.authUser!.username}`, body); + const sourceSync = body.dryRun ? undefined : await syncGitSources(options.env); + return reply.status(body.dryRun ? 200 : 202).send(successEnvelope({ ...result, sourceSync }, getRequestId(request))); + } catch (error) { return portfolioError(error, request, reply); } + }); + + app.post("/api/portfolio/analysis-jobs", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + try { + const result = await createPortfolioAnalysisJob(options.env, `web:${request.authUser!.username}:portfolio`, { + includeBranches: true, + includePullRequests: true, + includeActions: true, + }); + return reply.status(result.deduplicated ? 200 : 202).send(successEnvelope(result, getRequestId(request))); + } catch (error) { return portfolioError(error, request, reply); } + }); + + app.get("/api/portfolio/analysis-jobs/:jobId", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + const { jobId } = jobParams.parse(request.params); + try { return successEnvelope({ job: await getPortfolioAnalysisJob(jobId) }, getRequestId(request)); } + catch (error) { return portfolioError(error, request, reply); } + }); + + app.get("/api/portfolio/analysis-jobs/:jobId/results", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + const { jobId } = jobParams.parse(request.params); + const query = resultPageQuery.parse(request.query ?? {}); + try { return successEnvelope(await getPortfolioAnalysisJobResults(jobId, query), getRequestId(request)); } + catch (error) { return portfolioError(error, request, reply); } + }); + + app.post("/api/portfolio/analysis-jobs/:jobId/cancel", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + const { jobId } = jobParams.parse(request.params); + try { return successEnvelope({ job: await cancelPortfolioAnalysisJob(jobId) }, getRequestId(request)); } + catch (error) { return portfolioError(error, request, reply); } + }); + + app.post("/api/portfolio/repositories/:repositoryId/exclude", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + const { repositoryId } = repositoryParams.parse(request.params); + const { reason } = exclusionBody.parse(request.body ?? {}); + try { return successEnvelope(await excludePortfolioRepository(repositoryId, reason), getRequestId(request)); } + catch (error) { return portfolioError(error, request, reply); } + }); + + app.post("/api/portfolio/repositories/:repositoryId/restore", async (request, reply) => { + if (requireOwner(request, reply)) return reply; + const { repositoryId } = repositoryParams.parse(request.params); + try { return successEnvelope(await restorePortfolioRepository(repositoryId), getRequestId(request)); } + catch (error) { return portfolioError(error, request, reply); } + }); +}; diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts new file mode 100644 index 0000000..15abae1 --- /dev/null +++ b/backend/src/routes/projects.ts @@ -0,0 +1,414 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { + findProject, + getWorkspaceRead, + listProjects as listFixtureProjects, +} from "../fixtures/projectFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { + createProject, + getProjectBySlugOrId, + getProjectDNA, + getProjectWorkspace, + listProjects as listDatabaseProjects, + updateActiveContextPack, + updateProject, +} from "../repositories/projectsRepository.js"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { mergeProjectSetupState, projectSetupActiveContextPackId, projectSetupWorkspaceState, sanitizeProjectSetupState } from "../repositories/projectSetupState.js"; +import { + existingRepositoryFromProjectSetup, + hasProjectRepositoryProvisioning, + projectSlugFromInput, + provisionProjectRepository, +} from "../services/projectRepositoryProvisioning.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); + +const projectParamsSchema = z.object({ + projectId: idSchema, +}); + +const projectDnaSchema = z.object({ + vision: z.string().trim().min(1).max(4000), + goals: z.array(z.string().trim().min(1).max(1000)).max(50), + nonGoals: z.array(z.string().trim().min(1).max(1000)).max(50), + targetAudience: z.string().trim().min(1).max(1000), + principles: z.array(z.string().trim().min(1).max(1000)).max(50), + successMetrics: z.array(z.object({ + metric: z.string().trim().min(1).max(200), + target: z.string().trim().min(1).max(500), + targetValue: z.number().optional(), + operator: z.enum([">", "<", ">=", "<=", "=", "between"]).optional(), + })).max(50), + maturityStage: z.enum(["Discovery", "Definition", "Execution", "Validation", "Operational"]), + revision: z.number().int().positive(), + updatedAt: z.string().datetime().optional(), +}); + +const projectSetupStateSchema = z.record(z.string(), z.unknown()).refine( + (value) => JSON.stringify(value).length <= 750_000, + "Project setup state may not exceed 750 KB.", +); + +const createProjectBodySchema = z.object({ + id: idSchema.optional(), + name: z.string().trim().min(2).max(120), + description: z.string().trim().min(8).max(2000), + domain: z.string().trim().min(1).max(120).optional(), + primaryGoal: z.string().trim().min(1).max(1000).optional(), + targetAudience: z.string().trim().min(1).max(500).optional(), + dna: projectDnaSchema.optional(), + setupState: projectSetupStateSchema.optional(), +}); + +const updateProjectBodySchema = z.object({ + name: z.string().trim().min(2).max(120).optional(), + description: z.string().trim().min(8).max(2000).optional(), + domain: z.string().trim().min(1).max(120).optional(), + status: z.enum(["active", "archived", "paused"]).optional(), +}).refine((value) => Object.keys(value).length > 0, { + message: "At least one project field must be provided.", +}); + +const updateActiveContextPackBodySchema = z.object({ + contextPackId: idSchema, +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseProjectParams = (request: FastifyRequest, reply: FastifyReply) => { + const parsed = projectParamsSchema.safeParse(request.params); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +type ProjectRoutesOptions = { + dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; + env?: AppEnv; +}; + +type RepositoryEvidence = { repositoryLinkCount: number; readyRepositoryCount: number }; + +const repositoryEvidenceForProjects = async (projectSlugs: string[]) => { + const evidence = new Map(); + for (const slug of projectSlugs) evidence.set(slug, { repositoryLinkCount: 0, readyRepositoryCount: 0 }); + if (!projectSlugs.length) return evidence; + + const links = await prisma.giteaRepositoryLink.findMany({ + where: { project: { deletedAt: null, slug: { in: projectSlugs } } }, + select: { syncStatus: true, project: { select: { slug: true } } }, + }); + for (const link of links) { + const current = evidence.get(link.project.slug) ?? { repositoryLinkCount: 0, readyRepositoryCount: 0 }; + current.repositoryLinkCount += 1; + if (link.syncStatus === "ready") current.readyRepositoryCount += 1; + evidence.set(link.project.slug, current); + } + return evidence; +}; + +const withRepositoryEvidence = (project: T, evidence: Map) => ({ + ...project, + ...(evidence.get(project.id) ?? { repositoryLinkCount: 0, readyRepositoryCount: 0 }), +}); + +export const registerProjectRoutes = async ( + app: FastifyInstance, + options: ProjectRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + + app.get("/api/projects", async (request) => { + if (!useDatabase) return successEnvelope(listFixtureProjects(), getRequestId(request)); + const projects = await listDatabaseProjects(); + const evidence = await repositoryEvidenceForProjects(projects.map((project) => project.id)); + return successEnvelope(projects.map((project) => withRepositoryEvidence(project, evidence)), getRequestId(request)); + }); + + app.post("/api/projects", async (request, reply) => { + if (useDatabase && request.authUser?.role !== "owner") { + return reply.status(403).send(errorEnvelope( + "PROJECT_CREATION_FORBIDDEN", + "Only the ProjectBrain owner can create a project and provision its Gitea repository.", + getRequestId(request), + )); + } + if (useDatabase && options.env?.NODE_ENV === "production" && (!options.env.PROJECTBRAIN_GITEA_URL || !options.env.PROJECTBRAIN_GITEA_TOKEN)) { + return reply.status(503).send(errorEnvelope( + "GITEA_NOT_CONFIGURED", + "ProjectBrain kan geen project starten zolang de Gitea-verbinding niet geconfigureerd is.", + getRequestId(request), + )); + } + const parsed = createProjectBodySchema.safeParse(request.body); + if (!parsed.success) { + return sendValidationError(request, reply, parsed.error.issues); + } + + if (!useDatabase) { + const now = new Date().toISOString(); + const slug = parsed.data.id ?? parsed.data.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + const setupState = sanitizeProjectSetupState(parsed.data.setupState); + const setupWorkspace = projectSetupWorkspaceState(parsed.data.setupState); + return reply.status(201).send( + successEnvelope( + mergeProjectSetupState(parsed.data.setupState, { + id: slug, + name: parsed.data.name, + description: parsed.data.description, + health: 100, + healthStatus: "healthy", + lastSync: now, + domain: parsed.data.domain ?? "general", + status: "active", + createdAt: now, + updatedAt: now, + activeContextPackId: projectSetupActiveContextPackId(parsed.data.setupState), + dna: { + vision: parsed.data.dna?.vision ?? parsed.data.primaryGoal ?? parsed.data.description, + goals: parsed.data.dna?.goals ?? (parsed.data.primaryGoal ? [parsed.data.primaryGoal] : []), + nonGoals: parsed.data.dna?.nonGoals ?? [], + targetAudience: parsed.data.dna?.targetAudience ?? parsed.data.targetAudience ?? "Delivery teams", + principles: parsed.data.dna?.principles ?? [], + successMetrics: parsed.data.dna?.successMetrics ?? [], + maturityStage: parsed.data.dna?.maturityStage ?? "Discovery", + revision: parsed.data.dna?.revision ?? 1, + updatedAt: now, + }, + constraints: Array.isArray(setupState.constraints) ? setupState.constraints : [], + principles: Array.isArray(setupState.principles) ? setupState.principles : [], + initiatives: [], + featureGroups: Array.isArray(setupState.featureGroups) ? setupState.featureGroups : [], + memoryRules: Array.isArray(setupState.memoryRules) ? setupState.memoryRules : [], + promptTemplates: Array.isArray(setupState.promptTemplates) ? setupState.promptTemplates : [], + workspaceState: { + ...setupWorkspace, + projectId: slug, + primaryObjective: typeof setupWorkspace?.primaryObjective === "string" ? setupWorkspace.primaryObjective : parsed.data.primaryGoal ?? parsed.data.description, + focusAreas: Array.isArray(setupWorkspace?.focusAreas) ? setupWorkspace.focusAreas : [], + openTasksCount: typeof setupWorkspace?.openTasksCount === "number" ? setupWorkspace.openTasksCount : 0, + openBugCount: typeof setupWorkspace?.openBugCount === "number" ? setupWorkspace.openBugCount : 0, + activeSection: typeof setupWorkspace?.activeSection === "string" ? setupWorkspace.activeSection : "workspace", + updatedAt: now, + notes: Array.isArray(setupWorkspace?.notes) ? setupWorkspace.notes : [], + }, + }), + getRequestId(request), + ), + ); + } + + const existingRepository = existingRepositoryFromProjectSetup(parsed.data.setupState); + const desiredSlug = projectSlugFromInput(parsed.data.id, parsed.data.name); + let project; + try { + project = await createProject(parsed.data); + } catch (error) { + if (error instanceof Error && error.message === "PROJECT_SLUG_ALREADY_EXISTS") { + const existingProject = await getProjectBySlugOrId(desiredSlug); + const sameCreateRequest = existingProject?.name === parsed.data.name.trim() + && existingProject.description === parsed.data.description.trim(); + const hasManagedProvisioning = !existingRepository + && Boolean(options.env?.PROJECTBRAIN_GITEA_URL && options.env.PROJECTBRAIN_GITEA_TOKEN) + && sameCreateRequest + && await hasProjectRepositoryProvisioning(desiredSlug); + if (hasManagedProvisioning) { + project = existingProject; + if (!project) throw error; + } else { + return reply.status(409).send( + errorEnvelope( + "VALIDATION_ERROR", + "A project with this id or slug already exists.", + getRequestId(request), + [ + { + field: "id", + message: "Choose a different project name or id.", + code: "custom", + }, + ], + ), + ); + } + } else { + throw error; + } + } + + if (!project) throw new Error("PROJECT_CREATE_FAILED"); + const shouldProvisionRepository = !existingRepository + && Boolean(options.env?.PROJECTBRAIN_GITEA_URL && options.env.PROJECTBRAIN_GITEA_TOKEN); + if (shouldProvisionRepository) { + try { + await provisionProjectRepository(options.env!, project.id, `web:${request.authUser?.username ?? "owner"}`); + } catch (error) { + const code = error instanceof Error ? error.message : "GITEA_REPOSITORY_PROVISIONING_FAILED"; + return reply.status(502).send(errorEnvelope( + "GITEA_REPOSITORY_PROVISIONING_FAILED", + "Het ProjectBrain-project is veilig opgeslagen, maar de Gitea-repository kon nog niet worden aangemaakt. Probeer opnieuw; de bestaande provisioning wordt idempotent hervat.", + getRequestId(request), + [{ field: "repository", message: code, code: "external_service" }], + )); + } + } + + const evidence = await repositoryEvidenceForProjects([project.id]); + return reply.status(201).send(successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request))); + }); + + app.get("/api/projects/:projectId", async (request, reply) => { + const params = parseProjectParams(request, reply); + if (!params) return reply; + + if (useDatabase) { + const project = await getProjectBySlugOrId(params.projectId); + if (!project) return sendProjectNotFound(request, reply, params.projectId); + const evidence = await repositoryEvidenceForProjects([project.id]); + return successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request)); + } + + const project = findProject(params.projectId); + if (!project) return sendProjectNotFound(request, reply, params.projectId); + + return successEnvelope(project, getRequestId(request)); + }); + + app.patch("/api/projects/:projectId", async (request, reply) => { + const params = parseProjectParams(request, reply); + if (!params) return reply; + + const parsed = updateProjectBodySchema.safeParse(request.body); + if (!parsed.success) { + return sendValidationError(request, reply, parsed.error.issues); + } + + if (useDatabase) { + const project = await updateProject(params.projectId, parsed.data); + if (!project) return sendProjectNotFound(request, reply, params.projectId); + const evidence = await repositoryEvidenceForProjects([project.id]); + return successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request)); + } + + const project = findProject(params.projectId); + if (!project) return sendProjectNotFound(request, reply, params.projectId); + + return successEnvelope( + { + ...project, + ...parsed.data, + updatedAt: new Date().toISOString(), + lastSync: new Date().toISOString(), + }, + getRequestId(request), + ); + }); + + app.get("/api/projects/:projectId/workspace", async (request, reply) => { + const params = parseProjectParams(request, reply); + if (!params) return reply; + + if (useDatabase) { + const workspace = await getProjectWorkspace(params.projectId); + if (!workspace) return sendProjectNotFound(request, reply, params.projectId); + const evidence = await repositoryEvidenceForProjects([workspace.project.id]); + return successEnvelope({ ...workspace, project: withRepositoryEvidence(workspace.project, evidence) }, getRequestId(request)); + } + + const project = findProject(params.projectId); + if (!project) return sendProjectNotFound(request, reply, params.projectId); + + return successEnvelope(getWorkspaceRead(project), getRequestId(request)); + }); + + app.patch("/api/projects/:projectId/workspace/active-context-pack", async (request, reply) => { + const params = parseProjectParams(request, reply); + if (!params) return reply; + + const parsed = updateActiveContextPackBodySchema.safeParse(request.body); + if (!parsed.success) { + return sendValidationError(request, reply, parsed.error.issues); + } + + if (useDatabase) { + const workspace = await updateActiveContextPack(params.projectId, parsed.data); + if (!workspace) return sendProjectNotFound(request, reply, params.projectId); + const evidence = await repositoryEvidenceForProjects([workspace.project.id]); + return successEnvelope({ ...workspace, project: withRepositoryEvidence(workspace.project, evidence) }, getRequestId(request)); + } + + const project = findProject(params.projectId); + if (!project) return sendProjectNotFound(request, reply, params.projectId); + + return successEnvelope( + { + ...getWorkspaceRead(project), + activeContextPackId: parsed.data.contextPackId, + workspaceState: project.workspaceState + ? { + ...project.workspaceState, + activeContextPackId: parsed.data.contextPackId, + updatedAt: new Date().toISOString(), + } + : undefined, + }, + getRequestId(request), + ); + }); + + app.get("/api/projects/:projectId/dna", async (request, reply) => { + const params = parseProjectParams(request, reply); + if (!params) return reply; + + if (useDatabase) { + const dna = await getProjectDNA(params.projectId); + if (!dna) return sendProjectNotFound(request, reply, params.projectId); + + return successEnvelope(dna, getRequestId(request)); + } + + const project = findProject(params.projectId); + if (!project) return sendProjectNotFound(request, reply, params.projectId); + + return successEnvelope(project.dna, getRequestId(request)); + }); + +}; diff --git a/backend/src/routes/prompts.ts b/backend/src/routes/prompts.ts new file mode 100644 index 0000000..a36d19e --- /dev/null +++ b/backend/src/routes/prompts.ts @@ -0,0 +1,195 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import type { AppEnv } from "../config/env.js"; +import { + findContextPack, + generatePrompt, +} from "../fixtures/contextPackFixtures.js"; +import { + generatePromptFromContextPack as generateDatabasePrompt, + getContextPackBySlugOrId as getDatabaseContextPackBySlugOrId, + projectExists as databaseProjectExists, +} from "../repositories/contextPacksRepository.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); + +const projectParamsSchema = z.object({ + projectId: idSchema, +}); + +const targetToolSchema = z.enum([ + "codex", + "claude", + "gpt", + "cursor", + "gemini", + "local_llm", + "markdown", + "json", + "mcp", +]); + +const personaSchema = z.enum([ + "developer", + "architect", + "qa", + "ux", + "security", + "devops", + "technical_writer", + "product_owner", +]); + +const generatePromptBodySchema = z.object({ + contextPackId: z.string().optional(), + targetTool: targetToolSchema.default("codex"), + persona: personaSchema.default("developer"), + objective: z.string().optional(), + expectedOutput: z.string().optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const sendPromptGenerationFailed = ( + request: FastifyRequest, + reply: FastifyReply, + message: string, +) => + reply.status(400).send( + errorEnvelope("PROMPT_GENERATION_FAILED", message, getRequestId(request)), + ); + +type PromptRoutesOptions = { + dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; +}; + +export const registerPromptRoutes = async ( + app: FastifyInstance, + options: PromptRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + + app.post("/api/projects/:projectId/prompts/generate", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + + const projectExists = useDatabase + ? await databaseProjectExists(params.projectId) + : Boolean(findProject(params.projectId)); + if (!projectExists) return sendProjectNotFound(request, reply, params.projectId); + + const parsedBody = generatePromptBodySchema.safeParse(request.body); + if (!parsedBody.success) { + return sendPromptGenerationFailed( + request, + reply, + "Prompt generation request body is invalid.", + ); + } + + if (!parsedBody.data.contextPackId?.trim()) { + return reply.status(400).send( + errorEnvelope( + "ACTIVE_CONTEXT_PACK_REQUIRED", + "contextPackId is required to generate a prompt.", + getRequestId(request), + ), + ); + } + + const contextPack = useDatabase + ? await getDatabaseContextPackBySlugOrId(params.projectId, parsedBody.data.contextPackId) + : findContextPack(params.projectId, parsedBody.data.contextPackId); + + if (!contextPack) { + return reply.status(404).send( + errorEnvelope( + "CONTEXT_PACK_NOT_FOUND", + `Context pack '${parsedBody.data.contextPackId}' was not found.`, + getRequestId(request), + ), + ); + } + + if (!parsedBody.data.objective?.trim()) { + return sendPromptGenerationFailed( + request, + reply, + "objective is required to generate a prompt.", + ); + } + + const prompt = useDatabase + ? await generateDatabasePrompt(params.projectId, { + contextPackId: parsedBody.data.contextPackId, + targetTool: parsedBody.data.targetTool, + persona: parsedBody.data.persona, + objective: parsedBody.data.objective, + expectedOutput: parsedBody.data.expectedOutput, + }) + : generatePrompt(params.projectId, { + contextPackId: parsedBody.data.contextPackId, + targetTool: parsedBody.data.targetTool, + persona: parsedBody.data.persona, + objective: parsedBody.data.objective, + expectedOutput: parsedBody.data.expectedOutput, + }); + + if (!prompt) { + return sendPromptGenerationFailed( + request, + reply, + "Prompt generation failed for the selected context pack.", + ); + } + + return reply.status(201).send(successEnvelope(prompt, getRequestId(request))); + }); +}; diff --git a/backend/src/routes/regressions.ts b/backend/src/routes/regressions.ts new file mode 100644 index 0000000..3d72e90 --- /dev/null +++ b/backend/src/routes/regressions.ts @@ -0,0 +1,330 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { + applyRegressionFilters, + convertRegressionToRule, + createRegression, + findRegression, + listRegressionsForProject, +} from "../fixtures/regressionFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + ConvertRegressionRuleSeverity, + RegressionListFilters, + RegressionSeverity, + RegressionStatus, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + getIdempotencyKey, + getIdempotentResponse, + setIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const regressionParamsSchema = z.object({ + projectId: idSchema, + regressionId: idSchema, +}); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const regressionQuerySchema = paginationQuerySchema.extend({ + status: z.string().optional(), + featureId: z.string().optional(), + severity: z.string().optional(), + timesOccurredMin: z.coerce.number().int().min(0).optional(), + timesOccurredMax: z.coerce.number().int().min(0).optional(), +}); +const createRegressionBodySchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + affectedFeatureIds: z.array(z.string()).optional(), + typicalCause: z.string().optional(), + preventionRule: z.string().optional(), + severity: z.enum(["low", "medium", "high", "critical"]).optional(), +}); +const convertRegressionBodySchema = z.object({ + ruleTitle: z.string().optional(), + severity: z.enum(["important", "critical", "do_not_break"]).optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + sendProjectNotFound(request, reply, projectId); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare( + String(right[field] ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +type RegressionQuery = z.output; +type PaginationQuery = z.output; + +const toFilters = (query: RegressionQuery): RegressionListFilters => ({ + ...(query.status ? { status: splitCsv(query.status) as RegressionStatus[] } : {}), + ...(query.featureId ? { featureId: query.featureId } : {}), + ...(query.severity + ? { severity: splitCsv(query.severity) as RegressionSeverity[] } + : {}), + ...(query.timesOccurredMin !== undefined + ? { timesOccurredMin: query.timesOccurredMin } + : {}), + ...(query.timesOccurredMax !== undefined + ? { timesOccurredMax: query.timesOccurredMax } + : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const toPaginationInput = ( + query: PaginationQuery, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + return idempotencyKey; +}; + +const sendRegressionNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + regressionId: string, +) => + reply.status(404).send( + errorEnvelope( + "REGRESSION_NOT_FOUND", + `Regression '${regressionId}' was not found.`, + getRequestId(request), + ), + ); + +export const registerRegressionRoutes = async (app: FastifyInstance) => { + app.get("/api/projects/:projectId/regressions", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(regressionQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + const regressions = sortByField( + applyRegressionFilters( + listRegressionsForProject(params.projectId), + filters, + ), + query.sort, + ); + + return successEnvelope( + paginate(regressions, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get( + "/api/projects/:projectId/regressions/:regressionId", + async (request, reply) => { + const params = parseParams(regressionParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const regression = findRegression(params.projectId, params.regressionId); + if (!regression) { + return sendRegressionNotFound(request, reply, params.regressionId); + } + return successEnvelope(regression, getRequestId(request)); + }, + ); + + app.post("/api/projects/:projectId/regressions", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = createRegressionBodySchema.safeParse(request.body); + if (!parsedBody.success || !parsedBody.data.title?.trim()) { + return reply.status(400).send( + errorEnvelope( + "REGRESSION_VALIDATION_FAILED", + "title is required to create a regression.", + getRequestId(request), + ), + ); + } + + const body = successEnvelope( + createRegression(params.projectId, { + ...parsedBody.data, + title: parsedBody.data.title, + }), + getRequestId(request), + ); + setIdempotentResponse(cacheKey, { statusCode: 201, body }); + return reply.status(201).send(body); + }); + + app.post( + "/api/projects/:projectId/regressions/:regressionId/convert-to-rule", + async (request, reply) => { + const params = parseParams(regressionParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey( + request.method, + request.url, + idempotencyKey, + ); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = convertRegressionBodySchema.safeParse(request.body); + if (!parsedBody.success) { + return reply.status(409).send( + errorEnvelope( + "CONVERSION_FAILED", + "Regression conversion request body is invalid.", + getRequestId(request), + ), + ); + } + + if (!findRegression(params.projectId, params.regressionId)) { + return sendRegressionNotFound(request, reply, params.regressionId); + } + + const result = convertRegressionToRule( + params.projectId, + params.regressionId, + parsedBody.data.ruleTitle, + parsedBody.data.severity as ConvertRegressionRuleSeverity | undefined, + ); + if (!result) { + return reply.status(409).send( + errorEnvelope( + "CONVERSION_FAILED", + `Conversion failed for regression '${params.regressionId}'.`, + getRequestId(request), + ), + ); + } + + const body = successEnvelope(result, getRequestId(request)); + setIdempotentResponse(cacheKey, { statusCode: 200, body }); + return reply.status(200).send(body); + }, + ); +}; diff --git a/backend/src/routes/relationships.ts b/backend/src/routes/relationships.ts new file mode 100644 index 0000000..d23c1dd --- /dev/null +++ b/backend/src/routes/relationships.ts @@ -0,0 +1,314 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { + applyRelationshipFilters, + createRelationship, + deleteRelationship, + findRelationship, + listRelationshipsForProject, +} from "../fixtures/relationshipFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + RelationshipListFilters, + RelationshipType, + ReviewMutationResponseDto, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + getIdempotencyKey, + getIdempotentResponse, + setIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const relationshipParamsSchema = z.object({ + projectId: idSchema, + relationshipId: idSchema, +}); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const relationshipTypes = [ + "depends_on", + "conflicts_with", + "duplicates", + "supersedes", + "belongs_to", + "implements", + "references", +] as const; +const relationshipQuerySchema = paginationQuerySchema.extend({ + from: z.string().optional(), + to: z.string().optional(), + type: z.string().optional(), + confidence: z.coerce.number().optional(), + strength: z.coerce.number().int().min(1).max(5).optional(), +}); +const createRelationshipBodySchema = z.object({ + from: z.string().optional(), + to: z.string().optional(), + type: z.enum(relationshipTypes).optional(), + rationale: z.string().optional(), + strength: z.number().int().min(1).max(5).optional(), + confidence: z.number().min(0).max(5).optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare( + String(right[field] ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +const toPaginationInput = ( + query: z.output, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const toFilters = ( + query: z.output, +): RelationshipListFilters => ({ + ...(query.from ? { from: query.from } : {}), + ...(query.to ? { to: query.to } : {}), + ...(query.type ? { type: splitCsv(query.type) as RelationshipType[] } : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.strength !== undefined ? { strength: query.strength } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + return idempotencyKey; +}; + +const sendRelationshipNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + relationshipId: string, +) => + reply.status(404).send( + errorEnvelope( + "RELATIONSHIP_NOT_FOUND", + `Relationship '${relationshipId}' was not found.`, + getRequestId(request), + ), + ); + +export const registerRelationshipRoutes = async (app: FastifyInstance) => { + app.get("/api/projects/:projectId/relationships", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(relationshipQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + const relationships = sortByField( + applyRelationshipFilters( + listRelationshipsForProject(params.projectId), + filters, + ), + query.sort, + ); + + return successEnvelope( + paginate(relationships, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get( + "/api/projects/:projectId/relationships/:relationshipId", + async (request, reply) => { + const params = parseParams(relationshipParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const relationship = findRelationship(params.projectId, params.relationshipId); + if (!relationship) { + return sendRelationshipNotFound(request, reply, params.relationshipId); + } + return successEnvelope(relationship, getRequestId(request)); + }, + ); + + app.post("/api/projects/:projectId/relationships", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = createRelationshipBodySchema.safeParse(request.body); + if ( + !parsedBody.success || + !parsedBody.data.from?.trim() || + !parsedBody.data.to?.trim() || + !parsedBody.data.type || + !parsedBody.data.rationale?.trim() + ) { + return reply.status(400).send( + errorEnvelope( + "RELATIONSHIP_VALIDATION_FAILED", + "from, to, type and rationale are required to create a relationship.", + getRequestId(request), + ), + ); + } + + const body = successEnvelope( + createRelationship(params.projectId, { + ...parsedBody.data, + from: parsedBody.data.from, + to: parsedBody.data.to, + type: parsedBody.data.type, + rationale: parsedBody.data.rationale, + }), + getRequestId(request), + ); + setIdempotentResponse(cacheKey, { statusCode: 201, body }); + return reply.status(201).send(body); + }); + + app.delete( + "/api/projects/:projectId/relationships/:relationshipId", + async (request, reply) => { + const params = parseParams(relationshipParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey( + request.method, + request.url, + idempotencyKey, + ); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const relationship = deleteRelationship( + params.projectId, + params.relationshipId, + ); + if (!relationship) { + return sendRelationshipNotFound(request, reply, params.relationshipId); + } + + const body = successEnvelope( + { + success: true, + message: `Relationship '${params.relationshipId}' deleted.`, + updatedEntityId: params.relationshipId, + affectedEntities: [params.relationshipId], + }, + getRequestId(request), + ); + setIdempotentResponse(cacheKey, { statusCode: 200, body }); + return reply.status(200).send(body); + }, + ); +}; diff --git a/backend/src/routes/releaseEvidence.ts b/backend/src/routes/releaseEvidence.ts new file mode 100644 index 0000000..10f489c --- /dev/null +++ b/backend/src/routes/releaseEvidence.ts @@ -0,0 +1,315 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import type { AppEnv } from "../config/env.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + CreateReleaseEvidenceRecordRequestDto, + EvidenceStrength, + ReleaseEvidenceKind, + ReleaseEvidenceListFilters, + ReleaseEvidenceRecordDto, + ReleaseEvidenceStatus, + VerificationState, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + getIdempotencyKey, + getIdempotentResponse, + setIdempotentResponse, +} from "../utils/idempotency.js"; +import { + createReleaseEvidenceRecordInDatabase, + getReleaseEvidenceRecordFromDatabase, + listReleaseEvidenceRecordsFromDatabase, +} from "../repositories/releaseEvidenceRepository.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const evidenceParamsSchema = z.object({ projectId: idSchema, releaseEvidenceId: idSchema }); +const releaseEvidenceKindSchema = z.enum([ + "validation", + "tower_smoke", + "browser_audit", + "backup", + "restore_dry_run", + "deployment", + "release_gate", + "operator_note", +]); +const releaseEvidenceStatusSchema = z.enum(["captured", "verified", "rejected", "superseded"]); +const evidenceStrengthSchema = z.enum(["weak", "medium", "strong", "verified"]); +const verificationStateSchema = z.enum(["unverified", "needs evidence", "verified", "blocked"]); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const releaseEvidenceQuerySchema = paginationQuerySchema.extend({ + kind: z.string().optional(), + status: z.string().optional(), + evidenceStrength: z.string().optional(), + verificationState: z.string().optional(), + source: z.string().optional(), + createdBy: z.string().optional(), +}); +const releaseEvidenceBodySchema = z.object({ + kind: releaseEvidenceKindSchema, + title: z.string().min(1), + summary: z.string().min(1), + status: releaseEvidenceStatusSchema.optional(), + command: z.string().optional(), + outputText: z.string().optional(), + route: z.string().optional(), + source: z.string().optional(), + confidence: z.coerce.number().int().min(0).max(100).optional(), + evidenceStrength: evidenceStrengthSchema.optional(), + verificationState: verificationStateSchema.optional(), + createdBy: z.string().optional(), + verifiedBy: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); + return undefined; + } + return idempotencyKey; +}; + +const withMutation = async ( + request: FastifyRequest, + reply: FastifyReply, + callback: (cacheKey: string) => Promise | void, +) => { + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); + if (replay) { + reply.status(replay.statusCode).send(replay.body); + return; + } + await callback(cacheKey); +}; + +const nowIso = () => new Date().toISOString(); +const fixtureReleaseEvidence: ReleaseEvidenceRecordDto[] = [ + { + id: "rel-widefrog-runtime-readiness", + projectId: "widefrog", + capturedAt: nowIso(), + kind: "release_gate", + title: "Fixture release evidence checklist", + summary: "Runtime Readiness has fixture-mode release evidence while database mode stores durable records.", + status: "verified", + route: "/projects/widefrog/runtime", + source: "fixture", + confidence: 80, + approvalState: "approved", + evidenceStrength: "medium", + verificationState: "verified", + createdBy: "fixture", + verifiedAt: nowIso(), + verifiedBy: "fixture", + createdAt: nowIso(), + updatedAt: nowIso(), + }, +]; + +const sortByField = >(items: T[], sort: string | undefined): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare(String(right[field] ?? ""), undefined, { numeric: true }); + return direction === "desc" ? -comparison : comparison; + }); +}; + +const toPaginationInput = ( + query: z.output, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const fixturePaginate = >( + items: T[], + query: z.output, + filters: PaginationInput["filters"], +) => paginate(sortByField(items, query.sort), toPaginationInput(query, filters)); + +const releaseEvidenceFilters = ( + query: z.output, +): ReleaseEvidenceListFilters => ({ + ...(query.kind ? { kind: splitCsv(query.kind) as ReleaseEvidenceKind[] } : {}), + ...(query.status ? { status: splitCsv(query.status) as ReleaseEvidenceStatus[] } : {}), + ...(query.evidenceStrength ? { evidenceStrength: splitCsv(query.evidenceStrength) as EvidenceStrength[] } : {}), + ...(query.verificationState ? { verificationState: splitCsv(query.verificationState) as VerificationState[] } : {}), + ...(query.source ? { source: splitCsv(query.source) } : {}), + ...(query.createdBy ? { createdBy: splitCsv(query.createdBy) } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const matchesFixtureFilters = (item: ReleaseEvidenceRecordDto, filters: ReleaseEvidenceListFilters) => + (!filters.kind?.length || filters.kind.includes(item.kind)) && + (!filters.status?.length || filters.status.includes(item.status)) && + (!filters.evidenceStrength?.length || filters.evidenceStrength.includes(item.evidenceStrength)) && + (!filters.verificationState?.length || filters.verificationState.includes(item.verificationState)) && + (!filters.source?.length || filters.source.includes(item.source)) && + (!filters.createdBy?.length || filters.createdBy.includes(item.createdBy)); + +const ensureFixtureProject = (request: FastifyRequest, reply: FastifyReply, projectId: string) => { + const project = findProject(projectId); + if (!project) { + reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request))); + return undefined; + } + return project; +}; + +type ReleaseEvidenceRoutesOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; + +export const registerReleaseEvidenceRoutes = async ( + app: FastifyInstance, + options: ReleaseEvidenceRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + + app.get("/api/projects/:projectId/release-evidence", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + const query = parseQuery(releaseEvidenceQuerySchema, request, reply); + if (!params || !query) return reply; + const filters = releaseEvidenceFilters(query); + if (useDatabase) { + const result = await listReleaseEvidenceRecordsFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + return successEnvelope(result, getRequestId(request)); + } + const project = ensureFixtureProject(request, reply, params.projectId); + if (!project) return reply; + const items = fixtureReleaseEvidence + .filter((item) => item.projectId === project.id) + .filter((item) => matchesFixtureFilters(item, filters)); + return successEnvelope(fixturePaginate(items, query, filters), getRequestId(request)); + }); + + app.get("/api/projects/:projectId/release-evidence/:releaseEvidenceId", async (request, reply) => { + const params = parseParams(evidenceParamsSchema, request, reply); + if (!params) return reply; + if (useDatabase) { + const result = await getReleaseEvidenceRecordFromDatabase(params.projectId, params.releaseEvidenceId); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + if (result === "RELEASE_EVIDENCE_NOT_FOUND") return reply.status(404).send(errorEnvelope("RELEASE_EVIDENCE_NOT_FOUND", `Release evidence '${params.releaseEvidenceId}' was not found.`, getRequestId(request))); + return successEnvelope(result, getRequestId(request)); + } + const project = ensureFixtureProject(request, reply, params.projectId); + if (!project) return reply; + const item = fixtureReleaseEvidence.find((entry) => entry.projectId === project.id && entry.id === params.releaseEvidenceId); + if (!item) return reply.status(404).send(errorEnvelope("RELEASE_EVIDENCE_NOT_FOUND", `Release evidence '${params.releaseEvidenceId}' was not found.`, getRequestId(request))); + return successEnvelope(item, getRequestId(request)); + }); + + app.post("/api/projects/:projectId/release-evidence", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + const parsedBody = releaseEvidenceBodySchema.safeParse(request.body); + if (!parsedBody.success) return sendValidationError(request, reply, parsedBody.error.issues); + await withMutation(request, reply, async (cacheKey) => { + if (useDatabase) { + const record = await createReleaseEvidenceRecordInDatabase(params.projectId, parsedBody.data as CreateReleaseEvidenceRecordRequestDto); + if (!record) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + const body = successEnvelope(record, getRequestId(request)); + setIdempotentResponse(cacheKey, { statusCode: 201, body }); + return reply.status(201).send(body); + } + const project = ensureFixtureProject(request, reply, params.projectId); + if (!project) return; + const timestamp = nowIso(); + const record: ReleaseEvidenceRecordDto = { + id: `rel-${project.id}-${Date.now()}`, + projectId: project.id, + capturedAt: timestamp, + kind: parsedBody.data.kind, + title: parsedBody.data.title, + summary: parsedBody.data.summary, + status: parsedBody.data.status ?? "captured", + ...(parsedBody.data.command ? { command: parsedBody.data.command } : {}), + ...(parsedBody.data.outputText ? { outputText: parsedBody.data.outputText } : {}), + ...(parsedBody.data.route ? { route: parsedBody.data.route } : {}), + source: parsedBody.data.source ?? "fixture", + confidence: parsedBody.data.confidence ?? 100, + approvalState: "approved", + evidenceStrength: parsedBody.data.evidenceStrength ?? "verified", + verificationState: parsedBody.data.verificationState ?? "verified", + createdBy: parsedBody.data.createdBy ?? "api", + verifiedAt: timestamp, + verifiedBy: parsedBody.data.verifiedBy ?? "api", + ...(parsedBody.data.metadata ? { metadata: parsedBody.data.metadata } : {}), + createdAt: timestamp, + updatedAt: timestamp, + }; + fixtureReleaseEvidence.unshift(record); + const body = successEnvelope(record, getRequestId(request)); + setIdempotentResponse(cacheKey, { statusCode: 201, body }); + return reply.status(201).send(body); + }); + }); +}; diff --git a/backend/src/routes/reviewQueue.ts b/backend/src/routes/reviewQueue.ts new file mode 100644 index 0000000..4d67725 --- /dev/null +++ b/backend/src/routes/reviewQueue.ts @@ -0,0 +1,605 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import type { AppEnv } from "../config/env.js"; +import { + applyReviewAction, + applyReviewQueueFilters, + convertReviewItem, + findReviewItem, + listReviewItemsForProject, + mergeReviewItem, + updateReviewItem, +} from "../fixtures/reviewQueueFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + EvidenceStrength, + ReviewQueueConvertTarget, + ReviewQueueItemType, + ReviewQueueListFilters, + ReviewQueuePriority, + ReviewQueueStatus, + VerificationState, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + applyReviewActionInDatabase, + convertReviewItemInDatabase, + getReviewItemFromDatabase, + listReviewItemsFromDatabase, + mergeReviewItemInDatabase, + updateReviewItemInDatabase, +} from "../repositories/reviewQueueRepository.js"; +import { syncBlueprintReviewAction } from "../repositories/blueprintsRepository.js"; +import { syncBrainstormReviewAction } from "../repositories/brainstormRepository.js"; +import { syncInitiativeReviewAction } from "../repositories/initiativesRepository.js"; +import { findFeature } from "../fixtures/featureFixtures.js"; +import { findMemoryRule } from "../fixtures/memoryRuleFixtures.js"; +import { findKnowledge } from "../fixtures/knowledgeFixtures.js"; +import { findContextPack } from "../fixtures/contextPackFixtures.js"; +import { + getIdempotencyKey, + getDurableIdempotentResponse, + setDurableIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const reviewParamsSchema = z.object({ projectId: idSchema, reviewItemId: idSchema }); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const reviewQuerySchema = paginationQuerySchema.extend({ + priority: z.string().optional(), + type: z.string().optional(), + status: z.string().optional(), + source: z.string().optional(), + confidence: z.coerce.number().optional(), + evidenceStrength: z.string().optional(), + verificationState: z.string().optional(), +}); +const priorities = ["critical", "important", "normal", "low"] as const; +const convertTargets = [ + "feature", + "bug", + "task", + "memory_rule", + "decision", + "knowledge", + "assumption", + "open_question", + "risk", + "documentation_update", +] as const; +const updateBodySchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + priority: z.enum(priorities).optional(), + notes: z.string().optional(), +}).strict(); +const actionBodySchema = z.object({ + notes: z.string().optional(), + reason: z.string().optional(), + targetEntityId: z.string().optional(), + target: z.enum(convertTargets).optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare( + String(right[field] ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +const toPaginationInput = ( + query: z.output, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const toFilters = ( + query: z.output, +): ReviewQueueListFilters => ({ + ...(query.priority + ? { priority: splitCsv(query.priority) as ReviewQueuePriority[] } + : {}), + ...(query.type ? { type: splitCsv(query.type) as ReviewQueueItemType[] } : {}), + ...(query.status + ? { status: splitCsv(query.status) as ReviewQueueStatus[] } + : {}), + ...(query.source ? { source: splitCsv(query.source) } : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.evidenceStrength + ? { evidenceStrength: splitCsv(query.evidenceStrength) as EvidenceStrength[] } + : {}), + ...(query.verificationState + ? { verificationState: splitCsv(query.verificationState) as VerificationState[] } + : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + return idempotencyKey; +}; + +const sendReviewNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + reviewItemId: string, +) => + reply.status(404).send( + errorEnvelope( + "REVIEW_ITEM_NOT_FOUND", + `Review item '${reviewItemId}' was not found.`, + getRequestId(request), + ), + ); + +const sendAlreadyDecided = (request: FastifyRequest, reply: FastifyReply) => + reply.status(409).send( + errorEnvelope( + "REVIEW_ALREADY_DECIDED", + "This review item already has a final decision and can no longer be changed.", + getRequestId(request), + ), + ); + +const withMutation = async ( + request: FastifyRequest, + reply: FastifyReply, + useDatabase: boolean, + callback: (cacheKey: string) => Promise | void, +) => { + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = await getDurableIdempotentResponse(cacheKey, useDatabase); + if (replay) { + reply.status(replay.statusCode).send(replay.body); + return; + } + await callback(cacheKey); +}; + +type ReviewQueueRoutesOptions = { + dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; +}; + +export const registerReviewQueueRoutes = async ( + app: FastifyInstance, + options: ReviewQueueRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + app.get("/api/projects/:projectId/review-queue", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(reviewQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + + if (useDatabase) { + const result = await listReviewItemsFromDatabase( + params.projectId, + filters, + { + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + }, + ); + if (!result) { + return reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${params.projectId}' was not found.`, + getRequestId(request), + ), + ); + } + return successEnvelope(result, getRequestId(request)); + } + + const items = sortByField( + applyReviewQueueFilters(listReviewItemsForProject(params.projectId), filters), + query.sort, + ); + + return successEnvelope( + paginate(items, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.get( + "/api/projects/:projectId/review-queue/:reviewItemId", + async (request, reply) => { + const params = parseParams(reviewParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const item = useDatabase + ? await getReviewItemFromDatabase(params.projectId, params.reviewItemId) + : findReviewItem(params.projectId, params.reviewItemId); + if (!item) return sendReviewNotFound(request, reply, params.reviewItemId); + return successEnvelope(item, getRequestId(request)); + }, + ); + + app.patch( + "/api/projects/:projectId/review-queue/:reviewItemId", + async (request, reply) => { + const params = parseParams(reviewParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + await withMutation(request, reply, useDatabase, async (cacheKey) => { + const parsedBody = updateBodySchema.safeParse(request.body); + if ( + !parsedBody.success || + (parsedBody.data.title !== undefined && !parsedBody.data.title.trim()) || + (parsedBody.data.description !== undefined && !parsedBody.data.description.trim()) + ) { + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Review queue update body is invalid.", + getRequestId(request), + ), + ); + return; + } + const item = useDatabase + ? await updateReviewItemInDatabase( + params.projectId, + params.reviewItemId, + parsedBody.data, + ) + : updateReviewItem( + params.projectId, + params.reviewItemId, + parsedBody.data, + ); + if (!item) { + sendReviewNotFound(request, reply, params.reviewItemId); + return; + } + if (item === "REVIEW_ALREADY_DECIDED") { + sendAlreadyDecided(request, reply); + return; + } + const body = successEnvelope(item, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + reply.status(200).send(body); + }); + }, + ); + + app.post( + "/api/projects/:projectId/review-queue/:reviewItemId/accept", + async (request, reply) => { + const params = parseParams(reviewParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + await withMutation(request, reply, useDatabase, async (cacheKey) => { + const parsedBody = actionBodySchema.safeParse(request.body); + if (!parsedBody.success) { + sendValidationError(request, reply, parsedBody.error.issues); + return; + } + const result = useDatabase + ? await applyReviewActionInDatabase( + params.projectId, + params.reviewItemId, + "accept", + parsedBody.data.notes ?? parsedBody.data.reason, + ) + : applyReviewAction( + params.projectId, + params.reviewItemId, + "accept", + parsedBody.data.notes ?? parsedBody.data.reason, + ); + if (!result) { + sendReviewNotFound(request, reply, params.reviewItemId); + return; + } + if (result === "REVIEW_ALREADY_DECIDED") { + sendAlreadyDecided(request, reply); + return; + } + const blueprintSync = useDatabase + ? await syncBlueprintReviewAction(params.projectId, params.reviewItemId, "accept") + : undefined; + const brainstormSync = useDatabase + ? await syncBrainstormReviewAction(params.projectId, params.reviewItemId, "accept") + : undefined; + const initiativeSync = useDatabase + ? await syncInitiativeReviewAction(params.projectId, params.reviewItemId, "accept") + : undefined; + const body = successEnvelope({ ...result, ...(blueprintSync ? { blueprintSync } : {}), ...(brainstormSync ? { brainstormSync } : {}), ...(initiativeSync ? { initiativeSync } : {}) }, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + reply.status(200).send(body); + }); + }, + ); + + app.post( + "/api/projects/:projectId/review-queue/:reviewItemId/reject", + async (request, reply) => { + const params = parseParams(reviewParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + await withMutation(request, reply, useDatabase, async (cacheKey) => { + const parsedBody = actionBodySchema.safeParse(request.body); + if (!parsedBody.success) { + sendValidationError(request, reply, parsedBody.error.issues); + return; + } + const result = useDatabase + ? await applyReviewActionInDatabase( + params.projectId, + params.reviewItemId, + "reject", + parsedBody.data.notes ?? parsedBody.data.reason, + ) + : applyReviewAction( + params.projectId, + params.reviewItemId, + "reject", + parsedBody.data.notes ?? parsedBody.data.reason, + ); + if (!result) { + sendReviewNotFound(request, reply, params.reviewItemId); + return; + } + if (result === "REVIEW_ALREADY_DECIDED") { + sendAlreadyDecided(request, reply); + return; + } + const blueprintSync = useDatabase + ? await syncBlueprintReviewAction(params.projectId, params.reviewItemId, "reject") + : undefined; + const brainstormSync = useDatabase + ? await syncBrainstormReviewAction(params.projectId, params.reviewItemId, "reject") + : undefined; + const initiativeSync = useDatabase + ? await syncInitiativeReviewAction(params.projectId, params.reviewItemId, "reject") + : undefined; + const body = successEnvelope({ ...result, ...(blueprintSync ? { blueprintSync } : {}), ...(brainstormSync ? { brainstormSync } : {}), ...(initiativeSync ? { initiativeSync } : {}) }, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + reply.status(200).send(body); + }); + }, + ); + + app.post( + "/api/projects/:projectId/review-queue/:reviewItemId/merge", + async (request, reply) => { + const params = parseParams(reviewParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + await withMutation(request, reply, useDatabase, async (cacheKey) => { + const parsedBody = actionBodySchema.safeParse(request.body); + if (!parsedBody.success) { + sendValidationError(request, reply, parsedBody.error.issues); + return; + } + if (!parsedBody.data.targetEntityId?.trim()) { + reply.status(409).send( + errorEnvelope( + "REVIEW_ACTION_CONFLICT", + "targetEntityId is required when merging a review item.", + getRequestId(request), + ), + ); + return; + } + if (!useDatabase) { + const targetId = parsedBody.data.targetEntityId; + const targetExists = Boolean( + findFeature(params.projectId, targetId) || + findMemoryRule(params.projectId, targetId) || + findKnowledge(params.projectId, targetId) || + findContextPack(params.projectId, targetId) || + findReviewItem(params.projectId, targetId), + ); + if (!targetExists || targetId === params.reviewItemId) { + reply.status(409).send(errorEnvelope( + "REVIEW_TARGET_NOT_FOUND", + "The merge target must identify an existing entity in this project.", + getRequestId(request), + )); + return; + } + } + const result = useDatabase + ? await mergeReviewItemInDatabase( + params.projectId, + params.reviewItemId, + parsedBody.data.targetEntityId, + parsedBody.data.notes ?? parsedBody.data.reason, + ) + : mergeReviewItem( + params.projectId, + params.reviewItemId, + parsedBody.data.targetEntityId, + parsedBody.data.notes ?? parsedBody.data.reason, + ); + if (!result) { + sendReviewNotFound(request, reply, params.reviewItemId); + return; + } + if (result === "REVIEW_ALREADY_DECIDED") { + sendAlreadyDecided(request, reply); + return; + } + if (result === "REVIEW_TARGET_NOT_FOUND") { + reply.status(409).send(errorEnvelope( + "REVIEW_TARGET_NOT_FOUND", + "The merge target must identify an existing entity in this project.", + getRequestId(request), + )); + return; + } + const body = successEnvelope(result, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + reply.status(200).send(body); + }); + }, + ); + + app.post( + "/api/projects/:projectId/review-queue/:reviewItemId/convert", + async (request, reply) => { + const params = parseParams(reviewParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + await withMutation(request, reply, useDatabase, async (cacheKey) => { + const parsedBody = actionBodySchema.safeParse(request.body); + if (!parsedBody.success || !parsedBody.data.target) { + reply.status(400).send( + errorEnvelope( + "REVIEW_ACTION_INVALID", + "target is required and must be a supported conversion target.", + getRequestId(request), + ), + ); + return; + } + if (!useDatabase) { + reply.status(501).send(errorEnvelope( + "DATABASE_MODE_REQUIRED", + "Review conversion is unavailable in fixture mode because no durable artifact can be created.", + getRequestId(request), + )); + return; + } + const result = useDatabase + ? await convertReviewItemInDatabase( + params.projectId, + params.reviewItemId, + parsedBody.data.target as ReviewQueueConvertTarget, + parsedBody.data.notes ?? parsedBody.data.reason, + ) + : convertReviewItem( + params.projectId, + params.reviewItemId, + parsedBody.data.target as ReviewQueueConvertTarget, + parsedBody.data.notes ?? parsedBody.data.reason, + ); + if (!result) { + sendReviewNotFound(request, reply, params.reviewItemId); + return; + } + if (result === "REVIEW_ALREADY_DECIDED") { + sendAlreadyDecided(request, reply); + return; + } + const body = successEnvelope(result, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + reply.status(200).send(body); + }); + }, + ); +}; diff --git a/backend/src/routes/runtimePersistence.ts b/backend/src/routes/runtimePersistence.ts new file mode 100644 index 0000000..0bd5a96 --- /dev/null +++ b/backend/src/routes/runtimePersistence.ts @@ -0,0 +1,400 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import type { AppEnv } from "../config/env.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { + AcceptedTruthItemDto, + AcceptedTruthKind, + AcceptedTruthListFilters, + AcceptedTruthStatus, + EvidenceStrength, + CreateOperatingLogEntryRequestDto, + OperatingLogEntryDto, + OperatingLogEntryType, + OperatingLogListFilters, + ProjectUpdateDto, + ProjectUpdateListFilters, + ProjectUpdateStatus, + ProjectUpdateTargetArea, +} from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + createOperatingLogEntryInDatabase, + createProjectUpdateFromTruthInDatabase, + applyProjectUpdateInDatabase, + listAcceptedTruthFromDatabase, + listOperatingLogEntriesFromDatabase, + listProjectUpdatesFromDatabase, + materializeReviewItemAsTruthInDatabase, +} from "../repositories/runtimeRepository.js"; +import { + getIdempotencyKey, + getDurableIdempotentResponse, + setDurableIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const reviewParamsSchema = z.object({ projectId: idSchema, reviewItemId: idSchema }); +const truthParamsSchema = z.object({ projectId: idSchema, truthItemId: idSchema }); +const updateParamsSchema = z.object({ projectId: idSchema, projectUpdateId: idSchema }); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const truthQuerySchema = paginationQuerySchema.extend({ + kind: z.string().optional(), + status: z.string().optional(), + confidence: z.coerce.number().optional(), + evidenceStrength: z.string().optional(), + sourceReviewItemId: z.string().optional(), +}); +const updateQuerySchema = paginationQuerySchema.extend({ + status: z.string().optional(), + targetArea: z.string().optional(), + kind: z.string().optional(), + sourceTruthItemId: z.string().optional(), +}); +const logQuerySchema = paginationQuerySchema.extend({ + type: z.string().optional(), + source: z.string().optional(), + importance: z.string().optional(), + linkedEntityId: z.string().optional(), +}); +const operatingLogBodySchema = z.object({ + type: z.string().min(1), + title: z.string().min(1), + summary: z.string().min(1), + linkedEntityIds: z.array(z.string()).optional(), + source: z.string().optional(), + importance: z.enum(["critical", "important", "normal", "low"]).optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const ensureProject = (request: FastifyRequest, reply: FastifyReply, projectId: string) => { + const project = findProject(projectId); + if (!project) { + reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request))); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const toPaginationInput = ( + query: z.output, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const sortByField = >(items: T[], sort: string | undefined): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare(String(right[field] ?? ""), undefined, { numeric: true }); + return direction === "desc" ? -comparison : comparison; + }); +}; + +const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); + return undefined; + } + return idempotencyKey; +}; + +const withMutation = async ( + request: FastifyRequest, + reply: FastifyReply, + useDatabase: boolean, + callback: (cacheKey: string) => Promise | void, +) => { + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = await getDurableIdempotentResponse(cacheKey, useDatabase); + if (replay) { + reply.status(replay.statusCode).send(replay.body); + return; + } + await callback(cacheKey); +}; + +const nowIso = () => new Date().toISOString(); +const fixtureTruth: AcceptedTruthItemDto[] = []; +const fixtureUpdates: ProjectUpdateDto[] = []; +const fixtureLogs: OperatingLogEntryDto[] = []; + +const fixturePaginate = >( + items: T[], + query: z.output, + filters: PaginationInput["filters"], +) => paginate(sortByField(items, query.sort), toPaginationInput(query, filters)); + +const truthFilters = (query: z.output): AcceptedTruthListFilters => ({ + ...(query.kind ? { kind: splitCsv(query.kind) as AcceptedTruthKind[] } : {}), + ...(query.status ? { status: splitCsv(query.status) as AcceptedTruthStatus[] } : {}), + ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), + ...(query.evidenceStrength ? { evidenceStrength: splitCsv(query.evidenceStrength) as EvidenceStrength[] } : {}), + ...(query.sourceReviewItemId ? { sourceReviewItemId: query.sourceReviewItemId } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const updateFilters = (query: z.output): ProjectUpdateListFilters => ({ + ...(query.status ? { status: splitCsv(query.status) as ProjectUpdateStatus[] } : {}), + ...(query.targetArea ? { targetArea: splitCsv(query.targetArea) as ProjectUpdateTargetArea[] } : {}), + ...(query.kind ? { kind: splitCsv(query.kind) as AcceptedTruthKind[] } : {}), + ...(query.sourceTruthItemId ? { sourceTruthItemId: query.sourceTruthItemId } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const logFilters = (query: z.output): OperatingLogListFilters => ({ + ...(query.type ? { type: splitCsv(query.type) as OperatingLogEntryType[] } : {}), + ...(query.source ? { source: splitCsv(query.source) } : {}), + ...(query.importance ? { importance: splitCsv(query.importance) as OperatingLogListFilters["importance"] } : {}), + ...(query.linkedEntityId ? { linkedEntityId: query.linkedEntityId } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +type RuntimePersistenceRoutesOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; + +export const registerRuntimePersistenceRoutes = async ( + app: FastifyInstance, + options: RuntimePersistenceRoutesOptions = {}, +) => { + const useDatabase = options.dataSource === "database"; + + app.get("/api/projects/:projectId/accepted-truth", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(truthQuerySchema, request, reply); + if (!query) return reply; + const filters = truthFilters(query); + if (useDatabase) { + const result = await listAcceptedTruthFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + return successEnvelope(result, getRequestId(request)); + } + const items = fixtureTruth.filter((item) => item.projectId === params.projectId); + return successEnvelope(fixturePaginate(items, query, filters), getRequestId(request)); + }); + + app.post("/api/projects/:projectId/review-queue/:reviewItemId/materialize-truth", async (request, reply) => { + const params = parseParams(reviewParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + await withMutation(request, reply, useDatabase, async (cacheKey) => { + if (useDatabase) { + const result = await materializeReviewItemAsTruthInDatabase(params.projectId, params.reviewItemId); + if (!result) return reply.status(404).send(errorEnvelope("REVIEW_ITEM_NOT_FOUND", `Review item '${params.reviewItemId}' was not found.`, getRequestId(request))); + const body = successEnvelope(result, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + return reply.status(200).send(body); + } + const id = `truth-${params.projectId}-${Date.now()}`; + const truth: AcceptedTruthItemDto = { + id, + projectId: params.projectId, + sourceReviewItemId: params.reviewItemId, + kind: "knowledge", + title: `Accepted truth from ${params.reviewItemId}`, + summary: "Fixture-mode accepted truth materialized from review queue.", + status: "accepted", + source: "review_queue", + confidence: 72, + evidenceStrength: "medium", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + fixtureTruth.unshift(truth); + const body = successEnvelope({ success: true, message: "Accepted truth materialized.", updatedEntityId: params.reviewItemId, createdEntityId: id, affectedEntities: [params.reviewItemId, id] }, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + return reply.status(200).send(body); + }); + }); + + app.get("/api/projects/:projectId/project-updates", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(updateQuerySchema, request, reply); + if (!query) return reply; + const filters = updateFilters(query); + if (useDatabase) { + const result = await listProjectUpdatesFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + return successEnvelope(result, getRequestId(request)); + } + const items = fixtureUpdates.filter((item) => item.projectId === params.projectId); + return successEnvelope(fixturePaginate(items, query, filters), getRequestId(request)); + }); + + app.post("/api/projects/:projectId/accepted-truth/:truthItemId/project-updates", async (request, reply) => { + const params = parseParams(truthParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + await withMutation(request, reply, useDatabase, async (cacheKey) => { + if (useDatabase) { + const result = await createProjectUpdateFromTruthInDatabase(params.projectId, params.truthItemId); + if (!result) return reply.status(404).send(errorEnvelope("ACCEPTED_TRUTH_NOT_FOUND", `Accepted truth '${params.truthItemId}' was not found.`, getRequestId(request))); + const body = successEnvelope(result, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + return reply.status(200).send(body); + } + const truth = fixtureTruth.find((item) => item.id === params.truthItemId && item.projectId === params.projectId); + if (!truth) return reply.status(404).send(errorEnvelope("ACCEPTED_TRUTH_NOT_FOUND", `Accepted truth '${params.truthItemId}' was not found.`, getRequestId(request))); + const id = `update-${params.projectId}-${Date.now()}`; + const update: ProjectUpdateDto = { + id, + projectId: params.projectId, + sourceTruthItemId: truth.id, + kind: truth.kind, + title: truth.title, + summary: truth.summary, + targetArea: truth.kind === "feature" ? "feature-map" : "knowledge", + status: "ready", + confidence: truth.confidence, + evidenceStrength: truth.evidenceStrength, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + fixtureUpdates.unshift(update); + const body = successEnvelope({ success: true, message: "Project update prepared.", updatedEntityId: truth.id, createdEntityId: id, affectedEntities: [truth.id, id] }, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + return reply.status(200).send(body); + }); + }); + + app.patch("/api/projects/:projectId/project-updates/:projectUpdateId/apply", async (request, reply) => { + const params = parseParams(updateParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + await withMutation(request, reply, useDatabase, async (cacheKey) => { + if (useDatabase) { + const result = await applyProjectUpdateInDatabase(params.projectId, params.projectUpdateId); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_UPDATE_NOT_FOUND", `Project update '${params.projectUpdateId}' was not found.`, getRequestId(request))); + if (result === "PROJECT_UPDATE_ALREADY_APPLIED") return reply.status(409).send(errorEnvelope("PROJECT_UPDATE_ALREADY_APPLIED", "This project update was already applied.", getRequestId(request))); + const body = successEnvelope(result, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + return reply.status(200).send(body); + } + const update = fixtureUpdates.find((item) => item.id === params.projectUpdateId && item.projectId === params.projectId); + if (!update) return reply.status(404).send(errorEnvelope("PROJECT_UPDATE_NOT_FOUND", `Project update '${params.projectUpdateId}' was not found.`, getRequestId(request))); + update.status = "applied"; + update.appliedAt = nowIso(); + update.updatedAt = nowIso(); + const body = successEnvelope({ success: true, message: "Project update applied.", updatedEntityId: update.id, affectedEntities: [update.id, update.sourceTruthItemId] }, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 200, body }, useDatabase); + return reply.status(200).send(body); + }); + }); + + app.get("/api/projects/:projectId/operating-log", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(logQuerySchema, request, reply); + if (!query) return reply; + const filters = logFilters(query); + if (useDatabase) { + const result = await listOperatingLogEntriesFromDatabase(params.projectId, filters, { page: query.page, pageSize: query.pageSize, sort: query.sort }); + if (!result) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + return successEnvelope(result, getRequestId(request)); + } + const items = fixtureLogs.filter((item) => item.projectId === params.projectId); + return successEnvelope(fixturePaginate(items, query, filters), getRequestId(request)); + }); + + app.post("/api/projects/:projectId/operating-log", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!useDatabase && !ensureProject(request, reply, params.projectId)) return reply; + const parsedBody = operatingLogBodySchema.safeParse(request.body); + if (!parsedBody.success) return sendValidationError(request, reply, parsedBody.error.issues); + await withMutation(request, reply, useDatabase, async (cacheKey) => { + if (useDatabase) { + const entry = await createOperatingLogEntryInDatabase(params.projectId, parsedBody.data as CreateOperatingLogEntryRequestDto); + if (!entry) return reply.status(404).send(errorEnvelope("PROJECT_NOT_FOUND", `Project '${params.projectId}' was not found.`, getRequestId(request))); + const body = successEnvelope(entry, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body }, useDatabase); + return reply.status(201).send(body); + } + const entry: OperatingLogEntryDto = { + id: `log-${params.projectId}-${Date.now()}`, + projectId: params.projectId, + timestamp: nowIso(), + type: parsedBody.data.type as OperatingLogEntryType, + title: parsedBody.data.title, + summary: parsedBody.data.summary, + linkedEntityIds: parsedBody.data.linkedEntityIds ?? [], + source: parsedBody.data.source ?? "manual", + importance: parsedBody.data.importance ?? "normal", + createdAt: nowIso(), + updatedAt: nowIso(), + }; + fixtureLogs.unshift(entry); + const body = successEnvelope(entry, getRequestId(request)); + await setDurableIdempotentResponse(cacheKey, { statusCode: 201, body }, useDatabase); + return reply.status(201).send(body); + }); + }); +}; diff --git a/backend/src/routes/snapshots.ts b/backend/src/routes/snapshots.ts new file mode 100644 index 0000000..089c24a --- /dev/null +++ b/backend/src/routes/snapshots.ts @@ -0,0 +1,301 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; +import { findProject } from "../fixtures/projectFixtures.js"; +import { + applySnapshotFilters, + compareSnapshots, + createSnapshot, + findSnapshot, + listSnapshotsForProject, +} from "../fixtures/snapshotFixtures.js"; +import { getRequestId } from "../plugins/requestId.js"; +import type { SnapshotListFilters, SnapshotSourceRun } from "../types/api.js"; +import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; +import { + getIdempotencyKey, + getIdempotentResponse, + setIdempotentResponse, +} from "../utils/idempotency.js"; + +const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); +const projectParamsSchema = z.object({ projectId: idSchema }); +const snapshotParamsSchema = z.object({ + projectId: idSchema, + snapshotId: idSchema, +}); +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + pageSize: z.coerce.number().int().positive().max(100).default(25), + cursor: z.string().optional(), + sort: z.string().optional(), +}); +const snapshotQuerySchema = paginationQuerySchema.extend({ + sourceRun: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + containsRisk: z.string().optional(), +}); +const createSnapshotBodySchema = z.object({ + sourceRun: z + .enum(["manual", "import-analysis", "check-run", "build"]) + .optional(), + summary: z.string().optional(), + keyRisks: z.array(z.string()).optional(), +}); +const compareBodySchema = z.object({ + baseSnapshotId: z.string().optional(), + targetSnapshotId: z.string().optional(), +}); + +const sendValidationError = ( + request: FastifyRequest, + reply: FastifyReply, + validation: z.ZodIssue[], +) => + reply.status(400).send( + errorEnvelope( + "VALIDATION_ERROR", + "Request validation failed", + getRequestId(request), + validation.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + code: issue.code, + })), + ), + ); + +const parseParams = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.params); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const parseQuery = ( + schema: TSchema, + request: FastifyRequest, + reply: FastifyReply, +): z.output | undefined => { + const parsed = schema.safeParse(request.query); + if (!parsed.success) { + sendValidationError(request, reply, parsed.error.issues); + return undefined; + } + return parsed.data; +}; + +const sendProjectNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => + reply.status(404).send( + errorEnvelope( + "PROJECT_NOT_FOUND", + `Project '${projectId}' was not found.`, + getRequestId(request), + ), + ); + +const ensureProject = ( + request: FastifyRequest, + reply: FastifyReply, + projectId: string, +) => { + const project = findProject(projectId); + if (!project) { + sendProjectNotFound(request, reply, projectId); + return undefined; + } + return project; +}; + +const splitCsv = (value: string | undefined): string[] => + value?.split(",").map((item) => item.trim()).filter(Boolean) ?? []; + +const sortByField = >( + items: T[], + sort: string | undefined, +): T[] => { + if (!sort) return items; + const [field, direction = "asc"] = sort.split(":"); + if (!field) return items; + return [...items].sort((left, right) => { + const comparison = String(left[field] ?? "").localeCompare( + String(right[field] ?? ""), + undefined, + { numeric: true }, + ); + return direction === "desc" ? -comparison : comparison; + }); +}; + +type SnapshotQuery = z.output; +type PaginationQuery = z.output; + +const toFilters = (query: SnapshotQuery): SnapshotListFilters => ({ + ...(query.sourceRun + ? { sourceRun: splitCsv(query.sourceRun) as SnapshotSourceRun[] } + : {}), + ...(query.from ? { from: query.from } : {}), + ...(query.to ? { to: query.to } : {}), + ...(query.containsRisk ? { containsRisk: query.containsRisk } : {}), + ...(query.cursor ? { cursor: query.cursor } : {}), +}); + +const toPaginationInput = ( + query: PaginationQuery, + filters: PaginationInput["filters"], +): PaginationInput => ({ + page: query.page, + pageSize: query.pageSize, + cursor: query.cursor, + sort: query.sort, + filters, +}); + +const getRequiredIdempotencyKey = ( + request: FastifyRequest, + reply: FastifyReply, +): string | undefined => { + const rawKey = request.headers["idempotency-key"]; + const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!idempotencyKey?.trim()) { + reply.status(400).send( + errorEnvelope( + "IDEMPOTENCY_KEY_REQUIRED", + "Idempotency-Key header is required.", + getRequestId(request), + ), + ); + return undefined; + } + return idempotencyKey; +}; + +const sendSnapshotNotFound = ( + request: FastifyRequest, + reply: FastifyReply, + snapshotId: string, +) => + reply.status(404).send( + errorEnvelope( + "SNAPSHOT_NOT_FOUND", + `Snapshot '${snapshotId}' was not found.`, + getRequestId(request), + ), + ); + +export const registerSnapshotRoutes = async (app: FastifyInstance) => { + app.get("/api/projects/:projectId/snapshots", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const query = parseQuery(snapshotQuerySchema, request, reply); + if (!query) return reply; + + const filters = toFilters(query); + const snapshots = sortByField( + applySnapshotFilters(listSnapshotsForProject(params.projectId), filters), + query.sort, + ); + return successEnvelope( + paginate(snapshots, toPaginationInput(query, filters)), + getRequestId(request), + ); + }); + + app.post("/api/projects/:projectId/snapshots", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = createSnapshotBodySchema.safeParse(request.body); + if (!parsedBody.success) { + return reply.status(400).send( + errorEnvelope( + "SNAPSHOT_VALIDATION_FAILED", + "Snapshot request body is invalid.", + getRequestId(request), + ), + ); + } + + const body = successEnvelope( + createSnapshot(params.projectId, parsedBody.data), + getRequestId(request), + ); + setIdempotentResponse(cacheKey, { statusCode: 201, body }); + return reply.status(201).send(body); + }); + + app.post("/api/projects/:projectId/snapshots/compare", async (request, reply) => { + const params = parseParams(projectParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const idempotencyKey = getRequiredIdempotencyKey(request, reply); + if (!idempotencyKey) return reply; + const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); + const replay = getIdempotentResponse(cacheKey); + if (replay) return reply.status(replay.statusCode).send(replay.body); + + const parsedBody = compareBodySchema.safeParse(request.body); + if ( + !parsedBody.success || + !parsedBody.data.baseSnapshotId?.trim() || + !parsedBody.data.targetSnapshotId?.trim() + ) { + return reply.status(400).send( + errorEnvelope( + "SNAPSHOT_COMPARE_FAILED", + "baseSnapshotId and targetSnapshotId are required.", + getRequestId(request), + ), + ); + } + + const comparison = compareSnapshots( + params.projectId, + parsedBody.data.baseSnapshotId, + parsedBody.data.targetSnapshotId, + ); + if (!comparison) { + return reply.status(404).send( + errorEnvelope( + "SNAPSHOT_NOT_FOUND", + "One or both snapshots were not found.", + getRequestId(request), + ), + ); + } + + const body = successEnvelope(comparison, getRequestId(request)); + setIdempotentResponse(cacheKey, { statusCode: 200, body }); + return reply.status(200).send(body); + }); + + app.get( + "/api/projects/:projectId/snapshots/:snapshotId", + async (request, reply) => { + const params = parseParams(snapshotParamsSchema, request, reply); + if (!params) return reply; + if (!ensureProject(request, reply, params.projectId)) return reply; + const snapshot = findSnapshot(params.projectId, params.snapshotId); + if (!snapshot) return sendSnapshotNotFound(request, reply, params.snapshotId); + return successEnvelope(snapshot, getRequestId(request)); + }, + ); +}; diff --git a/backend/src/routes/systemReadiness.ts b/backend/src/routes/systemReadiness.ts new file mode 100644 index 0000000..4b0da83 --- /dev/null +++ b/backend/src/routes/systemReadiness.ts @@ -0,0 +1,237 @@ +import type { FastifyInstance } from "fastify"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { getRequestId } from "../plugins/requestId.js"; +import { successEnvelope } from "../utils/envelope.js"; +import { loadAutonomyRuntime, type AutonomyRuntimeSnapshot } from "../services/autonomyRuntime.js"; + +type ReadinessCheck = { + id: string; + label: string; + status: "pass" | "warn" | "fail" | "skip"; + detail: string; + value?: string | number | boolean; +}; + +type SystemReadinessResponse = { + status: "ready" | "degraded" | "blocked"; + service: "projectbrain-api"; + dataSource: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; + environment: AppEnv["NODE_ENV"]; + database: { + required: boolean; + reachable: boolean; + provider: "postgresql"; + projectCount?: number; + blueprintGraphCount?: number; + migrationCount?: number; + portfolioRepositoryCount?: number; + linkedRepositoryCount?: number; + error?: string; + }; + checks: ReadinessCheck[]; + analysisWorker: { + status: "ready" | "degraded" | "unavailable"; + activeJobId?: string; + queuedJobs?: number; + lastSeenAt?: string; + }; + autonomyExecutor: { + status: "ready" | "degraded" | "unavailable"; + connectedExecutors?: number; + enabledProjects?: number; + claimableProjects?: number; + activeProjects?: number; + pendingWorkItems?: number; + lastSeenAt?: string; + }; + generatedAt: string; +}; + +const toSafeError = (error: unknown): string => { + if (error instanceof Error) return error.message; + return String(error); +}; + +export const registerSystemReadinessRoutes = async ( + app: FastifyInstance, + options: { env: AppEnv }, +) => { + app.get("/api/system/readiness", async (request) => { + const checks: ReadinessCheck[] = []; + const databaseRequired = options.env.PROJECTBRAIN_DATA_SOURCE === "database"; + const database = { + required: databaseRequired, + reachable: false, + provider: "postgresql" as const, + projectCount: undefined as number | undefined, + blueprintGraphCount: undefined as number | undefined, + migrationCount: undefined as number | undefined, + portfolioRepositoryCount: undefined as number | undefined, + linkedRepositoryCount: undefined as number | undefined, + error: undefined as string | undefined, + }; + const analysisWorker: SystemReadinessResponse["analysisWorker"] = { status: "unavailable" }; + const autonomyExecutor: SystemReadinessResponse["autonomyExecutor"] = { status: "unavailable" }; + + checks.push({ + id: "data-source-mode", + label: "Data source mode", + status: databaseRequired ? "pass" : "warn", + detail: databaseRequired + ? "Backend is configured for database-backed persistence." + : "Backend is running in fixture mode; persistence checks are informational only.", + value: options.env.PROJECTBRAIN_DATA_SOURCE, + }); + + if (!databaseRequired) { + checks.push({ + id: "database-connection", + label: "Database connection", + status: "skip", + detail: "Skipped because PROJECTBRAIN_DATA_SOURCE is not database.", + }); + } else { + try { + await prisma.$queryRaw`SELECT 1`; + database.reachable = true; + const [projectCount, blueprintGraphCount, migrations] = await Promise.all([ + prisma.project.count(), + prisma.blueprintGraph.count(), + prisma.$queryRaw>`SELECT COUNT(*)::bigint AS count FROM "_prisma_migrations" WHERE finished_at IS NOT NULL`, + ]); + database.projectCount = projectCount; + database.blueprintGraphCount = blueprintGraphCount; + database.migrationCount = Number(migrations[0]?.count ?? 0); + try { + const [portfolioRepositoryCount, linkedRepositoryCount] = await Promise.all([ + prisma.portfolioRepository.count(), + prisma.giteaRepositoryLink.count(), + ]); + database.portfolioRepositoryCount = portfolioRepositoryCount; + database.linkedRepositoryCount = linkedRepositoryCount; + checks.push({ + id: "portfolio-control-plane", + label: "Portfolio control plane", + status: portfolioRepositoryCount === linkedRepositoryCount && portfolioRepositoryCount > 0 ? "pass" : "warn", + detail: `${linkedRepositoryCount} of ${portfolioRepositoryCount} discovered repositories are linked to ProjectBrain.`, + value: linkedRepositoryCount, + }); + } catch { + checks.push({ id: "portfolio-control-plane", label: "Portfolio control plane", status: "skip", detail: "Portfolio tables are not available yet; migrations may still be applying." }); + } + try { + const autonomy: AutonomyRuntimeSnapshot = await loadAutonomyRuntime(options.env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS); + const executorFresh = autonomy.counts.connectedExecutors > 0; + // Paused Auto Mode has no executor dependency. Keep the actual heartbeat + // and connection count untouched; readiness is not proof of connection. + const executorRequired = autonomy.counts.enabledProjects > 0 + || autonomy.counts.claimableProjects > 0 + || autonomy.counts.activeProjects > 0 + || autonomy.counts.pendingWorkItems > 0; + autonomyExecutor.status = !executorRequired || executorFresh ? "ready" : autonomy.executor ? "degraded" : "unavailable"; + autonomyExecutor.connectedExecutors = autonomy.counts.connectedExecutors; + autonomyExecutor.enabledProjects = autonomy.counts.enabledProjects; + autonomyExecutor.claimableProjects = autonomy.counts.claimableProjects; + autonomyExecutor.activeProjects = autonomy.counts.activeProjects; + autonomyExecutor.pendingWorkItems = autonomy.counts.pendingWorkItems; + if (autonomy.executor) autonomyExecutor.lastSeenAt = autonomy.executor.lastSeenAt; + checks.push({ + id: "autonomy-executor", + label: "Autonomous execution loop", + status: !executorRequired ? "skip" : executorFresh ? "pass" : "warn", + detail: !executorRequired + ? "Auto Mode has no enabled projects or pending/active work; an autonomous executor is not required. Historical heartbeat data is unchanged." + : executorFresh + ? `${autonomy.counts.connectedExecutors} scheduled autonomous executor(s) connected; ${autonomy.counts.activeProjects} project(s) active and ${autonomy.counts.claimableProjects} claimable.` + : `The autonomous executor heartbeat is missing or stale; ${autonomy.counts.pendingWorkItems} pending work item(s) will remain durable but will not start automatically.`, + value: autonomy.counts.pendingWorkItems, + }); + } catch { + checks.push({ + id: "autonomy-executor", + label: "Autonomous execution loop", + status: "skip", + detail: "Autonomous-executor tables are not available yet; migrations may still be applying.", + }); + } + + checks.push({ + id: "database-connection", + label: "Database connection", + status: "pass", + detail: "Postgres accepted a live query through Prisma.", + }); + checks.push({ + id: "database-seed", + label: "Seeded project data", + status: projectCount > 0 ? "pass" : "warn", + detail: projectCount > 0 + ? "At least one project is available in database mode." + : "Database is reachable but has no projects yet; run the seed command or create a project.", + value: projectCount, + }); + checks.push({ + id: "database-migrations", + label: "Applied migrations", + status: database.migrationCount > 0 ? "pass" : "warn", + detail: database.migrationCount > 0 + ? "Prisma migrations table contains finished migrations." + : "No finished Prisma migrations were found.", + value: database.migrationCount, + }); + try { + const [worker, queuedJobs] = await Promise.all([ + prisma.analysisWorkerHeartbeat.findUnique({ where: { id: "primary" } }), + prisma.analysisJob.count({ where: { status: "queued" } }), + ]); + const workerFresh = Boolean(worker && worker.lastSeenAt.getTime() >= Date.now() - 30_000); + analysisWorker.status = workerFresh && worker?.status === "ready" ? "ready" : worker ? "degraded" : "unavailable"; + analysisWorker.queuedJobs = queuedJobs; + if (worker?.activeJobId) analysisWorker.activeJobId = worker.activeJobId; + if (worker) analysisWorker.lastSeenAt = worker.lastSeenAt.toISOString(); + checks.push({ + id: "analysis-worker", + label: "Repository analysis worker", + status: workerFresh ? "pass" : "warn", + detail: workerFresh ? "The bounded repository-analysis worker is reporting a fresh heartbeat." : "The analysis worker heartbeat is missing or stale; synchronous API and MCP reads remain available.", + value: queuedJobs, + }); + } catch { + checks.push({ + id: "analysis-worker", + label: "Repository analysis worker", + status: "skip", + detail: "Analysis-worker tables are not available yet; migrations may still be applying.", + }); + } + } catch (error) { + database.error = toSafeError(error); + checks.push({ + id: "database-connection", + label: "Database connection", + status: "fail", + detail: database.error, + }); + } + } + + const hasFailure = checks.some((check) => check.status === "fail"); + const hasWarning = checks.some((check) => check.status === "warn"); + const status: SystemReadinessResponse["status"] = hasFailure ? "blocked" : hasWarning ? "degraded" : "ready"; + + const data: SystemReadinessResponse = { + status, + service: "projectbrain-api", + dataSource: options.env.PROJECTBRAIN_DATA_SOURCE, + environment: options.env.NODE_ENV, + database, + checks, + analysisWorker, + autonomyExecutor, + generatedAt: new Date().toISOString(), + }; + + return successEnvelope(data, getRequestId(request)); + }); +}; diff --git a/backend/src/security/authStore.ts b/backend/src/security/authStore.ts new file mode 100644 index 0000000..efcfcc4 --- /dev/null +++ b/backend/src/security/authStore.ts @@ -0,0 +1,111 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { hashPassword, verifyPassword } from "./password.js"; + +export type AuthUser = { id: string; username: string; role: "owner" | "member"; credentialVersion: number }; +export type CreatedSession = { token: string; csrfToken: string; expiresAt: Date; user: AuthUser }; + +type MemoryUser = AuthUser & { passwordHash: string; disabledAt?: Date }; +type MemorySession = { tokenHash: string; csrfToken: string; userId: string; credentialVersion: number; expiresAt: Date; revokedAt?: Date }; +const memoryUsers = new Map(); +const memorySessions = new Map(); + +const tokenHash = (token: string, secret: string) => createHash("sha256").update(`${secret}:${token}`).digest("hex"); +const publicUser = (user: AuthUser): AuthUser => ({ id: user.id, username: user.username, role: user.role, credentialVersion: user.credentialVersion }); + +export const initializeOwner = async (env: AppEnv): Promise => { + if (env.PROJECTBRAIN_DATA_SOURCE === "database") { + const existing = await prisma.user.findUnique({ where: { username: env.PROJECTBRAIN_OWNER_USERNAME } }); + if (!existing) { + try { + await prisma.user.create({ data: { username: env.PROJECTBRAIN_OWNER_USERNAME, passwordHash: await hashPassword(env.PROJECTBRAIN_OWNER_PASSWORD), role: "owner" } }); + } catch (error) { + if (!(typeof error === "object" && error !== null && "code" in error && error.code === "P2002")) throw error; + } + } + return; + } + memoryUsers.clear(); + memorySessions.clear(); + memoryUsers.set(env.PROJECTBRAIN_OWNER_USERNAME, { id: `fixture-owner-${randomUUID()}`, username: env.PROJECTBRAIN_OWNER_USERNAME, passwordHash: await hashPassword(env.PROJECTBRAIN_OWNER_PASSWORD), role: "owner", credentialVersion: 1 }); +}; + +export const authenticate = async (env: AppEnv, username: string, password: string): Promise => { + if (env.PROJECTBRAIN_DATA_SOURCE === "database") { + const user = await prisma.user.findUnique({ where: { username } }); + if (!user || user.disabledAt || !(await verifyPassword(password, user.passwordHash))) return undefined; + await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); + return publicUser(user); + } + const user = memoryUsers.get(username); + return user && !user.disabledAt && await verifyPassword(password, user.passwordHash) ? publicUser(user) : undefined; +}; + +export const createSession = async (env: AppEnv, user: AuthUser): Promise => { + const token = randomBytes(32).toString("base64url"); + const csrfToken = randomBytes(24).toString("base64url"); + const expiresAt = new Date(Date.now() + env.PROJECTBRAIN_SESSION_TTL_HOURS * 60 * 60 * 1000); + const hash = tokenHash(token, env.PROJECTBRAIN_AUTH_SECRET); + if (env.PROJECTBRAIN_DATA_SOURCE === "database") { + await prisma.authSession.create({ data: { tokenHash: hash, csrfToken, userId: user.id, credentialVersion: user.credentialVersion, expiresAt } }); + } else { + memorySessions.set(hash, { tokenHash: hash, csrfToken, userId: user.id, credentialVersion: user.credentialVersion, expiresAt }); + } + return { token, csrfToken, expiresAt, user }; +}; + +export const resolveSession = async (env: AppEnv, token: string): Promise<{ user: AuthUser; csrfToken: string } | undefined> => { + const hash = tokenHash(token, env.PROJECTBRAIN_AUTH_SECRET); + if (env.PROJECTBRAIN_DATA_SOURCE === "database") { + const session = await prisma.authSession.findUnique({ where: { tokenHash: hash }, include: { user: true } }); + if (!session || session.revokedAt || session.expiresAt <= new Date() || session.user.disabledAt || session.credentialVersion !== session.user.credentialVersion) return undefined; + const now = new Date(); + await prisma.authSession.update({ + where: { id: session.id }, + data: { lastSeenAt: now, expiresAt: new Date(now.getTime() + env.PROJECTBRAIN_SESSION_TTL_HOURS * 60 * 60 * 1000) }, + }); + return { user: publicUser(session.user), csrfToken: session.csrfToken }; + } + const session = memorySessions.get(hash); + const user = session ? [...memoryUsers.values()].find((item) => item.id === session.userId) : undefined; + if (!session || !user || session.revokedAt || session.expiresAt <= new Date() || session.credentialVersion !== user.credentialVersion) return undefined; + session.expiresAt = new Date(Date.now() + env.PROJECTBRAIN_SESSION_TTL_HOURS * 60 * 60 * 1000); + return { user: publicUser(user), csrfToken: session.csrfToken }; +}; + +export const revokeSession = async (env: AppEnv, token: string): Promise => { + const hash = tokenHash(token, env.PROJECTBRAIN_AUTH_SECRET); + if (env.PROJECTBRAIN_DATA_SOURCE === "database") { + await prisma.authSession.updateMany({ where: { tokenHash: hash, revokedAt: null }, data: { revokedAt: new Date() } }); + } else { + const session = memorySessions.get(hash); + if (session) session.revokedAt = new Date(); + } +}; + +export const rotatePassword = async (env: AppEnv, user: AuthUser, currentPassword: string, newPassword: string): Promise => { + const verified = await authenticate(env, user.username, currentPassword); + if (!verified) return false; + const passwordHash = await hashPassword(newPassword); + if (env.PROJECTBRAIN_DATA_SOURCE === "database") { + await prisma.$transaction(async (tx) => { + await tx.user.update({ where: { id: user.id }, data: { passwordHash, credentialVersion: { increment: 1 } } }); + await tx.authSession.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } }); + }); + } else { + const stored = memoryUsers.get(user.username); + if (!stored) return false; + stored.passwordHash = passwordHash; + stored.credentialVersion += 1; + for (const session of memorySessions.values()) if (session.userId === user.id) session.revokedAt = new Date(); + } + return true; +}; + +export const canAccessProject = async (env: AppEnv, user: AuthUser, projectId: string, write = false): Promise => { + if (user.role === "owner") return true; + if (env.PROJECTBRAIN_DATA_SOURCE !== "database") return false; + const membership = await prisma.projectMembership.findFirst({ where: { userId: user.id, project: { OR: [{ id: projectId }, { slug: projectId }] } } }); + return Boolean(membership && (!write || membership.role !== "reader")); +}; diff --git a/backend/src/security/entraOidc.ts b/backend/src/security/entraOidc.ts new file mode 100644 index 0000000..212cdc0 --- /dev/null +++ b/backend/src/security/entraOidc.ts @@ -0,0 +1,173 @@ +import { createHash, randomBytes } from "node:crypto"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import type { AuthUser } from "./authStore.js"; + +const PROVIDER = "microsoft-entra"; +const ATTEMPT_TTL_MS = 10 * 60 * 1000; +const remoteKeySets = new Map>(); + +export type EntraProfile = { + tenantId: string; + subject: string; + displayName?: string; + preferredUsername?: string; +}; + +export type EntraAttempt = { + purpose: "login" | "link"; + userId?: string; + codeVerifier: string; + nonce: string; + returnTo: string; +}; + +export const isEntraConfigured = (env: AppEnv) => Boolean( + env.PROJECTBRAIN_ENTRA_TENANT_ID + && env.PROJECTBRAIN_ENTRA_CLIENT_ID + && env.PROJECTBRAIN_ENTRA_CLIENT_SECRET + && env.PROJECTBRAIN_PUBLIC_URL, +); + +const requireConfiguration = (env: AppEnv) => { + if (!isEntraConfigured(env)) throw new Error("ENTRA_NOT_CONFIGURED"); + return { + tenantId: env.PROJECTBRAIN_ENTRA_TENANT_ID!, + clientId: env.PROJECTBRAIN_ENTRA_CLIENT_ID!, + clientSecret: env.PROJECTBRAIN_ENTRA_CLIENT_SECRET!, + redirectUri: new URL("/api/auth/entra/callback", env.PROJECTBRAIN_PUBLIC_URL!).toString(), + }; +}; + +const sha256 = (value: string) => createHash("sha256").update(value).digest(); +const stateHash = (state: string) => sha256(state).toString("hex"); +const safeReturnTo = (value?: string) => value && value.startsWith("/") && !value.startsWith("//") && !value.includes("\\") ? value : "/"; + +export const createEntraAuthorization = async ( + env: AppEnv, + input: { purpose: "login" | "link"; userId?: string; returnTo?: string }, +) => { + const config = requireConfiguration(env); + if (env.PROJECTBRAIN_DATA_SOURCE !== "database") throw new Error("ENTRA_DATABASE_REQUIRED"); + if (input.purpose === "link" && !input.userId) throw new Error("ENTRA_LINK_USER_REQUIRED"); + + const state = randomBytes(32).toString("base64url"); + const nonce = randomBytes(32).toString("base64url"); + const codeVerifier = randomBytes(64).toString("base64url"); + const codeChallenge = sha256(codeVerifier).toString("base64url"); + await prisma.$transaction([ + prisma.oidcLoginAttempt.deleteMany({ where: { expiresAt: { lte: new Date() } } }), + prisma.oidcLoginAttempt.create({ data: { + stateHash: stateHash(state), provider: PROVIDER, purpose: input.purpose, userId: input.userId, + codeVerifier, nonce, returnTo: safeReturnTo(input.returnTo), expiresAt: new Date(Date.now() + ATTEMPT_TTL_MS), + } }), + ]); + + const authorizationUrl = new URL(`https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize`); + authorizationUrl.search = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: config.redirectUri, + response_mode: "query", + scope: "openid profile email", + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + prompt: "select_account", + }).toString(); + return authorizationUrl.toString(); +}; + +export const consumeEntraAttempt = async (env: AppEnv, state: string): Promise => { + requireConfiguration(env); + const attempt = await prisma.oidcLoginAttempt.findUnique({ where: { stateHash: stateHash(state) } }); + if (!attempt) return undefined; + await prisma.oidcLoginAttempt.delete({ where: { id: attempt.id } }); + if (attempt.expiresAt <= new Date() || attempt.provider !== PROVIDER || !["login", "link"].includes(attempt.purpose)) return undefined; + return { + purpose: attempt.purpose as "login" | "link", + userId: attempt.userId ?? undefined, + codeVerifier: attempt.codeVerifier, + nonce: attempt.nonce, + returnTo: safeReturnTo(attempt.returnTo), + }; +}; + +const stringClaim = (payload: JWTPayload, key: string) => typeof payload[key] === "string" ? payload[key] as string : undefined; + +export const exchangeEntraCode = async (env: AppEnv, code: string, attempt: EntraAttempt): Promise => { + const config = requireConfiguration(env); + const tokenEndpoint = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`; + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + code_verifier: attempt.codeVerifier, + grant_type: "authorization_code", + redirect_uri: config.redirectUri, + scope: "openid profile email", + }), + signal: AbortSignal.timeout(10_000), + }); + const tokenResult = await response.json() as { id_token?: string }; + if (!response.ok || !tokenResult.id_token) throw new Error("ENTRA_TOKEN_EXCHANGE_FAILED"); + + const jwksUrl = `https://login.microsoftonline.com/${config.tenantId}/discovery/v2.0/keys`; + const keySet = remoteKeySets.get(jwksUrl) ?? createRemoteJWKSet(new URL(jwksUrl)); + remoteKeySets.set(jwksUrl, keySet); + const issuer = `https://login.microsoftonline.com/${config.tenantId}/v2.0`; + const { payload } = await jwtVerify(tokenResult.id_token, keySet, { issuer, audience: config.clientId, algorithms: ["RS256"] }); + if (payload.nonce !== attempt.nonce) throw new Error("ENTRA_NONCE_INVALID"); + const tenantId = stringClaim(payload, "tid"); + const subject = stringClaim(payload, "oid"); + if (!tenantId || tenantId.toLowerCase() !== config.tenantId.toLowerCase() || !subject) throw new Error("ENTRA_IDENTITY_INVALID"); + return { + tenantId, + subject, + displayName: stringClaim(payload, "name"), + preferredUsername: stringClaim(payload, "preferred_username"), + }; +}; + +const publicUser = (user: { id: string; username: string; role: "owner" | "member"; credentialVersion: number }): AuthUser => ({ + id: user.id, username: user.username, role: user.role, credentialVersion: user.credentialVersion, +}); + +export const resolveEntraUser = async (profile: EntraProfile): Promise => { + const identity = await prisma.externalIdentity.findUnique({ + where: { provider_tenantId_subject: { provider: PROVIDER, tenantId: profile.tenantId, subject: profile.subject } }, + include: { user: true }, + }); + if (!identity || identity.user.disabledAt) return undefined; + await prisma.$transaction([ + prisma.externalIdentity.update({ where: { id: identity.id }, data: { displayName: profile.displayName, preferredUsername: profile.preferredUsername, lastLoginAt: new Date() } }), + prisma.user.update({ where: { id: identity.userId }, data: { lastLoginAt: new Date() } }), + ]); + return publicUser(identity.user); +}; + +export const linkEntraIdentity = async (userId: string, profile: EntraProfile): Promise => { + const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } }); + const existingIdentity = await prisma.externalIdentity.findUnique({ where: { provider_tenantId_subject: { provider: PROVIDER, tenantId: profile.tenantId, subject: profile.subject } } }); + if (existingIdentity && existingIdentity.userId !== userId) throw new Error("ENTRA_IDENTITY_ALREADY_LINKED"); + await prisma.externalIdentity.upsert({ + where: { provider_userId: { provider: PROVIDER, userId } }, + create: { provider: PROVIDER, tenantId: profile.tenantId, subject: profile.subject, userId, displayName: profile.displayName, preferredUsername: profile.preferredUsername, lastLoginAt: new Date() }, + update: { tenantId: profile.tenantId, subject: profile.subject, displayName: profile.displayName, preferredUsername: profile.preferredUsername, lastLoginAt: new Date() }, + }); + return publicUser(user); +}; + +export const getEntraLinkStatus = async (userId: string) => { + const identity = await prisma.externalIdentity.findUnique({ where: { provider_userId: { provider: PROVIDER, userId } } }); + return { linked: Boolean(identity), displayName: identity?.displayName ?? undefined, preferredUsername: identity?.preferredUsername ?? undefined }; +}; + +export const unlinkEntraIdentity = async (userId: string) => { + await prisma.externalIdentity.deleteMany({ where: { provider: PROVIDER, userId } }); +}; diff --git a/backend/src/security/mcpOAuth.ts b/backend/src/security/mcpOAuth.ts new file mode 100644 index 0000000..6165950 --- /dev/null +++ b/backend/src/security/mcpOAuth.ts @@ -0,0 +1,82 @@ +import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import type { AppEnv } from "../config/env.js"; + +type SignedPayload = Record & { kind: string; exp: number; iat: number; jti: string }; +type OAuthClient = SignedPayload & { kind: "client"; redirectUris: string[]; clientName: string }; +type AuthorizationCode = SignedPayload & { kind: "code"; clientId: string; redirectUri: string; codeChallenge: string; scope: string; subject: string; credentialVersion: number; audience: string }; +type AccessToken = SignedPayload & { kind: "access"; clientId: string; scope: string; subject: string; credentialVersion: number; audience: string }; +type RefreshToken = SignedPayload & { kind: "refresh"; clientId: string; scope: string; subject: string; credentialVersion: number; audience: string }; + +const encode = (value: unknown) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +const signPart = (part: string, secret: string) => createHmac("sha256", secret).update(part).digest("base64url"); + +const issue = (payload: Omit, secret: string): string => { + const body = encode({ ...payload, iat: Math.floor(Date.now() / 1000), jti: randomUUID() }); + return `${body}.${signPart(body, secret)}`; +}; + +const read = (token: string, secret: string, kind: T["kind"]): T => { + const [body, signature, extra] = token.split("."); + if (!body || !signature || extra) throw new Error("OAUTH_TOKEN_INVALID"); + const expected = Buffer.from(signPart(body, secret)); + const provided = Buffer.from(signature); + if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) throw new Error("OAUTH_TOKEN_INVALID"); + const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T; + if (payload.kind !== kind || !Number.isFinite(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) throw new Error("OAUTH_TOKEN_EXPIRED"); + return payload; +}; + +const secondsFromNow = (seconds: number) => Math.floor(Date.now() / 1000) + seconds; +const consumedAuthorizationCodes = new Map(); +const isAllowedRedirect = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === "https:" || (url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname)); + } catch { return false; } +}; + +export const registerOAuthClient = (env: AppEnv, input: { redirectUris: string[]; clientName?: string }) => { + const redirectUris = [...new Set(input.redirectUris)].filter(isAllowedRedirect); + if (!redirectUris.length || redirectUris.length !== input.redirectUris.length) throw new Error("OAUTH_REDIRECT_INVALID"); + const clientId = issue({ kind: "client", exp: secondsFromNow(365 * 24 * 60 * 60), redirectUris, clientName: input.clientName?.slice(0, 120) || "ChatGPT" }, env.PROJECTBRAIN_AUTH_SECRET); + return { clientId, redirectUris }; +}; + +export const verifyOAuthClient = (env: AppEnv, clientId: string, redirectUri: string): OAuthClient => { + const client = read(clientId, env.PROJECTBRAIN_AUTH_SECRET, "client"); + if (!client.redirectUris.includes(redirectUri)) throw new Error("OAUTH_REDIRECT_INVALID"); + return client; +}; + +export const issueAuthorizationCode = (env: AppEnv, input: Omit): string => + issue({ ...input, kind: "code", exp: secondsFromNow(5 * 60) }, env.PROJECTBRAIN_AUTH_SECRET); + +export const exchangeAuthorizationCode = (env: AppEnv, input: { code: string; clientId: string; redirectUri: string; codeVerifier: string }) => { + const code = read(input.code, env.PROJECTBRAIN_AUTH_SECRET, "code"); + for (const [jti, expiry] of consumedAuthorizationCodes) if (expiry <= Math.floor(Date.now() / 1000)) consumedAuthorizationCodes.delete(jti); + if (consumedAuthorizationCodes.has(code.jti)) throw new Error("OAUTH_CODE_ALREADY_USED"); + if (code.clientId !== input.clientId || code.redirectUri !== input.redirectUri) throw new Error("OAUTH_CODE_INVALID"); + const challenge = createHash("sha256").update(input.codeVerifier).digest("base64url"); + if (challenge !== code.codeChallenge) throw new Error("OAUTH_PKCE_INVALID"); + consumedAuthorizationCodes.set(code.jti, code.exp); + return issueTokenPair(env, code); +}; + +const issueTokenPair = (env: AppEnv, input: { clientId: string; scope: string; subject: string; credentialVersion: number; audience: string }) => ({ + accessToken: issue({ ...input, kind: "access", exp: secondsFromNow(60 * 60) }, env.PROJECTBRAIN_AUTH_SECRET), + refreshToken: issue({ ...input, kind: "refresh", exp: secondsFromNow(30 * 24 * 60 * 60) }, env.PROJECTBRAIN_AUTH_SECRET), + expiresIn: 3600, +}); + +export const exchangeRefreshToken = (env: AppEnv, token: string, clientId: string) => { + const refresh = read(token, env.PROJECTBRAIN_AUTH_SECRET, "refresh"); + if (refresh.clientId !== clientId) throw new Error("OAUTH_CLIENT_INVALID"); + return issueTokenPair(env, refresh); +}; + +export const verifyMcpAccessToken = (env: AppEnv, token: string): AccessToken => { + const access = read(token, env.PROJECTBRAIN_AUTH_SECRET, "access"); + const expectedAudience = `${env.PROJECTBRAIN_PUBLIC_URL?.replace(/\/$/, "")}/mcp`; + if (!env.PROJECTBRAIN_MCP_ENABLED || access.audience !== expectedAudience || !access.scope.split(" ").includes("mcp:tools")) throw new Error("MCP_TOKEN_INVALID"); + return access; +}; diff --git a/backend/src/security/mcpServiceAuth.ts b/backend/src/security/mcpServiceAuth.ts new file mode 100644 index 0000000..0fc87c2 --- /dev/null +++ b/backend/src/security/mcpServiceAuth.ts @@ -0,0 +1,14 @@ +import { timingSafeEqual } from "node:crypto"; + +export const isMcpHubServiceToken = (configured: string | undefined, presented: string | undefined) => { + if (!configured || !presented) return false; + const expected = Buffer.from(configured, "utf8"); + const actual = Buffer.from(presented, "utf8"); + return expected.length === actual.length && timingSafeEqual(expected, actual); +}; + +export const mcpHubActor = (clientId: string | string[] | undefined) => { + const value = Array.isArray(clientId) ? clientId[0] : clientId; + const normalized = value?.trim().toLowerCase(); + return normalized && /^[a-z0-9][a-z0-9._:-]{0,79}$/.test(normalized) ? `mcp-hub:${normalized}` : "mcp-hub:service"; +}; diff --git a/backend/src/security/password.ts b/backend/src/security/password.ts new file mode 100644 index 0000000..48bc14d --- /dev/null +++ b/backend/src/security/password.ts @@ -0,0 +1,23 @@ +import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto"; +import { promisify } from "node:util"; + +const scrypt = promisify(scryptCallback); +const KEY_LENGTH = 64; + +export const hashPassword = async (password: string): Promise => { + const salt = randomBytes(16); + const derived = await scrypt(password, salt, KEY_LENGTH) as Buffer; + return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`; +}; + +export const verifyPassword = async (password: string, encoded: string): Promise => { + const [algorithm, saltValue, hashValue] = encoded.split("$"); + if (algorithm !== "scrypt" || !saltValue || !hashValue) return false; + try { + const expected = Buffer.from(hashValue, "base64url"); + const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length) as Buffer; + return actual.length === expected.length && timingSafeEqual(actual, expected); + } catch { + return false; + } +}; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..0efe7b6 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,30 @@ +import { buildApp } from "./app.js"; +import { loadEnv } from "./config/env.js"; + +const env = loadEnv(); +const app = await buildApp({ env }); + +const shutdown = async (signal: string) => { + app.log.info({ signal }, "Shutting down ProjectBrain API"); + await app.close(); + process.exit(0); +}; + +process.on("SIGINT", () => { + void shutdown("SIGINT"); +}); + +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); + +try { + await app.listen({ host: env.HOST, port: env.PORT }); + app.log.info( + { host: env.HOST, port: env.PORT }, + "ProjectBrain API listening", + ); +} catch (error) { + app.log.error(error, "ProjectBrain API failed to start"); + process.exit(1); +} diff --git a/backend/src/services/activityRuntime.ts b/backend/src/services/activityRuntime.ts new file mode 100644 index 0000000..381e74e --- /dev/null +++ b/backend/src/services/activityRuntime.ts @@ -0,0 +1,248 @@ +import { createHash } from "node:crypto"; +import type { AppEnv } from "../config/env.js"; +import { getGiteaClient } from "../integrations/gitea/service.js"; +import type { GiteaActionJob, GiteaActionRunner } from "../integrations/gitea/types.js"; + +const RUNNING_JOB_STATUSES = new Set(["in_progress", "running"]); +const RUNNABLE_JOB_STATUSES = new Set(["queued"]); +const DEPENDENCY_BLOCKED_JOB_STATUSES = new Set(["pending", "waiting"]); +const ACTIVE_JOB_STATUSES = new Set([...RUNNING_JOB_STATUSES, ...RUNNABLE_JOB_STATUSES, ...DEPENDENCY_BLOCKED_JOB_STATUSES]); +const ZERO_DATE_PREFIX = "1970-01-01"; +const FRESH_CACHE_MS = 15_000; +const ERROR_CACHE_MS = 30_000; + +export type CompactRunner = { + id: number; + name: string; + status: string; + busy: boolean; + disabled: boolean; + labels: string[]; +}; + +export type CompactRunnerJob = { + id: number; + runId: number; + name: string; + repository?: string; + branch?: string; + status: string; + queueState: "running" | "runnable" | "dependency-blocked" | "completed"; + conclusion?: string; + runnerId?: number; + runnerName?: string; + labels: string[]; + currentStep?: string; + completedSteps: number; + totalSteps: number; + url: string; + createdAt: string; + startedAt?: string; + completedAt?: string; +}; + +export type GiteaRuntimeSnapshot = { + availability: "live" | "stale" | "unavailable"; + runners: CompactRunner[]; + jobs: CompactRunnerJob[]; + counts: { + running: number; + runnable: number; + dependencyBlocked: number; + }; + generatedAt: string; + stateVersion: string; + warning?: string; +}; + +export const prioritizeRuntimeJobs = >(jobs: T[]): T[] => { + const rank = { running: 0, runnable: 1, "dependency-blocked": 2, completed: 3 }; + return [...jobs].sort((a, b) => rank[a.queueState] - rank[b.queueState] || b.id - a.id); +}; + +type RuntimeClient = Pick, "listUserActionRunners" | "listUserActionJobs">; + +const validDate = (value?: string) => value && !value.startsWith(ZERO_DATE_PREFIX) ? value : undefined; + +const queueStateFor = (status: string): CompactRunnerJob["queueState"] => { + if (RUNNING_JOB_STATUSES.has(status)) return "running"; + if (RUNNABLE_JOB_STATUSES.has(status)) return "runnable"; + if (DEPENDENCY_BLOCKED_JOB_STATUSES.has(status)) return "dependency-blocked"; + return "completed"; +}; + +export const repositoryFromActionJob = (job: Pick): string | undefined => { + try { + const segments = new URL(job.html_url).pathname.split("/").filter(Boolean); + const actionsIndex = segments.lastIndexOf("actions"); + if (actionsIndex < 2) return undefined; + return `${decodeURIComponent(segments[actionsIndex - 2])}/${decodeURIComponent(segments[actionsIndex - 1])}`; + } catch { + return undefined; + } +}; + +const compactRunner = (runner: GiteaActionRunner): CompactRunner => ({ + id: runner.id, + name: runner.name, + status: runner.status, + busy: runner.busy, + disabled: runner.disabled, + labels: runner.labels.map((label) => label.name).filter(Boolean), +}); + +const compactJob = (job: GiteaActionJob): CompactRunnerJob => { + const currentStep = job.steps.find((step) => ACTIVE_JOB_STATUSES.has(step.status)) + ?? [...job.steps].reverse().find((step) => step.conclusion === "failure") + ?? [...job.steps].reverse().find((step) => step.status === "completed"); + return { + id: job.id, + runId: job.run_id, + name: job.name, + repository: repositoryFromActionJob(job), + branch: job.head_branch, + status: job.status, + queueState: queueStateFor(job.status), + conclusion: job.conclusion, + runnerId: job.runner_id || undefined, + runnerName: job.runner_name, + labels: job.labels, + currentStep: currentStep?.name, + completedSteps: job.steps.filter((step) => step.status === "completed").length, + totalSteps: job.steps.length, + url: job.html_url, + createdAt: job.created_at, + startedAt: validDate(job.started_at), + completedAt: validDate(job.completed_at), + }; +}; + +export const compactGiteaRuntime = ( + runners: GiteaActionRunner[], + jobs: GiteaActionJob[], + generatedAt: string, + exactCounts?: GiteaRuntimeSnapshot["counts"], +): GiteaRuntimeSnapshot => { + const allCompactJobs = jobs.map(compactJob); + const compactJobs = [ + ...prioritizeRuntimeJobs(allCompactJobs.filter((job) => job.queueState !== "completed")), + ...allCompactJobs + .filter((job) => job.queueState === "completed") + .sort((left, right) => right.id - left.id) + .slice(0, 10), + ]; + const runnerMap = new Map(runners.map((runner) => [runner.id, compactRunner(runner)])); + for (const job of compactJobs) { + if (!job.runnerId || !job.runnerName || runnerMap.has(job.runnerId) || !ACTIVE_JOB_STATUSES.has(job.status)) continue; + runnerMap.set(job.runnerId, { + id: job.runnerId, + name: job.runnerName, + status: "observed", + busy: true, + disabled: false, + labels: job.labels, + }); + } + const compact = { + runners: [...runnerMap.values()].sort((left, right) => left.name.localeCompare(right.name)), + jobs: compactJobs, + counts: exactCounts ?? { + running: compactJobs.filter((job) => job.queueState === "running").length, + runnable: compactJobs.filter((job) => job.queueState === "runnable").length, + dependencyBlocked: compactJobs.filter((job) => job.queueState === "dependency-blocked").length, + }, + }; + const stateVersion = createHash("sha256").update(JSON.stringify(compact)).digest("hex").slice(0, 24); + return { availability: "live", ...compact, generatedAt, stateVersion }; +}; + +export const createGiteaActivityRuntimeLoader = ( + env: AppEnv, + dependencies: { client?: RuntimeClient; now?: () => Date } = {}, +) => { + const client = dependencies.client ?? (env.PROJECTBRAIN_GITEA_URL && env.PROJECTBRAIN_GITEA_TOKEN ? getGiteaClient(env) : undefined); + const now = dependencies.now ?? (() => new Date()); + let cached: GiteaRuntimeSnapshot | undefined; + let cachedAt = 0; + let inFlight: Promise | undefined; + + return async (): Promise => { + const checkedAt = now(); + const cacheLifetime = cached?.availability === "live" ? FRESH_CACHE_MS : ERROR_CACHE_MS; + if (cached && checkedAt.getTime() - cachedAt < cacheLifetime) return cached; + if (!client) { + cached = { + availability: "unavailable", + runners: [], + jobs: [], + counts: { running: 0, runnable: 0, dependencyBlocked: 0 }, + generatedAt: checkedAt.toISOString(), + stateVersion: "gitea-runtime-unavailable", + warning: "Gitea runtime monitoring is not configured.", + }; + cachedAt = checkedAt.getTime(); + return cached; + } + if (inFlight) return inFlight; + + inFlight = Promise.allSettled([ + client.listUserActionRunners(), + client.listUserActionJobs({ status: "queued", limit: 50 }), + client.listUserActionJobs({ status: "pending", limit: 50 }), + client.listUserActionJobs({ status: "in_progress", limit: 50 }), + client.listUserActionJobs({ limit: 30 }), + ]) + .then(([runnerResult, runnableResult, dependencyResult, runningResult, recentResult]) => { + const jobResults = [runnableResult, dependencyResult, runningResult, recentResult]; + if (jobResults.every((result) => result.status === "rejected")) { + const reason = jobResults.find((result) => result.status === "rejected")?.reason; + throw reason instanceof Error ? reason : new Error("All Gitea job queries failed."); + } + const runners = runnerResult.status === "fulfilled" ? runnerResult.value.runners : []; + const runnableResponse = runnableResult.status === "fulfilled" ? runnableResult.value : undefined; + const dependencyResponse = dependencyResult.status === "fulfilled" ? dependencyResult.value : undefined; + const runningResponse = runningResult.status === "fulfilled" ? runningResult.value : undefined; + const recentResponse = recentResult.status === "fulfilled" ? recentResult.value : undefined; + const jobs = [...new Map([ + ...(runningResponse?.jobs ?? []), + ...(runnableResponse?.jobs ?? []), + ...(dependencyResponse?.jobs ?? []), + ...(recentResponse?.jobs ?? []), + ].map((job) => [job.id, job])).values()]; + const derived = compactGiteaRuntime([], jobs, checkedAt.toISOString()).counts; + const snapshot = compactGiteaRuntime(runners, jobs, checkedAt.toISOString(), { + running: runningResponse?.total_count ?? derived.running, + runnable: runnableResponse?.total_count ?? derived.runnable, + dependencyBlocked: dependencyResponse?.total_count ?? derived.dependencyBlocked, + }); + const warnings = [runnerResult, ...jobResults] + .filter((result) => result.status === "rejected") + .map((result) => result.status === "rejected" + ? result.reason instanceof Error ? result.reason.message : String(result.reason) + : ""); + cached = warnings.length ? { ...snapshot, warning: `Partial Gitea monitoring failure: ${[...new Set(warnings)].join("; ")}` } : snapshot; + cachedAt = checkedAt.getTime(); + return cached; + }) + .catch((error: unknown) => { + const warning = error instanceof Error ? error.message : "Gitea runtime monitoring failed."; + cached = cached?.runners.length || cached?.jobs.length + ? { ...cached, availability: "stale", warning } + : { + availability: "unavailable", + runners: [], + jobs: [], + counts: { running: 0, runnable: 0, dependencyBlocked: 0 }, + generatedAt: checkedAt.toISOString(), + stateVersion: createHash("sha256").update(warning).digest("hex").slice(0, 24), + warning, + }; + cachedAt = checkedAt.getTime(); + return cached; + }) + .finally(() => { inFlight = undefined; }); + return inFlight; + }; +}; + +export const isActiveRunnerJob = (status: string) => ACTIVE_JOB_STATUSES.has(status); diff --git a/backend/src/services/analysisStrategy.ts b/backend/src/services/analysisStrategy.ts new file mode 100644 index 0000000..2d30e88 --- /dev/null +++ b/backend/src/services/analysisStrategy.ts @@ -0,0 +1,39 @@ +export type AnalysisStrategy = "diff" | "targeted" | "full"; + +export type AnalysisStrategyInput = { + changedPaths?: string[]; + lastFullScanAt?: string; + activeFindingCount?: number; + now?: number; + staleAfterMs?: number; + budgetItems?: number; +}; + +export type AnalysisPlan = { strategy: AnalysisStrategy; reason: string; estimatedScope: "changed-area" | "repository" | "portfolio"; budgetItems: number }; + +export const chooseAnalysisStrategy = (input: AnalysisStrategyInput): AnalysisPlan => { + const changedPaths = [...new Set((input.changedPaths ?? []).map((path) => path.trim()).filter(Boolean))]; + const now = input.now ?? Date.now(); + const staleAfterMs = input.staleAfterMs ?? 7 * 24 * 60 * 60 * 1000; + const lastFull = input.lastFullScanAt ? Date.parse(input.lastFullScanAt) : Number.NaN; + const fullScanStale = !Number.isFinite(lastFull) || now - lastFull > staleAfterMs; + const budgetItems = Math.max(1, input.budgetItems ?? (changedPaths.length ? Math.min(changedPaths.length, 40) : 50)); + if (changedPaths.length && changedPaths.length <= 80) return { strategy: "diff", reason: `Analyseer ${changedPaths.length} gewijzigde paden eerst; breid alleen uit als findings daarom vragen.`, estimatedScope: "changed-area", budgetItems }; + if (!fullScanStale && (input.activeFindingCount ?? 0) > 0) return { strategy: "targeted", reason: "De volledige scan is recent; richt de volgende run op bekende risicogebieden en hun regressies.", estimatedScope: "repository", budgetItems }; + if (!fullScanStale) return { strategy: "targeted", reason: "De volledige scan is recent; voer een begrensde vervolgscan uit voordat opnieuw alles wordt gelezen.", estimatedScope: "repository", budgetItems }; + return { strategy: "full", reason: "Er is geen recente volledige scan beschikbaar; verifieer de standaardbranch opnieuw.", estimatedScope: "portfolio", budgetItems }; +}; + +export const findingFingerprint = (input: { ruleCode: string; path?: string; subject?: string; message?: string }) => { + const normalize = (value: string | undefined) => (value ?? "").toLowerCase().replace(/\\/g, "/").replace(/\d+/g, "#").replace(/\s+/g, " ").trim(); + return [normalize(input.ruleCode), normalize(input.path), normalize(input.subject), normalize(input.message)].filter(Boolean).join("|"); +}; + +export const groupFindingRootCauses = (findings: Array<{ fingerprint: string; ruleCode?: string; subject?: string; rootCause?: string }>) => { + const groups = new Map(); + for (const finding of findings) { + const key = finding.rootCause?.trim() || `${finding.ruleCode ?? "unknown"}:${(finding.subject ?? finding.fingerprint).split(":")[0]}`; + groups.set(key, [...(groups.get(key) ?? []), finding.fingerprint]); + } + return [...groups.entries()].map(([rootCause, fingerprints]) => ({ rootCause, fingerprints: [...new Set(fingerprints)] })); +}; diff --git a/backend/src/services/attention.ts b/backend/src/services/attention.ts new file mode 100644 index 0000000..9c35085 --- /dev/null +++ b/backend/src/services/attention.ts @@ -0,0 +1,39 @@ +import { FindingSeverity, FindingStatus } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import { getProjectWorkGraph } from "./workGraph.js"; +import { getProjectKnowledge } from "./projectKnowledge.js"; +import { getAutonomyState } from "../repositories/autonomyRepository.js"; +import { humanAttentionReviewWhere } from "../repositories/reviewAttentionPolicy.js"; +import { summarizeHumanBlocker } from "@projectbrain/contracts"; + +export type AttentionItem = { + id: string; + rootCauseKey: string; + kind: "decision" | "approval" | "conflict" | "escalated_recovery" | "capability" | "acceptance"; + title: string; + summary: string; + severity: "info" | "warning" | "critical"; + route: string; + actions: Array<"resolve" | "choose" | "approve" | "reject" | "defer" | "inspect">; + createdAt: string; +}; + +const dedupe = (items: AttentionItem[]) => Array.from(new Map(items.map((item) => [item.rootCauseKey, item])).values()).sort((left, right) => (right.severity === "critical" ? 3 : right.severity === "warning" ? 2 : 1) - (left.severity === "critical" ? 3 : left.severity === "warning" ? 2 : 1) || right.createdAt.localeCompare(left.createdAt)); + +export const getProjectAttention = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, select: { id: true, slug: true, reviewQueueItems: { where: humanAttentionReviewWhere, select: { id: true, title: true, description: true, priority: true, createdAt: true, type: true } }, deliveryPlans: { where: { status: "awaiting_approval", approvalState: "pending" }, select: { id: true, title: true, updatedAt: true } }, repositoryFindings: { where: { status: { in: [FindingStatus.new, FindingStatus.regressed] }, severity: FindingSeverity.critical }, orderBy: [{ riskScore: "desc" }, { updatedAt: "asc" }], take: 20, select: { id: true, fingerprint: true, title: true, message: true, status: true, updatedAt: true } } } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + const [graph, knowledge, autonomy] = await Promise.all([getProjectWorkGraph(project.id), getProjectKnowledge(project.id), getAutonomyState(project.id)]); + const items: AttentionItem[] = [ + ...project.reviewQueueItems.map((item) => ({ id: `review:${item.id}`, rootCauseKey: `review:${item.type}:${item.title.toLowerCase()}`, kind: "decision" as const, title: item.title, summary: item.description, severity: item.priority === "critical" ? "critical" as const : item.priority === "high" ? "warning" as const : "info" as const, route: `/projects/${project.id}/review-queue?reviewItemId=${encodeURIComponent(item.id)}`, actions: ["choose", "approve", "reject", "defer", "inspect"] as AttentionItem["actions"], createdAt: item.createdAt.toISOString() })), + ...project.deliveryPlans.map((delivery) => ({ id: `approval:${delivery.id}`, rootCauseKey: `approval:${delivery.id}`, kind: "approval" as const, title: `Approve ${delivery.title}`, summary: "This delivery is ready but needs an explicit approval before repository work starts.", severity: "warning" as const, route: `/projects/${project.id}/initiatives`, actions: ["approve", "reject", "defer", "inspect"] as AttentionItem["actions"], createdAt: delivery.updatedAt.toISOString() })), + // Delivery approvals and review items already have a dedicated human item. + // Excluding their projected graph nodes prevents one cause appearing twice. + ...graph.blockers.filter((node) => !["attention", "delivery"].includes(node.source) && (node.executionEligibility === "needs_user" || node.blockReason?.toLowerCase().includes("escalat"))).map((node) => ({ id: `work:${node.id}`, rootCauseKey: `work:${node.id}`, kind: "escalated_recovery" as const, title: node.title, summary: summarizeHumanBlocker(node.blockReason), severity: "critical" as const, route: `/projects/${project.id}/attention`, actions: ["resolve", "defer", "inspect"] as AttentionItem["actions"], createdAt: node.updatedAt })), + ...project.repositoryFindings.map((finding) => ({ id: `finding:${finding.id}`, rootCauseKey: `finding:${finding.fingerprint}`, kind: "acceptance" as const, title: finding.title, summary: `${finding.status === FindingStatus.regressed ? "A previously resolved critical finding has returned" : "A critical repository finding needs triage"}: ${finding.message}`, severity: "critical" as const, route: `/projects/${project.id}/findings?finding=${encodeURIComponent(finding.id)}`, actions: ["choose", "defer", "inspect"] as AttentionItem["actions"], createdAt: finding.updatedAt.toISOString() })), + ...(autonomy.blocker?.kind === "human" ? [{ id: "autonomy:human-blocker", rootCauseKey: "autonomy:human-blocker", kind: "capability" as const, title: "Auto Mode wacht op jouw actie", summary: summarizeHumanBlocker(autonomy.blocker.reason), severity: "critical" as const, route: `/projects/${project.id}/auto`, actions: ["resolve", "choose", "inspect"] as AttentionItem["actions"], createdAt: autonomy.blocker.at } satisfies AttentionItem] : []), + ...knowledge.conflicts.filter((conflict) => conflict.status === "attention").map((conflict) => ({ id: conflict.id, rootCauseKey: conflict.id, kind: "conflict" as const, title: "Project truth conflict", summary: conflict.summary, severity: "warning" as const, route: `/projects/${project.slug}/knowledge?conflict=${encodeURIComponent(conflict.id)}`, actions: ["choose", "resolve", "defer", "inspect"] as AttentionItem["actions"], createdAt: knowledge.generatedAt })), + ]; + const deduped = dedupe(items); + return { projectId: project.id, generatedAt: new Date().toISOString(), items: deduped, total: deduped.length, summary: deduped.length ? `${deduped.length} actionable human item(s) remain.` : "No human action is currently required." }; +}; diff --git a/backend/src/services/autonomyAssignments.ts b/backend/src/services/autonomyAssignments.ts new file mode 100644 index 0000000..b651abe --- /dev/null +++ b/backend/src/services/autonomyAssignments.ts @@ -0,0 +1,28 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import { enqueueAutonomyTaskInState, getAutonomyProject, getAutonomyState, saveAutonomyState, type EnqueueAutonomyTaskInput } from "../repositories/autonomyRepository.js"; + +const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; + +export const enqueueAutonomyTask = async (projectIdOrSlug: string, input: EnqueueAutonomyTaskInput, actor = "owner") => { + const project = await getAutonomyProject(projectIdOrSlug); + const current = await getAutonomyState(project.id); + const queued = enqueueAutonomyTaskInState(current, input, actor); + const state = await saveAutonomyState(project, queued.state, actor); + await prisma.operatingLogEntry.create({ + data: { + projectId: project.id, + slug: `log-autonomy-task-${queued.task.id}`, + readableId: `log-autonomy-task-${queued.task.id}`, + type: "assignment queued", + title: `ProjectBrain-opdracht: ${queued.task.title}`, + summary: queued.task.instruction ?? queued.task.title, + linkedEntityIdsJson: json([queued.task.id, state.roadmap?.id].filter(Boolean)), + source: "projectbrain_workspace", + importance: "normal", + payloadJson: json({ taskId: queued.task.id, autoModeEnabled: state.enabled }), + provenanceJson: json({ createdFrom: "workspace_assignment", actor }), + }, + }); + return { state, task: queued.task, autoModeEnabled: state.enabled }; +}; diff --git a/backend/src/services/autonomyDelivery.ts b/backend/src/services/autonomyDelivery.ts new file mode 100644 index 0000000..0c14587 --- /dev/null +++ b/backend/src/services/autonomyDelivery.ts @@ -0,0 +1,23 @@ +import { getAutonomyProject, getAutonomyState, readAutonomyState, saveAutonomyState } from "../repositories/autonomyRepository.js"; +import { createFeatureDelivery, getFeatureDelivery, listFeatureDeliveries, type FeatureDeliveryInput } from "../repositories/featureDeliveryRepository.js"; + +export const createOrResumeAutonomyDelivery = async (projectId: string, input: FeatureDeliveryInput, actor: string) => { + const state = await getAutonomyState(projectId); + const lease = state.activeLease; + if (!lease || lease.kind !== "implement" || lease.actor !== actor) return createFeatureDelivery(projectId, input, actor); + const item = state.roadmap?.phases.find((phase) => phase.id === lease.phaseId)?.items.find((entry) => entry.id === lease.itemId); + if (item?.deliveryId) return getFeatureDelivery(projectId, item.deliveryId); + // Recover a create that committed before its controller binding/response did. + const existing = (await listFeatureDeliveries(projectId)).find((delivery) => delivery.title === input.title && delivery.intent === input.intent && !["failed", "merged", "verified", "released"].includes(delivery.spec.status)); + const delivery = existing ?? await createFeatureDelivery(projectId, input, actor); + const project = await getAutonomyProject(projectId); + const nextState = readAutonomyState(project, actor); + if (nextState.activeLease?.token !== lease.token || nextState.activeLease.actor !== actor) throw new Error("AUTO_MODE_LEASE_STALE"); + const phase = nextState.roadmap?.phases.find((entry) => entry.id === nextState.activeLease?.phaseId); + const boundItem = phase?.items.find((entry) => entry.id === nextState.activeLease?.itemId); + if (!boundItem) throw new Error("AUTO_MODE_ITEM_NOT_FOUND"); + if (boundItem.deliveryId && boundItem.deliveryId !== delivery.id) throw new Error("AUTO_MODE_DELIVERY_ALREADY_LINKED"); + boundItem.deliveryId = delivery.id; + await saveAutonomyState(project, nextState, actor); + return delivery; +}; diff --git a/backend/src/services/autonomyRecovery.ts b/backend/src/services/autonomyRecovery.ts new file mode 100644 index 0000000..d2921aa --- /dev/null +++ b/backend/src/services/autonomyRecovery.ts @@ -0,0 +1,74 @@ +const HUMAN_BLOCKER_PATTERNS = [ + /credential(?:s)?\s+(?:are\s+)?required/i, + /missing\s+(?:credential|secret|token)/i, + /secret\s+(?:is\s+)?required/i, + /token\s+(?:is\s+)?required/i, + /authentication\s+(?:is\s+)?required/i, + /authorization\s+(?:is\s+)?required/i, + /permission\s+(?:is\s+)?required/i, + /approval\s+(?:is\s+)?required/i, + /requires\s+(?:owner|user|human)\s+(?:approval|authorization|decision|input|choice)/i, + /owner\s+(?:decision|input|choice)\s+(?:is\s+)?required/i, + /human\s+(?:decision|input)\s+(?:is\s+)?required/i, + /product\s+decision\s+(?:is\s+)?required/i, + /scope\s+decision\s+(?:is\s+)?required/i, + /choose\s+between\s+.+\s+and\s+.+/i, + /destructive\s+(?:change|operation|action|risk)/i, + /irreversible\s+(?:change|operation|action|risk)/i, +]; + +const RETRYABLE_BLOCKER_PATTERNS = [ + /timeout/i, + /timed\s+out/i, + /temporar(?:y|ily)/i, + /transient/i, + /network/i, + /connection\s+(?:reset|refused|failed|closed)/i, + /rate\s*limit/i, + /provider/i, + /runner/i, + /ci\s+(?:failed|failure|busy|unavailable)/i, + /validation\s+(?:failed|failure|unavailable)/i, + /gitea\s+(?:failed|failure|unavailable|busy)/i, + /service\s+unavailable/i, + /bad\s+gateway/i, + /gateway\s+timeout/i, + /http\s+5\d\d/i, + /non-https/i, + /bootstrap/i, +]; + +/** + * Classify a blocker from durable evidence, not from model preference. + * + * The requested kind is kept for API compatibility and telemetry, but it may + * not override the server-side safety classifier. This prevents a model from + * turning an infrastructure problem into a human chore, while still ensuring + * that explicit credentials/authorization/destructive decisions fail closed. + */ +export const classifyAutonomyBlocker = (summary: string, _requested?: "human" | "retryable") => { + const clauses = summary.split(/[.!?;,\n]|\b(?:but|however|and|maar|en)\b/i); + const humanEvidence = clauses.some((clause) => HUMAN_BLOCKER_PATTERNS.some((pattern) => { + const match = pattern.exec(clause); + if (!match) return false; + const prefix = clause.slice(0, match.index); + return !/\b(?:no|not|without|geen|niet)\b(?!\s+only)[^;.!?]*$/i.test(prefix); + })); + if (humanEvidence) return "human" as const; + + const retryableEvidence = RETRYABLE_BLOCKER_PATTERNS.some((pattern) => pattern.test(summary)); + if (retryableEvidence) return "retryable" as const; + + // Unknown operational failures remain retryable. Human attention is a scarce + // product state and must only be created by positive human-decision evidence. + return "retryable" as const; +}; + +export const recoverStoredBlockerKind = (reason: string, storedKind: string) => + storedKind === "retryable" || (/\b(?:ci|validation|lint|dependency|sequencing|bootstrap|runner|network|provider)\b/i.test(reason) && classifyAutonomyBlocker(reason) === "retryable") + ? "retryable" as const : "human" as const; + +export const autonomyRetryDelayMinutes = (failureCount: number) => { + const step = Math.max(0, Math.min(6, failureCount - 1)); + return Math.min(1_440, 15 * (2 ** step)); +}; diff --git a/backend/src/services/autonomyReplanner.ts b/backend/src/services/autonomyReplanner.ts new file mode 100644 index 0000000..ce0521c --- /dev/null +++ b/backend/src/services/autonomyReplanner.ts @@ -0,0 +1,103 @@ +import type { AutonomyRoadmapItem, AutonomyState } from "../repositories/autonomyRepository.js"; + +export type DeliveryRealitySignal = { + itemId: string; + deliveryId: string; + status: string; + mergeCommitSha?: string; + validationEvidence?: string; + supersededBy?: string; + externalUnavailable?: boolean; + branchMissing?: boolean; +}; + +export type ReplanResult = { state: AutonomyState; changed: boolean; completedExternally: string[]; repairItems: string[]; waitingItems: string[]; reasons: string[] }; + +const terminalDelivery = new Set(["merged", "verified", "released"]); + +const recoveryItem = (item: AutonomyRoadmapItem, signal: DeliveryRealitySignal): AutonomyRoadmapItem => ({ + id: `recovery:${item.id}:${signal.deliveryId}`, + title: `Herstel en herplan: ${item.title}`, + instruction: signal.supersededBy ? `Delivery ${signal.deliveryId} is vervangen door ${signal.supersededBy}. Inspecteer de nieuwe werkelijkheid en hervat alleen wat nog ontbreekt.` : `Herstel ${item.title} op basis van de actuele repository- en deliverytoestand.`, + source: "recovery", + createdAt: new Date().toISOString(), + status: "pending", + attempts: 0, + priority: Math.min(100, (item.priority ?? 50) + 10), + scope: item.scope === "future" ? "future" : item.scope ?? "committed", + risk: item.risk, + releaseBlocking: item.releaseBlocking, + touchedPaths: item.touchedPaths, + domain: item.domain, + dependsOn: item.dependsOn, +}); + +/** Reconcile durable plan state with observed delivery truth without discarding valid work. */ +export const reconcileAutonomyPlan = (state: AutonomyState, signals: DeliveryRealitySignal[]): ReplanResult => { + const next = structuredClone(state) as AutonomyState; + const completedExternally: string[] = []; + const repairItems: string[] = []; + const waitingItems: string[] = []; + const reasons: string[] = []; + let changed = false; + for (const signal of signals) { + const item = next.roadmap?.phases.flatMap((phase) => phase.items).find((candidate) => candidate.id === signal.itemId); + if (!item || (item.status === "completed" && !signal.supersededBy)) continue; + if (signal.supersededBy) { + item.supersededBy = signal.supersededBy; + item.lastError = `Delivery ${signal.deliveryId} is superseded by ${signal.supersededBy}; the remaining objective is being replanned.`; + item.status = "completed"; + const phase = next.roadmap?.phases.find((candidate) => candidate.items.some((candidateItem) => candidateItem.id === item.id)); + if (phase && !phase.items.some((candidate) => candidate.id === `recovery:${item.id}:${signal.deliveryId}`)) { const repair = recoveryItem(item, signal); phase.items.push(repair); repairItems.push(repair.id); } + reasons.push(`${item.title} was superseded; a bounded recovery item was added.`); + changed = true; + continue; + } + const externallyComplete = terminalDelivery.has(signal.status) || Boolean(signal.mergeCommitSha && signal.validationEvidence); + if (externallyComplete) { + item.status = "completed"; + item.lastResult = `Delivery ${signal.deliveryId} was already merged and validated outside the current executor lease.`; + item.completionEvidence = [...new Set([...(item.completionEvidence ?? []), `delivery:${signal.deliveryId}`, ...(signal.mergeCommitSha ? [`merge:${signal.mergeCommitSha}`] : []), ...(signal.validationEvidence ? [signal.validationEvidence] : [])])]; + delete item.lastError; + completedExternally.push(item.id); + reasons.push(`${item.title} was preserved as completed from repository/delivery evidence.`); + changed = true; + if (next.activeLease?.itemId === item.id) delete next.activeLease; + continue; + } + if (signal.externalUnavailable) { + const message = `External dependency for ${signal.deliveryId} is unavailable; it will be reconsidered when the dependency recovers.`; + if (!item.waitingExternal || item.lastError !== message || item.status !== "pending") { + item.waitingExternal = true; + item.lastError = message; + item.status = "pending"; + changed = true; + } + waitingItems.push(item.id); + reasons.push(`${item.title} is waiting on an external dependency, not a human decision.`); + continue; + } + if (item.waitingExternal && ["draft", "awaiting_approval", "approved", "executing", "validating", "pull_request_open"].includes(signal.status)) { + item.waitingExternal = false; + delete item.lastError; + if (item.status === "blocked") item.status = "pending"; + reasons.push(`${item.title} is claimable again because the external dependency responded.`); + changed = true; + } + if (signal.branchMissing) { + item.lastError = `The delivery branch for ${signal.deliveryId} disappeared; ProjectBrain will replan on the next safe claim.`; + item.status = "pending"; + delete item.deliveryId; + if (next.activeLease?.itemId === item.id) delete next.activeLease; + reasons.push(`${item.title} will be repaired from the current repository state.`); + changed = true; + } + } + if (changed && next.roadmap) { + const remaining = next.roadmap.phases.some((phase) => phase.items.some((item) => item.status !== "completed" && item.scope !== "future" && item.scope !== "stretch")); + next.roadmap.status = remaining ? "active" : "completed"; + if (!remaining) { next.roadmap.completedAt = new Date().toISOString(); next.status = "completed"; } + else if (next.enabled) next.status = "running"; + } + return { state: next, changed, completedExternally, repairItems, waitingItems, reasons }; +}; diff --git a/backend/src/services/autonomyRoadmap.ts b/backend/src/services/autonomyRoadmap.ts new file mode 100644 index 0000000..2b6a061 --- /dev/null +++ b/backend/src/services/autonomyRoadmap.ts @@ -0,0 +1,44 @@ +import { randomUUID } from "node:crypto"; +import type { AutonomyRoadmap, AutonomyRoadmapPhase } from "../repositories/autonomyRepository.js"; +const slug = (value: string) => value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "item"; +const itemId = (phaseIndex: number, title: string, itemIndex: number) => `p${phaseIndex + 1}-${slug(title)}-${itemIndex + 1}`; +const phaseId = (phaseIndex: number, title: string) => `phase-${phaseIndex + 1}-${slug(title)}`; +const nowIso = () => new Date().toISOString(); +const parseTask = (line: string): { title: string; completed: boolean } | undefined => { + const checkbox = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.+?)\s*$/); + if (checkbox) return { title: checkbox[2].trim(), completed: checkbox[1].toLowerCase() === "x" }; + const bullet = line.match(/^\s*[-*+]\s+(.+?)\s*$/); + if (bullet) return { title: bullet[1].trim(), completed: false }; + const numbered = line.match(/^\s*\d+[.)]\s+(.+?)\s*$/); + if (numbered) return { title: numbered[1].trim(), completed: false }; + return undefined; +}; + +export const parseRoadmapMarkdown = (markdown: string, explicitTitle?: string): AutonomyRoadmap => { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const h1 = lines.find((line) => /^#\s+/.test(line))?.replace(/^#\s+/, "").trim(); + const parsed: Array<{ title: string; objectiveLines: string[]; tasks: Array<{ title: string; completed: boolean }> }> = []; + let current: { title: string; objectiveLines: string[]; tasks: Array<{ title: string; completed: boolean }> } | undefined; + const ensureCurrent = () => { + if (!current) { current = { title: "Execution", objectiveLines: [], tasks: [] }; parsed.push(current); } + return current; + }; + for (const line of lines) { + const heading = line.match(/^#{2,6}\s+(.+?)\s*$/); + if (heading) { current = { title: heading[1].trim(), objectiveLines: [], tasks: [] }; parsed.push(current); continue; } + if (/^#\s+/.test(line)) continue; + const task = parseTask(line); + if (task) { ensureCurrent().tasks.push(task); continue; } + const text = line.trim(); + if (text && current) current.objectiveLines.push(text); + } + if (!parsed.length) ensureCurrent(); + const phases: AutonomyRoadmapPhase[] = parsed.map((phase, pIndex) => ({ + id: phaseId(pIndex, phase.title), title: phase.title, + objective: phase.objectiveLines.join(" ").trim() || phase.title, + status: pIndex === 0 ? "active" : "pending", brainstormIterations: 0, brainstormConverged: false, validationFailures: 0, + items: phase.tasks.map((task, tIndex) => ({ id: itemId(pIndex, task.title, tIndex), title: task.title, source: "roadmap", createdAt: nowIso(), status: task.completed ? "completed" : "pending", attempts: 0 })), + })); + return { id: `roadmap-${randomUUID()}`, title: explicitTitle?.trim() || h1 || "Project roadmap", sourceMarkdown: markdown, status: "active", currentPhaseIndex: 0, phases, importedAt: nowIso() }; +}; + diff --git a/backend/src/services/autonomyRuntime.ts b/backend/src/services/autonomyRuntime.ts new file mode 100644 index 0000000..bea98cb --- /dev/null +++ b/backend/src/services/autonomyRuntime.ts @@ -0,0 +1,380 @@ +import { prisma } from "../db/prisma.js"; +import { normalizeAutonomyState, type AutonomyState } from "../repositories/autonomyRepository.js"; +import type { CanonicalLifecycle } from "@projectbrain/contracts"; +import { canonicalLifecycleFromAutonomy } from "./canonicalLifecycle.js"; + +export type AutonomyExecutorStatus = "idle" | "working" | "degraded"; + +// Give an owning run a small grace period after its configured run budget. This +// prevents an abandoned short run from holding ownership for the global maximum. +export const AUTONOMY_HEARTBEAT_GRACE_MS = 5 * 60 * 1_000; +export const DEFAULT_AUTONOMY_HEARTBEAT_TAKEOVER_AFTER_MS = 55 * 60 * 1_000; + +type AutonomyHeartbeatInput = { + instanceId: string; + runId?: string; + status: AutonomyExecutorStatus; + activeProjectId?: string; + detail?: string; +}; + +export const autonomyHeartbeatSlotId = (activeProjectId?: string) => activeProjectId ? `project:${activeProjectId}` : "primary"; + +export const resolveAutonomyHeartbeat = ( + current: { runId: string | null; status: string; activeProjectId?: string | null; startedAt: Date } | null, + input: AutonomyHeartbeatInput, + now = new Date(), + takeoverAfterMs = DEFAULT_AUTONOMY_HEARTBEAT_TAKEOVER_AFTER_MS, +) => { + const differentRun = Boolean(current?.runId && current.runId !== input.runId); + if (differentRun) { + const currentRunWithinBudget = current?.status === "working" + && current.startedAt.getTime() + takeoverAfterMs > now.getTime(); + if (input.status !== "working" || currentRunWithinBudget) { + return { + accepted: false as const, + startedAt: current!.startedAt, + reason: input.status !== "working" ? "run_owner_mismatch" as const : "active_run" as const, + }; + } + } + const switchesActiveProject = Boolean( + current?.status === "working" + && current.runId === input.runId + && current.activeProjectId + && input.activeProjectId + && current.activeProjectId !== input.activeProjectId, + ); + if (switchesActiveProject) { + return { accepted: false as const, startedAt: current!.startedAt, reason: "active_project" as const }; + } + return { + accepted: true as const, + startedAt: !current || differentRun ? now : current.startedAt, + }; +}; + +export type AutonomyProjectRuntime = { + projectId: string; + projectSlug: string; + projectName: string; + enabled: boolean; + status: AutonomyState["status"]; + lifecycle: CanonicalLifecycle; + pendingWorkCount: number; + completedWorkCount: number; + maxWorkItemsPerRun: number; + maxRunMinutes: number; + activeWorkKind?: string; + activeRunId?: string; + leaseExpiresAt?: string; + blockerReason?: string; + blockerKind?: "human" | "retryable"; + retryAt?: string; + executorStatus?: AutonomyExecutorStatus | "stale" | "offline"; + executorLastSeenAt?: string; + executorRunId?: string; + lastProgressAt?: string; + stalled: boolean; +}; + +export type AutonomyRuntimeSnapshot = { + executor?: { + status: AutonomyExecutorStatus | "stale"; + instanceId: string; + runId?: string; + activeProjectId?: string; + detail?: string; + startedAt: string; + lastSeenAt: string; + }; + executors: Array<{ + status: AutonomyExecutorStatus | "stale"; + instanceId: string; + runId?: string; + activeProjectId?: string; + detail?: string; + startedAt: string; + lastSeenAt: string; + }>; + counts: { + connectedExecutors: number; + enabledProjects: number; + claimableProjects: number; + activeProjects: number; + blockedProjects: number; + waitingRoadmapProjects: number; + pendingWorkItems: number; + stalledProjects: number; + }; + projects: AutonomyProjectRuntime[]; +}; + +type CompactAutonomyOptions = { itemLimit?: number; eventLimit?: number }; + +const clip = (value: string | undefined, limit: number) => { + if (!value) return undefined; + return value.length > limit ? `${value.slice(0, limit - 1)}…` : value; +}; + +export const compactAutonomyState = (state: AutonomyState, options: CompactAutonomyOptions = {}) => { + const itemLimit = Math.max(1, Math.min(25, Math.trunc(options.itemLimit ?? 25))); + const eventLimit = Math.max(0, Math.min(20, Math.trunc(options.eventLimit ?? 5))); + const roadmap = state.roadmap; + const allItems = roadmap?.phases.flatMap((phase) => phase.items) ?? []; + const executionItems = allItems.filter((item) => item.scope !== "future" && item.scope !== "stretch"); + const currentPhase = roadmap?.phases[roadmap.currentPhaseIndex]; + const currentItems = currentPhase?.items ?? []; + const firstActionableIndex = currentItems.findIndex((item) => item.status !== "completed" && item.scope !== "future" && item.scope !== "stretch"); + const itemWindowStart = firstActionableIndex < 0 + ? Math.max(0, currentItems.length - itemLimit) + : Math.max(0, firstActionableIndex - Math.min(3, itemLimit - 1)); + const itemWindow = currentItems.slice(itemWindowStart, itemWindowStart + itemLimit); + const itemCounts = { + pending: currentItems.filter((item) => item.status === "pending" && item.scope !== "future" && item.scope !== "stretch").length, + inProgress: currentItems.filter((item) => item.status === "in_progress" && item.scope !== "future" && item.scope !== "stretch").length, + completed: currentItems.filter((item) => item.status === "completed" && item.scope !== "future" && item.scope !== "stretch").length, + blocked: currentItems.filter((item) => item.status === "blocked" && item.scope !== "future" && item.scope !== "stretch").length, + }; + return { + version: state.version, + enabled: state.enabled, + status: state.status, + profile: state.profile, + lifecycle: canonicalLifecycleFromAutonomy(state), + autoBrainstorm: state.autoBrainstorm, + maxBrainstormIterations: state.maxBrainstormIterations, + maxConsecutiveFailures: state.maxConsecutiveFailures, + leaseMinutes: state.leaseMinutes, + maxWorkItemsPerRun: state.maxWorkItemsPerRun, + maxRunMinutes: state.maxRunMinutes, + pauseAfterCurrent: state.pauseAfterCurrent, + ...(roadmap ? { roadmap: { + id: roadmap.id, + title: roadmap.title, + status: roadmap.status, + currentPhaseIndex: roadmap.currentPhaseIndex, + phaseCount: roadmap.phases.length, + itemCounts: { + pending: executionItems.filter((item) => item.status === "pending").length, + inProgress: executionItems.filter((item) => item.status === "in_progress").length, + completed: executionItems.filter((item) => item.status === "completed").length, + blocked: executionItems.filter((item) => item.status === "blocked").length, + future: allItems.length - executionItems.length, + }, + ...(currentPhase ? { currentPhase: { + id: currentPhase.id, + title: currentPhase.title, + objective: clip(currentPhase.objective, 1_000), + status: currentPhase.status, + brainstormIterations: currentPhase.brainstormIterations, + brainstormConverged: currentPhase.brainstormConverged, + validationFailures: currentPhase.validationFailures, + itemCounts, + items: itemWindow.map((item) => ({ + id: item.id, + title: clip(item.title, 500), + status: item.status, + attempts: item.attempts, + source: item.source, + deliveryId: item.deliveryId, + dependsOn: item.dependsOn, + lastError: clip(item.lastError, 500), + })), + itemWindowStart, + itemsReturned: itemWindow.length, + totalItems: currentItems.length, + hasMoreItems: currentItems.length > itemLimit, + } } : {}), + } } : {}), + ...(state.activeLease ? { activeLease: state.activeLease } : {}), + ...(state.blocker ? { blocker: { ...state.blocker, reason: clip(state.blocker.reason, 1_000) } } : {}), + runs: state.runs.slice(-3), + events: eventLimit ? state.events.slice(-eventLimit).map((entry) => ({ ...entry, summary: clip(entry.summary, 500) })) : [], + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + }; +}; + +const activeLease = (state: AutonomyState, now: number) => state.activeLease && Date.parse(state.activeLease.expiresAt) > now + ? state.activeLease + : undefined; + +const latestProgressAt = (state: AutonomyState) => { + const values = [ + state.updatedAt, + state.activeLease?.createdAt, + ...state.completedLeases.slice(-5).map((entry) => entry.completedAt), + ...state.events.slice(-5).map((entry) => entry.at), + ].map((value) => value ? Date.parse(value) : Number.NaN).filter(Number.isFinite); + return values.length ? new Date(Math.max(...values)).toISOString() : undefined; +}; + +export const summarizeAutonomyProject = (input: { + projectId: string; + projectSlug: string; + projectName: string; + autonomy: unknown; +}, now = Date.now()): AutonomyProjectRuntime => { + const state = normalizeAutonomyState(input.autonomy); + const items = (state.roadmap?.phases.flatMap((phase) => phase.items) ?? []) + .filter((item) => item.scope !== "future" && item.scope !== "stretch"); + const lease = activeLease(state, now); + const lastProgressAt = latestProgressAt(state); + return { + projectId: input.projectId, + projectSlug: input.projectSlug, + projectName: input.projectName, + enabled: state.enabled, + status: state.status, + lifecycle: canonicalLifecycleFromAutonomy(state, now), + pendingWorkCount: items.filter((item) => item.status === "pending" || item.status === "in_progress").length, + completedWorkCount: items.filter((item) => item.status === "completed").length, + maxWorkItemsPerRun: state.maxWorkItemsPerRun, + maxRunMinutes: state.maxRunMinutes, + ...(lease ? { activeWorkKind: lease.kind, leaseExpiresAt: lease.expiresAt, ...(lease.runId ? { activeRunId: lease.runId } : {}) } : {}), + ...(state.blocker?.reason ? { blockerReason: state.blocker.reason, blockerKind: state.blocker.kind, ...(state.blocker.retryAt ? { retryAt: state.blocker.retryAt } : {}) } : {}), + ...(lastProgressAt ? { lastProgressAt } : {}), + stalled: false, + }; +}; + +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; + +const getProjectRunTakeoverMs = async (activeProjectId?: string) => { + if (!activeProjectId) return DEFAULT_AUTONOMY_HEARTBEAT_TAKEOVER_AFTER_MS; + const project = await prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: activeProjectId }, { slug: activeProjectId }, { readableId: activeProjectId }] }, + select: { workspaceState: { select: { notesJson: true } } }, + }); + if (!project) return DEFAULT_AUTONOMY_HEARTBEAT_TAKEOVER_AFTER_MS; + const state = normalizeAutonomyState(asRecord(project.workspaceState?.notesJson).autonomy); + return Math.min(DEFAULT_AUTONOMY_HEARTBEAT_TAKEOVER_AFTER_MS, state.maxRunMinutes * 60_000 + AUTONOMY_HEARTBEAT_GRACE_MS); +}; + +export const recordAutonomyExecutorHeartbeat = async (input: AutonomyHeartbeatInput) => { + const heartbeatId = autonomyHeartbeatSlotId(input.activeProjectId); + const takeoverAfterMs = await getProjectRunTakeoverMs(input.activeProjectId); + for (let attempt = 0; attempt < 3; attempt += 1) { + const now = new Date(); + if (input.status === "working" && input.runId && input.activeProjectId) { + const otherProjectForRun = await prisma.autonomyExecutorHeartbeat.findFirst({ + where: { id: { not: heartbeatId }, runId: input.runId, status: "working" }, + }); + if (otherProjectForRun) { + return { heartbeat: otherProjectForRun, accepted: false as const, startedAt: otherProjectForRun.startedAt, reason: "active_project" as const }; + } + } + const current = await prisma.autonomyExecutorHeartbeat.findUnique({ where: { id: heartbeatId } }); + const resolution = resolveAutonomyHeartbeat(current, input, now, takeoverAfterMs); + if (!resolution.accepted && current) return { heartbeat: current, ...resolution }; + const data = { + instanceId: input.instanceId, + runId: input.runId ?? null, + status: input.status, + activeProjectId: input.activeProjectId + ?? (input.status === "working" && current?.runId === input.runId ? current?.activeProjectId : null), + detail: input.detail ?? null, + startedAt: resolution.startedAt, + lastSeenAt: now, + }; + if (current) { + const updated = await prisma.autonomyExecutorHeartbeat.updateMany({ + where: { id: heartbeatId, updatedAt: current.updatedAt }, + data, + }); + if (updated.count === 1) { + const heartbeat = await prisma.autonomyExecutorHeartbeat.findUniqueOrThrow({ where: { id: heartbeatId } }); + return { heartbeat, accepted: true as const }; + } + continue; + } + try { + const heartbeat = await prisma.autonomyExecutorHeartbeat.create({ data: { id: heartbeatId, ...data } }); + return { heartbeat, accepted: true as const }; + } catch (error) { + if ((error as { code?: string }).code !== "P2002") throw error; + } + } + throw new Error("AUTONOMY_HEARTBEAT_CONFLICT"); +}; + +export const touchAutonomyExecutorHeartbeat = async (runId: string, activeProjectId?: string) => { + const updated = await prisma.autonomyExecutorHeartbeat.updateMany({ + where: { + id: autonomyHeartbeatSlotId(activeProjectId), + runId, + status: "working", + ...(activeProjectId ? { OR: [{ activeProjectId: null }, { activeProjectId }] } : {}), + }, + data: { + lastSeenAt: new Date(), + ...(activeProjectId ? { activeProjectId } : {}), + }, + }); + return updated.count === 1; +}; + +export const loadAutonomyRuntime = async (staleSeconds: number): Promise => { + const [workspaces, heartbeats] = await Promise.all([ + prisma.workspaceState.findMany({ + where: { project: { deletedAt: null } }, + select: { notesJson: true, project: { select: { id: true, slug: true, name: true } } }, + }), + prisma.autonomyExecutorHeartbeat.findMany({ orderBy: { lastSeenAt: "desc" } }), + ]); + const now = Date.now(); + const executors = heartbeats.map((heartbeat) => { + const fresh = heartbeat.lastSeenAt.getTime() >= now - staleSeconds * 1_000; + return { + status: fresh ? heartbeat.status as AutonomyExecutorStatus : "stale" as const, + instanceId: heartbeat.instanceId, + ...(heartbeat.runId ? { runId: heartbeat.runId } : {}), + ...(heartbeat.activeProjectId ? { activeProjectId: heartbeat.activeProjectId } : {}), + ...(heartbeat.detail ? { detail: heartbeat.detail } : {}), + startedAt: heartbeat.startedAt.toISOString(), + lastSeenAt: heartbeat.lastSeenAt.toISOString(), + }; + }); + const projects: AutonomyProjectRuntime[] = workspaces.flatMap((workspace) => { + const autonomy = asRecord(workspace.notesJson).autonomy; + if (!autonomy || !Object.keys(asRecord(autonomy)).length) return []; + const base = summarizeAutonomyProject({ + projectId: workspace.project.id, + projectSlug: workspace.project.slug, + projectName: workspace.project.name, + autonomy, + }, now); + // Query order is newest first. Do not overwrite newer entries with older instances. + const executorForProject = executors.find((candidate) => candidate.activeProjectId === workspace.project.slug || candidate.activeProjectId === workspace.project.id); + const lastSeen = executorForProject?.lastSeenAt ? Date.parse(executorForProject.lastSeenAt) : Number.NaN; + const expectedSilenceMs = Math.max(2 * 60 * 60_000, (base.maxRunMinutes + 65) * 60_000); + const stalled = Boolean(base.enabled && base.status === "running" && base.pendingWorkCount > 0 && !base.activeWorkKind && (!Number.isFinite(lastSeen) || lastSeen < now - expectedSilenceMs)); + const executorStatus: AutonomyProjectRuntime["executorStatus"] = executorForProject?.status ?? "offline"; + return [{ + ...base, + executorStatus, + ...(executorForProject?.lastSeenAt ? { executorLastSeenAt: executorForProject.lastSeenAt } : {}), + ...(executorForProject?.runId ? { executorRunId: executorForProject.runId } : {}), + stalled, + }]; + }).sort((left, right) => Number(right.stalled) - Number(left.stalled) || right.pendingWorkCount - left.pendingWorkCount || left.projectName.localeCompare(right.projectName)); + const enabled = projects.filter((project) => project.enabled); + const executor = executors[0]; + return { + ...(executor ? { executor } : {}), + executors, + counts: { + connectedExecutors: executors.filter((candidate) => candidate.status !== "stale").length, + enabledProjects: enabled.length, + claimableProjects: enabled.filter((project) => project.status === "running" && project.pendingWorkCount > 0 && !project.activeWorkKind).length, + activeProjects: enabled.filter((project) => Boolean(project.activeWorkKind)).length, + blockedProjects: enabled.filter((project) => project.status === "blocked").length, + waitingRoadmapProjects: enabled.filter((project) => project.status === "waiting_roadmap").length, + pendingWorkItems: enabled.reduce((sum, project) => sum + project.pendingWorkCount, 0), + stalledProjects: enabled.filter((project) => project.stalled).length, + }, + projects, + }; +}; diff --git a/backend/src/services/autonomyRuntimeControl.ts b/backend/src/services/autonomyRuntimeControl.ts new file mode 100644 index 0000000..a1c93b0 --- /dev/null +++ b/backend/src/services/autonomyRuntimeControl.ts @@ -0,0 +1,147 @@ +import { getFeatureDelivery } from "../repositories/featureDeliveryRepository.js"; +import { + appendAutonomyEvent, + canResumeAutonomyLease, + createAutonomyEvent, + createAutonomyLease, + evaluateAutonomyRunBudget, + getAutonomyProject, + saveAutonomyState, + type AutonomyReportInput, + type AutonomyRoadmapItem, + type AutonomyState, + type AutonomyWork, +} from "../repositories/autonomyRepository.js"; +import { reconcileAutonomyPlan, type DeliveryRealitySignal } from "./autonomyReplanner.js"; +import { reconcileRepositoryAutonomyRoadmap } from "./repositoryAutonomy.js"; +import { chooseNextAutonomyWork } from "./autonomyScheduler.js"; +import { hasOpenAutonomyExecutionItems, isAutonomyItemInExecutionScope, workPrompt } from "./autonomyWork.js"; +import { getGiteaAutonomyExecutionTarget } from "./giteaAutonomyExecution.js"; + +const nowIso = () => new Date().toISOString(); + +const reconcileKnownDeliveryReality = async (state: AutonomyState, projectSlug: string) => { + const items = state.roadmap?.phases.flatMap((phase) => phase.items).filter((item) => item.deliveryId && item.status !== "completed") ?? []; + if (!items.length) return { state, changed: false, reasons: [] as string[] }; + const signals = (await Promise.all(items.map(async (item): Promise => { + try { + const delivery = await getFeatureDelivery(projectSlug, item.deliveryId!); + return { itemId: item.id, deliveryId: delivery.id, status: delivery.spec.status, mergeCommitSha: delivery.spec.pullRequest?.mergeCommitSha, validationEvidence: delivery.spec.validationEvidence?.headSha, ...(delivery.spec.status === "superseded" ? { supersededBy: delivery.spec.reviewFeedback ?? "replacement delivery" } : {}) }; + } catch (error) { + const code = error instanceof Error ? error.message : "DELIVERY_READ_FAILED"; + if (code === "FEATURE_DELIVERY_NOT_FOUND") return { itemId: item.id, deliveryId: item.deliveryId!, status: "missing", branchMissing: true }; + return { itemId: item.id, deliveryId: item.deliveryId!, status: "unavailable", externalUnavailable: true }; + } + }))).filter((signal): signal is DeliveryRealitySignal => Boolean(signal)); + return reconcileAutonomyPlan(state, signals); +}; + +/** Claim the next durable Auto Mode lease after reconciling external reality. */ +export const claimAutonomyWork = async (projectIdOrSlug: string, actor = "chatgpt", runId?: string) => { + const repositoryPlan = await reconcileRepositoryAutonomyRoadmap(projectIdOrSlug, actor); + const project = await getAutonomyProject(projectIdOrSlug); + let state = repositoryPlan.state; + const reality = await reconcileKnownDeliveryReality(state, project.slug); + if (reality.changed) { + state = appendAutonomyEvent(reality.state, createAutonomyEvent("autonomy.plan.reconciled", "system", reality.reasons.join(" "))); + state = await saveAutonomyState(project, state, "system"); + } else state = reality.state; + if (!state.enabled) throw new Error("AUTO_MODE_DISABLED"); + if (!state.roadmap) throw new Error("AUTO_MODE_ROADMAP_REQUIRED"); + if (state.status === "blocked" || state.roadmap.status === "blocked") return { state, work: undefined }; + if (state.status === "completed" || state.roadmap.status === "completed") return { state, work: undefined }; + if (state.activeLease) { + if (!canResumeAutonomyLease(state.activeLease, actor, runId)) return { state, work: undefined, resumed: false, busy: true }; + const phase = state.roadmap.phases.find((entry) => entry.id === state.activeLease!.phaseId)!; + const item = state.activeLease.itemId ? phase?.items.find((entry) => entry.id === state.activeLease!.itemId) : undefined; + const execution = item && state.activeLease.kind === "implement" + ? await getGiteaAutonomyExecutionTarget(project.id, item.id) + : undefined; + const work: AutonomyWork = { token: state.activeLease.token, kind: state.activeLease.kind, projectId: project.slug, roadmapId: state.roadmap.id, roadmapTitle: state.roadmap.title, phaseId: phase.id, phaseTitle: phase.title, phaseObjective: phase.objective, ...(item ? { itemId: item.id, itemTitle: item.title, deliveryId: item.deliveryId } : {}), ...(execution ? { execution } : {}), prompt: workPrompt(state.activeLease.kind, phase, item), expiresAt: state.activeLease.expiresAt }; + return { state, work, resumed: true }; + } + + if (runId) { + const budget = evaluateAutonomyRunBudget(state, actor, runId); + if (!budget.allowed) { + if (budget.reason === "owner_mismatch") return { state, work: undefined, resumed: false, busy: true }; + return { state, work: undefined, budgetReached: true, budgetReason: budget.reason }; + } + if (!state.runs.some((entry) => entry.id === runId)) state.runs = [...state.runs, budget.run].slice(-50); + } + + const roadmap = state.roadmap; + if (state.pauseAfterCurrent) { + state.pauseAfterCurrent = false; + state.enabled = false; + state.status = "off"; + state = appendAutonomyEvent(state, createAutonomyEvent("autonomy.paused_after_current", actor, "Auto Mode paused after the current work item completed.")); + state = await saveAutonomyState(project, state, actor); + return { state, work: undefined, pausedAfterCurrent: true }; + } + const executionItems = roadmap.phases.flatMap((entry) => entry.items).filter((item) => isAutonomyItemInExecutionScope(item)); + if (executionItems.every((item) => item.status === "completed")) { + const { evaluateProjectCompletion } = await import("./projectCompletion.js"); + const completion = await evaluateProjectCompletion(project.id, actor); + if (completion.status === "complete") { + roadmap.status = "completed"; + roadmap.completedAt = roadmap.completedAt ?? nowIso(); + state.status = "completed"; + state = appendAutonomyEvent(state, createAutonomyEvent("autonomy.definition_of_done.reached", actor, completion.summary)); + state = await saveAutonomyState(project, state, actor); + return { state, work: undefined, completedByDefinitionOfDone: true }; + } + } + + let phase = roadmap.phases[roadmap.currentPhaseIndex]; + while (phase?.status === "completed" && roadmap.currentPhaseIndex < roadmap.phases.length - 1) { + roadmap.currentPhaseIndex += 1; + phase = roadmap.phases[roadmap.currentPhaseIndex]; + phase.status = "active"; + } + if (!phase || roadmap.phases.every((entry) => entry.status === "completed")) { + roadmap.status = "completed"; + roadmap.completedAt = nowIso(); + state.status = "completed"; + state = appendAutonomyEvent(state, createAutonomyEvent("autonomy.roadmap.completed", actor, `Roadmap '${roadmap.title}' completed.`)); + state = await saveAutonomyState(project, state, actor); + return { state, work: undefined }; + } + + let kind: "brainstorm" | "implement" | "validate_phase"; + let item: AutonomyRoadmapItem | undefined; + const unfinishedItem = phase.items.find((entry) => entry.status === "in_progress"); + if (unfinishedItem) unfinishedItem.status = "pending"; + const scheduled = chooseNextAutonomyWork(roadmap.phases, roadmap.currentPhaseIndex, state.schedulingPolicy); + if (scheduled?.phase) { + phase = scheduled.phase; + roadmap.currentPhaseIndex = scheduled.phaseIndex; + } + const nextItem = scheduled?.item; + if (!nextItem && hasOpenAutonomyExecutionItems(phase)) return { state, work: undefined, busy: true }; + const initialBrainstormNeeded = state.autoBrainstorm && phase.items.length === 0 && phase.brainstormIterations === 0 && !phase.brainstormConverged; + if (nextItem) { + kind = "implement"; + item = nextItem; + item.status = "in_progress"; + item.attempts += 1; + phase.status = "executing"; + } else if (initialBrainstormNeeded || (state.autoBrainstorm && !phase.brainstormConverged && phase.brainstormIterations < state.maxBrainstormIterations)) { + kind = "brainstorm"; + phase.status = "brainstorming"; + } else { + phase.brainstormConverged = true; + kind = "validate_phase"; + phase.status = "validating"; + } + const lease = createAutonomyLease(state, actor, kind, phase, item, runId); + state.activeLease = lease; + state.status = "running"; + state = appendAutonomyEvent(state, createAutonomyEvent("autonomy.work.claimed", actor, `Claimed ${kind} work for '${item?.title ?? phase.title}'.`)); + state = await saveAutonomyState(project, state, actor); + const execution = item && kind === "implement" + ? await getGiteaAutonomyExecutionTarget(project.id, item.id) + : undefined; + const work: AutonomyWork = { token: lease.token, kind, projectId: project.slug, roadmapId: roadmap.id, roadmapTitle: roadmap.title, phaseId: phase.id, phaseTitle: phase.title, phaseObjective: phase.objective, ...(item ? { itemId: item.id, itemTitle: item.title, deliveryId: item.deliveryId } : {}), ...(execution ? { execution } : {}), prompt: workPrompt(kind, phase, item), expiresAt: lease.expiresAt }; + return { state, work, resumed: false }; +}; diff --git a/backend/src/services/autonomyScheduler.ts b/backend/src/services/autonomyScheduler.ts new file mode 100644 index 0000000..6a138ba --- /dev/null +++ b/backend/src/services/autonomyScheduler.ts @@ -0,0 +1,136 @@ +import type { AutonomyRoadmapItem, AutonomyRoadmapPhase } from "../repositories/autonomyRepository.js"; + +export type AutonomyScheduleCandidate = { + item: AutonomyRoadmapItem; + phase?: AutonomyRoadmapPhase; + phaseIndex: number; + score: number; + downstreamCount: number; + conflictKeys: string[]; +}; + +export type AutonomySchedulingPolicy = "balanced" | "downstream-impact"; + +const scopeWeight: Record = { + required: 42, + committed: 30, + stretch: 8, + future: -40, +}; + +const riskWeight: Record = { + critical: 26, + high: 18, + medium: 10, + normal: 4, + low: 0, +}; + +const priorityWeight = (priority: number | undefined) => Math.max(0, Math.min(100, Math.trunc(priority ?? 50))) * 0.35; + +const dependencyIds = (item: AutonomyRoadmapItem) => Array.from(new Set(item.dependsOn ?? [])); + +export const autonomyItemIsReady = (item: AutonomyRoadmapItem, allItems: AutonomyRoadmapItem[]) => + item.status === "pending" + && item.scope !== "future" + && item.scope !== "stretch" + && !item.waitingExternal + && dependencyIds(item).every((dependencyId) => allItems.some((candidate) => candidate.id === dependencyId && candidate.status === "completed")); + +export const downstreamCountForAutonomyItem = (item: AutonomyRoadmapItem, allItems: AutonomyRoadmapItem[]) => { + const reverse = new Map(allItems + .filter((candidate) => candidate.scope !== "future" && candidate.scope !== "stretch" && candidate.status !== "completed") + .map((candidate) => [candidate.id, dependencyIds(candidate)])); + const seen = new Set(); + const visit = (id: string) => { + for (const [candidateId, dependencies] of reverse) { + if (!dependencies.includes(id) || seen.has(candidateId)) continue; + seen.add(candidateId); + visit(candidateId); + } + }; + visit(item.id); + return seen.size; +}; + +export const conflictKeysForAutonomyItem = (item: AutonomyRoadmapItem) => [ + ...(item.touchedPaths ?? []).map((path) => `path:${path.toLowerCase()}`), + ...(item.domain ? [`domain:${item.domain.toLowerCase()}`] : []), + ...(item.deliveryId ? [`delivery:${item.deliveryId}`] : []), +]; + +export const scoreAutonomyItem = ( + item: AutonomyRoadmapItem, + allItems: AutonomyRoadmapItem[], + phaseIndex = 0, + currentPhaseIndex = phaseIndex, + policy: AutonomySchedulingPolicy = "balanced", +) => { + const downstreamCount = downstreamCountForAutonomyItem(item, allItems); + const releaseBlocker = item.releaseBlocking ? 30 : 0; + const userAssignment = item.source === "user_assignment" ? 36 : 0; + const failurePenalty = Math.min(35, item.attempts * 7); + const externalWaitPenalty = item.waitingExternal ? 80 : 0; + const phasePreference = phaseIndex === currentPhaseIndex ? 12 : Math.max(-12, 4 - Math.abs(phaseIndex - currentPhaseIndex) * 4); + const estimatedScopePenalty = Math.min(20, Math.max(0, item.estimatedScope ?? 0) * 2); + return scopeWeight[item.scope ?? "committed"] + + riskWeight[item.risk ?? "normal"] + + priorityWeight(item.priority) + + downstreamCount * (policy === "downstream-impact" ? 30 : 18) + + releaseBlocker + + userAssignment + + phasePreference + - failurePenalty + - externalWaitPenalty + - estimatedScopePenalty; +}; + +export const rankAutonomyItems = ( + items: AutonomyRoadmapItem[], + phaseIndex = 0, + currentPhaseIndex = phaseIndex, + policy: AutonomySchedulingPolicy = "balanced", +): AutonomyScheduleCandidate[] => items + .filter((item) => autonomyItemIsReady(item, items)) + .map((item) => ({ + item, + phase: undefined, + phaseIndex, + score: scoreAutonomyItem(item, items, phaseIndex, currentPhaseIndex, policy), + downstreamCount: downstreamCountForAutonomyItem(item, items), + conflictKeys: conflictKeysForAutonomyItem(item), + })) + .sort((left, right) => right.score - left.score || right.downstreamCount - left.downstreamCount || left.item.id.localeCompare(right.item.id)); + +export const chooseNextAutonomyWork = ( + phases: AutonomyRoadmapPhase[], + currentPhaseIndex: number, + policy: AutonomySchedulingPolicy = "balanced", +): AutonomyScheduleCandidate | undefined => { + const allItems = phases.flatMap((phase) => phase.items); + const candidates = phases.flatMap((phase, phaseIndex) => phase.items + .filter((item) => autonomyItemIsReady(item, allItems)) + .map((item) => ({ + item, + phase, + phaseIndex, + score: scoreAutonomyItem(item, allItems, phaseIndex, currentPhaseIndex, policy), + downstreamCount: downstreamCountForAutonomyItem(item, allItems), + conflictKeys: conflictKeysForAutonomyItem(item), + }))); + return candidates.sort((left, right) => right.score - left.score || right.downstreamCount - left.downstreamCount || left.phaseIndex - right.phaseIndex || left.item.id.localeCompare(right.item.id))[0]; +}; + +export const planParallelAutonomyWork = (candidates: AutonomyScheduleCandidate[]) => { + const groups: AutonomyScheduleCandidate[][] = []; + for (const candidate of candidates) { + const compatible = groups.find((group) => group.every((existing) => + existing.phaseIndex === candidate.phaseIndex + && !existing.conflictKeys.some((key) => candidate.conflictKeys.includes(key)) + && !existing.item.releaseBlocking + && !candidate.item.releaseBlocking)); + if (compatible) compatible.push(candidate); + else groups.push([candidate]); + } + return groups; +}; diff --git a/backend/src/services/autonomyWork.ts b/backend/src/services/autonomyWork.ts new file mode 100644 index 0000000..111bed0 --- /dev/null +++ b/backend/src/services/autonomyWork.ts @@ -0,0 +1,58 @@ +import type { AutonomyRoadmapItem, AutonomyRoadmapPhase, AutonomyWorkKind } from "../repositories/autonomyRepository.js"; +import { randomUUID } from "node:crypto"; + +export const workPrompt = (kind: AutonomyWorkKind, phase: AutonomyRoadmapPhase, item?: AutonomyRoadmapItem) => { + if (kind === "brainstorm") return `Review phase '${phase.title}' against ${phase.objective}. Inspect current repository and delivery evidence. Report only missing actionable tasks; report success without new tasks when the phase has converged.`; + if (kind === "implement") return `Complete '${item?.title}' in '${phase.title}'. User instruction: ${item?.instruction ?? item?.title}. Objective: ${phase.objective}. Auto Mode authorizes ordinary implementation and validated PR merge; do not ask for routine approval. ${item?.deliveryId ? `Resume delivery '${item.deliveryId}' and its existing branch/PR; do not create a replacement.` : "List existing deliveries first and reuse matching unfinished work before creating a delivery."} Read the repo, implement, run managed validation, inspect failed-job evidence, repair on the SAME branch, and revalidate the changed head. Sync successful exact-head evidence, open the PR, wait for required PR checks, then merge_feature_delivery with the current SHA. A PR awaiting checks or merge is unfinished work. Always report delivery_id, including failed/blocked reports. Report necessary repair tasks in discovered_tasks on failure so they run before retrying this item. Technical failures remain retryable; only actual missing authorization, credentials or product decisions need a human. Never weaken checks or create empty commits to trigger CI.`; + return `Independently validate phase '${phase.title}' against ${phase.objective}. Inspect repository/runtime evidence and all linked deliveries. Pending PRs or missing validation are incomplete. Report failed with discovered tasks for remaining gaps; report success only after the objective is satisfied.`; +}; + +export const addDiscoveredTasks = (phase: AutonomyRoadmapPhase, tasks: string[], options: { scope?: "required" | "committed" | "future"; source?: "brainstorm" | "recovery" } = {}) => { + const scope = options.scope ?? "future"; + const source = options.source ?? "brainstorm"; + const added: string[] = []; + for (const raw of tasks) { + const title = raw.trim(); + if (!title) continue; + const existing = phase.items.find((item) => item.title.toLowerCase() === title.toLowerCase()); + if (existing) { + // A failure can promote an already-known brainstorm candidate when the + // executor has identified it as a necessary repair prerequisite. + if (scope !== "future" && (existing.scope === "future" || existing.scope === "stretch")) { + existing.scope = scope; + existing.source = source; + added.push(title); + } + continue; + } + // Brainstorm output is an explicit backlog suggestion. It is visible in the + // work graph, but it cannot silently enlarge Required + Committed scope. + // Failure-discovered prerequisites use the recovery source and are promoted + // into the current committed execution scope intentionally. + phase.items.push({ id: `${phase.id}-discovered-${randomUUID()}`, title, source, scope, createdAt: new Date().toISOString(), status: "pending", attempts: 0 }); + added.push(title); + } + return added; +}; + +export const orderAutonomyRepairs = (phase: AutonomyRoadmapPhase, item: AutonomyRoadmapItem, titles: string[]) => { + const requested = new Set(titles.map((title) => title.trim().toLowerCase())); + const repairs = phase.items.filter((candidate) => candidate.id !== item.id && candidate.status !== "completed" && requested.has(candidate.title.toLowerCase())); + const reaches = (id: string, target: string, seen = new Set()): boolean => { + if (id === target) return true; + if (seen.has(id)) return false; + seen.add(id); + return (phase.items.find((candidate) => candidate.id === id)?.dependsOn ?? []).some((dependency) => reaches(dependency, target, seen)); + }; + if (repairs.some((repair) => reaches(repair.id, item.id))) throw new Error("AUTO_MODE_DEPENDENCY_CYCLE"); + if (repairs.length) item.dependsOn = [...new Set([...(item.dependsOn ?? []), ...repairs.map((repair) => repair.id)])]; + return repairs.length > 0; +}; + +export const isAutonomyItemReady = (phase: AutonomyRoadmapPhase, item: AutonomyRoadmapItem) => + item.status === "pending" && item.scope !== "future" && item.scope !== "stretch" + && (item.dependsOn ?? []).every((id) => phase.items.some((candidate) => candidate.id === id && candidate.status === "completed")); + +export const isAutonomyItemInExecutionScope = (item: AutonomyRoadmapItem) => item.scope !== "future" && item.scope !== "stretch"; + +export const hasOpenAutonomyExecutionItems = (phase: AutonomyRoadmapPhase) => phase.items.some((item) => item.status !== "completed" && isAutonomyItemInExecutionScope(item)); diff --git a/backend/src/services/blueprintEngine.ts b/backend/src/services/blueprintEngine.ts new file mode 100644 index 0000000..32dbd80 --- /dev/null +++ b/backend/src/services/blueprintEngine.ts @@ -0,0 +1,421 @@ +import type { ContextPackDto, ProjectDetailDto } from "../types/api.js"; +import { selectContextPacks } from "./contextPackSelector.js"; + +export type BlueprintDepthDto = "light" | "standard" | "deep" | "full"; +export type BlueprintArchetypeIdDto = "webapp" | "mobile-app" | "self-hosted-platform" | "ai-platform" | "game-rom-hack" | "browser-extension" | "api-backend-service" | "documentation-knowledge-project"; +export type BlueprintClusterIdDto = "project" | "product" | "ux" | "architecture" | "ai-context" | "frontend" | "backend" | "data" | "game-design" | "implementation" | "operations" | "quality"; + +type NodeTemplate = { + id: string; + title: string; + cluster: BlueprintClusterIdDto; + requiredDepth: BlueprintDepthDto; + dependsOn: string[]; + outputPath: string; + purpose: string; + requiredContext: string[]; + qualityChecks: string[]; + produces: string[]; + appliesTo?: BlueprintArchetypeIdDto[]; +}; + +type Archetype = { + id: BlueprintArchetypeIdDto; + label: string; + summary: string; + signals: string[]; + recommendedDepth: BlueprintDepthDto; + clusters: BlueprintClusterIdDto[]; +}; + +const depthRank: Record = { light: 1, standard: 2, deep: 3, full: 4 }; + +const archetypes: Archetype[] = [ + { id: "webapp", label: "Web application", summary: "Browser-based app with product, UX, frontend, backend, data and validation docs.", signals: ["web", "react", "frontend", "backend", "dashboard", "app"], recommendedDepth: "standard", clusters: ["project", "product", "ux", "architecture", "frontend", "backend", "data", "implementation", "quality"] }, + { id: "mobile-app", label: "Mobile-first app", summary: "Smartphone-centered product where UX flows, notifications and local state need early specification.", signals: ["mobile", "ios", "android", "notification", "offline"], recommendedDepth: "standard", clusters: ["project", "product", "ux", "architecture", "frontend", "data", "implementation", "quality"] }, + { id: "self-hosted-platform", label: "Self-hosted platform", summary: "Deployable platform with Docker, database, operations, backup and reliability concerns.", signals: ["self-hosted", "docker", "unraid", "homelab", "postgres", "backup", "container"], recommendedDepth: "deep", clusters: ["project", "product", "architecture", "frontend", "backend", "data", "operations", "implementation", "quality"] }, + { id: "ai-platform", label: "AI-assisted platform", summary: "AI behavior, context, prompt contracts, review discipline and truth boundaries must be documented.", signals: ["ai", "context", "prompt", "codex", "model", "agent", "review", "truth", "memory"], recommendedDepth: "deep", clusters: ["project", "product", "architecture", "ai-context", "backend", "data", "implementation", "quality"] }, + { id: "game-rom-hack", label: "Game / ROM-hack design", summary: "Game-design-heavy project with lore, progression, quests, flags, events and implementation constraints.", signals: ["game", "rom", "hack", "pokemon", "quest", "flags", "lore", "event", "region"], recommendedDepth: "full", clusters: ["project", "product", "game-design", "architecture", "implementation", "quality"] }, + { id: "browser-extension", label: "Browser extension", summary: "Browser-extension project with permissions, content scripts, background service and capture rules.", signals: ["extension", "chrome", "firefox", "manifest", "content script", "background", "permissions"], recommendedDepth: "standard", clusters: ["project", "product", "ux", "architecture", "frontend", "implementation", "quality"] }, + { id: "api-backend-service", label: "API / backend service", summary: "Backend-first service where data ownership, API contracts and operations drive docs.", signals: ["api", "backend", "service", "fastify", "database", "prisma", "endpoint"], recommendedDepth: "deep", clusters: ["project", "architecture", "backend", "data", "operations", "implementation", "quality"] }, + { id: "documentation-knowledge-project", label: "Documentation / knowledge system", summary: "Documentation-heavy project needing taxonomy, source discipline, versioning and truth boundaries.", signals: ["documentation", "knowledge", "wiki", "docs", "memory", "taxonomy", "legal"], recommendedDepth: "deep", clusters: ["project", "product", "ai-context", "data", "implementation", "quality"] }, +]; + +const nodeTemplates: NodeTemplate[] = [ + { id: "project-vision", title: "Project Vision", cluster: "project", requiredDepth: "light", dependsOn: [], outputPath: "docs/00-project/project-vision.md", purpose: "Define why the project exists, who it serves and what success means.", requiredContext: ["project name", "domain", "target audience", "goals", "non-goals"], qualityChecks: ["States target audience", "Separates goals from non-goals", "Defines success metrics"], produces: ["north-star summary", "scope boundary"] }, + { id: "project-dna", title: "Project DNA", cluster: "project", requiredDepth: "light", dependsOn: ["project-vision"], outputPath: "docs/00-project/project-dna.md", purpose: "Turn project identity into reusable AI context.", requiredContext: ["vision", "principles", "constraints", "maturity stage"], qualityChecks: ["Includes principles", "Includes constraints", "Is reusable as AI context"], produces: ["AI-ready project identity", "principles"] }, + { id: "product-requirements", title: "Product Requirements", cluster: "product", requiredDepth: "standard", dependsOn: ["project-vision", "project-dna"], outputPath: "docs/01-product/product-requirements.md", purpose: "Translate vision into user-facing capabilities and explicit boundaries.", requiredContext: ["goals", "audience", "features", "risks"], qualityChecks: ["Prioritizes capabilities", "Contains out-of-scope items", "Links requirements to user value"], produces: ["functional requirements", "priority map"] }, + { id: "user-personas", title: "User Personas and Usage Context", cluster: "product", requiredDepth: "standard", dependsOn: ["project-vision"], outputPath: "docs/01-product/user-personas.md", purpose: "Document users, jobs-to-be-done and constraints shaping UX.", requiredContext: ["target audience", "domain", "workflows"], qualityChecks: ["Concrete situations", "Pain points", "Links to workflows"], produces: ["personas", "job stories"] }, + { id: "core-workflows", title: "Core Workflows", cluster: "product", requiredDepth: "standard", dependsOn: ["product-requirements", "user-personas"], outputPath: "docs/01-product/core-workflows.md", purpose: "Define main user journeys for stable AI implementation targets.", requiredContext: ["requirements", "personas", "features"], qualityChecks: ["Trigger/steps/outcome", "Empty/error states", "Links to features"], produces: ["workflow list", "edge cases"] }, + { id: "feature-map", title: "Feature Map", cluster: "product", requiredDepth: "standard", dependsOn: ["product-requirements"], outputPath: "docs/01-product/feature-map.md", purpose: "Create structured feature inventory with statuses and dependencies.", requiredContext: ["feature groups", "tasks", "bugs", "decisions"], qualityChecks: ["Groups features", "Shows statuses", "Captures dependencies"], produces: ["feature inventory", "implementation slices"] }, + { id: "system-architecture", title: "System Architecture", cluster: "architecture", requiredDepth: "standard", dependsOn: ["project-dna", "core-workflows", "feature-map"], outputPath: "docs/02-architecture/system-architecture.md", purpose: "Define system boundaries, modules, integrations and risks.", requiredContext: ["stack", "constraints", "features", "risks"], qualityChecks: ["Names boundaries", "Separates layers", "Lists risks"], produces: ["module map", "architecture risks"] }, + { id: "data-model", title: "Data Model", cluster: "data", requiredDepth: "standard", dependsOn: ["feature-map", "system-architecture"], outputPath: "docs/02-architecture/data-model.md", purpose: "Define core entities, ownership, lifecycle and migration risks.", requiredContext: ["features", "workflows", "database constraints"], qualityChecks: ["Lists entities", "Defines ownership", "Flags migration risks"], produces: ["entity list", "persistence risks"], appliesTo: ["webapp", "mobile-app", "self-hosted-platform", "ai-platform", "api-backend-service", "documentation-knowledge-project"] }, + { id: "frontend-architecture", title: "Frontend Architecture", cluster: "frontend", requiredDepth: "standard", dependsOn: ["system-architecture"], outputPath: "docs/02-architecture/frontend-architecture.md", purpose: "Define routes, state ownership, component boundaries and data loading.", requiredContext: ["UX", "routes", "state", "real/mock boundaries"], qualityChecks: ["Route groups", "State ownership", "Loading/error states"], produces: ["route model", "component rules"], appliesTo: ["webapp", "mobile-app", "self-hosted-platform", "browser-extension", "ai-platform"] }, + { id: "backend-architecture", title: "Backend Architecture", cluster: "backend", requiredDepth: "standard", dependsOn: ["system-architecture", "data-model"], outputPath: "docs/02-architecture/backend-architecture.md", purpose: "Define API layering, repositories, validation and failure modes.", requiredContext: ["architecture", "data model", "routes"], qualityChecks: ["API boundaries", "Repository ownership", "Failure modes"], produces: ["service boundaries", "API risks"], appliesTo: ["webapp", "self-hosted-platform", "ai-platform", "api-backend-service"] }, + { id: "api-contracts", title: "API Contracts", cluster: "backend", requiredDepth: "deep", dependsOn: ["backend-architecture", "data-model"], outputPath: "docs/02-architecture/api-contracts.md", purpose: "Specify request/response shapes, errors and compatibility rules.", requiredContext: ["backend", "data model", "frontend needs"], qualityChecks: ["Endpoints", "Response envelopes", "Errors"], produces: ["endpoint contracts", "DTO inventory"], appliesTo: ["webapp", "self-hosted-platform", "ai-platform", "api-backend-service"] }, + { id: "ai-role-definition", title: "AI Role Definition", cluster: "ai-context", requiredDepth: "standard", dependsOn: ["project-dna"], outputPath: "docs/03-ai/ai-role-definition.md", purpose: "Define what AI may do and how output becomes reviewed truth.", requiredContext: ["DNA", "review queue", "accepted truth"], qualityChecks: ["AI boundaries", "Review required", "Uncertainty handling"], produces: ["AI operating rules", "truth boundary"], appliesTo: ["ai-platform", "documentation-knowledge-project", "self-hosted-platform"] }, + { id: "context-pack-strategy", title: "Context Pack Strategy", cluster: "ai-context", requiredDepth: "deep", dependsOn: ["ai-role-definition", "feature-map"], outputPath: "docs/03-ai/context-pack-strategy.md", purpose: "Define how context packs are selected, refreshed and audited.", requiredContext: ["context packs", "selector decisions", "memory rules"], qualityChecks: ["Pack categories", "Freshness rules", "Selector rationale"], produces: ["pack taxonomy", "selection rules"], appliesTo: ["ai-platform", "documentation-knowledge-project", "self-hosted-platform"] }, + { id: "review-before-truth-policy", title: "Review-before-Truth Policy", cluster: "ai-context", requiredDepth: "standard", dependsOn: ["ai-role-definition"], outputPath: "docs/03-ai/review-before-truth-policy.md", purpose: "Document that generated output is never truth until reviewed.", requiredContext: ["review queue", "accepted truth", "operating log"], qualityChecks: ["Source states", "Approval flow", "Rejection flow"], produces: ["approval workflow", "truth boundary"], appliesTo: ["ai-platform", "documentation-knowledge-project", "self-hosted-platform"] }, + { id: "ux-architecture", title: "UX Architecture", cluster: "ux", requiredDepth: "standard", dependsOn: ["user-personas", "core-workflows"], outputPath: "docs/04-ux/information-architecture.md", purpose: "Define navigation, hierarchy and cross-device UX rules.", requiredContext: ["personas", "workflows", "mobile constraints"], qualityChecks: ["Navigation", "Responsive behavior", "Empty states"], produces: ["navigation model", "screen hierarchy"], appliesTo: ["webapp", "mobile-app", "self-hosted-platform", "browser-extension", "ai-platform"] }, + { id: "game-design-pillars", title: "Game Design Pillars", cluster: "game-design", requiredDepth: "standard", dependsOn: ["project-vision", "project-dna"], outputPath: "docs/game-design/design-pillars.md", purpose: "Define gameplay identity, thematic pillars and constraints.", requiredContext: ["theme", "core loop", "player fantasy"], qualityChecks: ["Pillars", "Mechanic links", "Scope boundaries"], produces: ["design pillars", "mechanic principles"], appliesTo: ["game-rom-hack"] }, + { id: "quest-event-flag-map", title: "Quest, Event and Flag Map", cluster: "game-design", requiredDepth: "deep", dependsOn: ["game-design-pillars", "core-workflows"], outputPath: "docs/game-design/quest-event-flag-map.md", purpose: "Translate story/gameplay structure into quest chains, flags and scripted events.", requiredContext: ["story", "quests", "events", "flags"], qualityChecks: ["Quest dependencies", "Flags", "Progression links"], produces: ["quest graph", "flag table"], appliesTo: ["game-rom-hack"] }, + { id: "implementation-roadmap", title: "Implementation Roadmap", cluster: "implementation", requiredDepth: "standard", dependsOn: ["feature-map", "system-architecture"], outputPath: "docs/05-implementation/implementation-roadmap.md", purpose: "Break the project into delivery phases and Codex-ready slices.", requiredContext: ["features", "architecture", "validation"], qualityChecks: ["Ordered phases", "Validation per phase", "Risks"], produces: ["phases", "task slices"] }, + { id: "repo-structure", title: "Repo Documentation Structure", cluster: "implementation", requiredDepth: "deep", dependsOn: ["implementation-roadmap"], outputPath: "docs/05-implementation/repo-structure.md", purpose: "Define where design docs, AI context and implementation notes belong.", requiredContext: ["roadmap", "export requirements"], qualityChecks: ["Folders", "Naming rules", "AI context paths"], produces: ["repo docs tree", "export rules"] }, + { id: "deployment-plan", title: "Deployment and Operations Plan", cluster: "operations", requiredDepth: "deep", dependsOn: ["backend-architecture", "data-model"], outputPath: "docs/06-operations/deployment.md", purpose: "Document topology, env vars, healthchecks, backup and restore.", requiredContext: ["runtime", "Docker", "database"], qualityChecks: ["Env variables", "Healthchecks", "Backups"], produces: ["deployment guide", "ops checklist"], appliesTo: ["webapp", "self-hosted-platform", "ai-platform", "api-backend-service"] }, + { id: "validation-strategy", title: "Validation Strategy", cluster: "quality", requiredDepth: "standard", dependsOn: ["core-workflows", "system-architecture"], outputPath: "docs/07-quality/validation-strategy.md", purpose: "Define how generated work is checked before being trusted.", requiredContext: ["workflows", "risks", "build commands"], qualityChecks: ["Smoke checks", "Acceptance gates", "Regression memory"], produces: ["smoke matrix", "quality gates"] }, + { id: "acceptance-criteria", title: "Acceptance Criteria", cluster: "quality", requiredDepth: "standard", dependsOn: ["core-workflows", "feature-map", "validation-strategy"], outputPath: "docs/07-quality/acceptance-criteria.md", purpose: "Turn requirements into testable definitions of done.", requiredContext: ["requirements", "workflows", "features"], qualityChecks: ["Testable language", "Edge cases", "Links to workflows"], produces: ["definition of done", "edge-case checklist"] }, +]; + +const inferArchetype = (project: ProjectDetailDto): Archetype => { + const source = [project.name, project.description, project.domain, project.dna.vision, project.dna.targetAudience, ...project.dna.goals, ...project.dna.principles, ...(project.constraints?.map((item) => `${item.title} ${item.description}`) ?? [])].join(" ").toLowerCase(); + const scored = archetypes.map((archetype) => ({ archetype, score: archetype.signals.reduce((total, signal) => total + (source.includes(signal) ? 1 : 0), 0) })).sort((left, right) => right.score - left.score); + return scored[0]?.score > 0 ? scored[0].archetype : archetypes[0]; +}; + +const nodeApplies = (node: NodeTemplate, archetype: Archetype, depth: BlueprintDepthDto) => { + if (depthRank[node.requiredDepth] > depthRank[depth]) return false; + if (node.appliesTo?.length && !node.appliesTo.includes(archetype.id)) return false; + if (!archetype.clusters.includes(node.cluster)) return node.cluster === "project" || node.cluster === "quality"; + return true; +}; + +export const createBlueprintPlanDto = (project: ProjectDetailDto, contextPacks: ContextPackDto[], depth?: BlueprintDepthDto) => { + const archetype = inferArchetype(project); + const selectedDepth = depth ?? archetype.recommendedDepth; + const templates = nodeTemplates.filter((node) => nodeApplies(node, archetype, selectedDepth)); + const templateIds = new Set(templates.map((node) => node.id)); + const titles = new Map(templates.map((node) => [node.id, node.title])); + const nodes = templates.map((node, index) => { + const blockedBy = node.dependsOn.filter((dependency) => !templateIds.has(dependency)); + const intent = `${node.title} design documentation for ${project.name}. ${node.purpose}. ${node.requiredContext.join(" ")}. ${node.qualityChecks.join(" ")}. ${project.dna.vision}`; + const contextSelection = selectContextPacks(project.id, contextPacks, { mode: node.cluster === "quality" ? "validation" : node.cluster === "ai-context" ? "ai" : "documentation", intent, targetType: "documentation", targetId: node.id, maxPacks: 3 }, project.activeContextPackId); + return { + ...node, + projectId: project.id, + dependencyTitles: node.dependsOn.map((dependency) => titles.get(dependency) ?? dependency), + blockedBy, + generationOrder: index + 1, + status: blockedBy.length ? "blocked" : node.dependsOn.length ? "pending" : "ready", + contextSelection, + promptPreview: [`Generate the ${node.title} document for ${project.name}.`, `Purpose: ${node.purpose}`, `Use context pack: ${contextSelection.primaryContextPackId ?? "none selected"}.`, `Required context: ${node.requiredContext.join(", ")}.`, `Quality checks: ${node.qualityChecks.join("; ")}.`, "Do not treat generated content as accepted truth; send the draft to review first."].join("\n"), + exportReady: blockedBy.length === 0, + }; + }); + const clusterIds = [...new Set(nodes.map((node) => node.cluster))]; + const clusters = clusterIds.map((clusterId) => { + const clusterNodes = nodes.filter((node) => node.cluster === clusterId); + return { id: clusterId, label: clusterId.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "), planned: clusterNodes.length, ready: clusterNodes.filter((node) => node.status !== "blocked").length, blocked: clusterNodes.filter((node) => node.status === "blocked").length, exportPaths: clusterNodes.map((node) => node.outputPath) }; + }); + const blockedNodes = nodes.filter((node) => node.status === "blocked").length; + return { + id: `bp-${project.id}-${archetype.id}-${selectedDepth}`, + projectId: project.id, + archetype, + depth: selectedDepth, + status: blockedNodes ? "blocked" : "planned", + generatedAt: new Date().toISOString(), + intakeSummary: `${project.name} is treated as a ${archetype.label.toLowerCase()} with ${nodes.length} planned design-documentation nodes.`, + assumptions: [`Blueprint archetype inferred as ${archetype.label}.`, `Blueprint depth set to ${selectedDepth}.`, "Generated documents are draft candidates until reviewed and accepted."], + documentRoot: "docs/", + totalNodes: nodes.length, + readyNodes: nodes.filter((node) => node.status !== "blocked").length, + blockedNodes, + clusters, + nodes, + exportManifest: nodes.map((node) => node.outputPath), + recommendedNextActions: ["Review the inferred archetype and depth before generation.", "Start with Project Vision and Project DNA.", "Send drafts to Review Queue instead of accepting them automatically.", "Use the manifest as the repo documentation folder structure."], + }; +}; + +export type BlueprintPlanDto = ReturnType; +export type BlueprintNodeDto = BlueprintPlanDto["nodes"][number]; + +export const createBlueprintIndexMarkdownDto = (plan: BlueprintPlanDto): string => [ + `# ${plan.archetype.label} Blueprint Documentation Index`, + "", + plan.intakeSummary, + "", + `- Depth: ${plan.depth}`, + `- Nodes: ${plan.totalNodes}`, + `- Ready: ${plan.readyNodes}`, + `- Blocked: ${plan.blockedNodes}`, + "", + "## Export manifest", + "", + ...plan.nodes.map((node) => `- [ ] ${node.outputPath} — ${node.title} (${node.status})`), + "", + "## Generation order", + "", + ...plan.nodes.map((node) => `${node.generationOrder}. ${node.title} → ${node.outputPath}`), + "", + "## Review rule", + "", + "Generated documents are drafts until reviewed. Accepted truth remains the only source allowed to drive future project state.", +].join("\n"); + +export const createBlueprintNodeDraftMarkdownDto = (plan: BlueprintPlanDto, node: BlueprintNodeDto): string => { + const dependencies = node.dependencyTitles.length ? node.dependencyTitles.join(", ") : "None. This is a root document."; + const primaryContext = node.contextSelection.primaryContextPackId ?? "No primary context pack selected."; + const selectedPacks = node.contextSelection.selectedContextPackIds.length ? node.contextSelection.selectedContextPackIds.join(", ") : "None"; + return [ + `# ${node.title}`, + "", + "> Draft generated by ProjectBrain Blueprint Engine for review. Do not treat this document as accepted truth until reviewed.", + "", + "## Purpose", + "", + node.purpose, + "", + "## Blueprint metadata", + "", + `- Project: ${plan.projectId}`, + `- Archetype: ${plan.archetype.label}`, + `- Depth: ${plan.depth}`, + `- Cluster: ${node.cluster}`, + `- Generation order: ${node.generationOrder}`, + `- Output path: ${node.outputPath}`, + `- Dependencies: ${dependencies}`, + `- Primary context pack: ${primaryContext}`, + `- Selected context packs: ${selectedPacks}`, + "", + "## Required context", + "", + ...node.requiredContext.map((entry) => `- ${entry}`), + "", + "## Quality contract", + "", + ...node.qualityChecks.map((entry) => `- [ ] ${entry}`), + "", + "## Expected outputs", + "", + ...node.produces.map((entry) => `- ${entry}`), + "", + "## Draft outline", + "", + "### Summary", + "", + `This document should define ${node.title.toLowerCase()} for the project using the selected blueprint context.`, + "", + "### Key decisions to document", + "", + ...node.produces.map((entry) => `- Define ${entry}.`), + "", + "### Open questions", + "", + "- Which assumptions still need evidence before acceptance?", + "- Which downstream documents become stale if this document changes?", + "- Which implementation or Codex tasks should reference this document after acceptance?", + "", + "## Review gate", + "", + "This draft should be reviewed, corrected and accepted before it is exported as final project documentation or used as accepted implementation context.", + ].join("\n"); +}; + +export const createBlueprintNodeRunPreviewDto = (plan: BlueprintPlanDto, node: BlueprintNodeDto) => { + const draftMarkdown = createBlueprintNodeDraftMarkdownDto(plan, node); + return { + id: `bpr-${plan.id}-${node.id}`, + projectId: plan.projectId, + graphId: plan.id, + nodeId: node.id, + status: "needs_review", + generatedAt: new Date().toISOString(), + generatedBy: "deterministic", + inputSnapshot: { + projectId: plan.projectId, + archetypeId: plan.archetype.id, + depth: plan.depth, + dependencyIds: node.dependsOn, + selectedContextPackIds: node.contextSelection.selectedContextPackIds, + primaryContextPackId: node.contextSelection.primaryContextPackId, + }, + prompt: node.promptPreview, + draftMarkdown, + qualityReport: { + score: Math.min(95, 55 + node.qualityChecks.length * 6 + node.contextSelection.selectedContextPackIds.length * 5), + checks: node.qualityChecks.map((label) => ({ label, passed: true, note: "Contract is present in the draft checklist; human review must still validate the actual content." })), + warnings: node.contextSelection.primaryContextPackId ? [] : ["No primary context pack was selected for this node."], + }, + }; +}; + + +export const createBlueprintImpactReportDto = (plan: BlueprintPlanDto, sourceNodeId: string) => { + const source = plan.nodes.find((node) => node.id === sourceNodeId) ?? plan.nodes[0]; + const visited = new Set(); + const walk = (nodeId: string) => { + for (const candidate of plan.nodes) { + if (candidate.dependsOn.includes(nodeId) && !visited.has(candidate.id)) { + visited.add(candidate.id); + walk(candidate.id); + } + } + }; + walk(source.id); + const downstream = plan.nodes.filter((node) => visited.has(node.id)); + const direct = plan.nodes.filter((node) => node.dependsOn.includes(source.id)); + return { + graphId: plan.id, + sourceNodeId: source.id, + sourceTitle: source.title, + directNodeIds: direct.map((node) => node.id), + downstreamNodeIds: downstream.map((node) => node.id), + staleCandidateCount: downstream.length, + affectedExportPaths: downstream.map((node) => node.outputPath), + summary: downstream.length ? `${source.title} can make ${downstream.length} downstream blueprint document(s) stale.` : `${source.title} has no downstream blueprint dependencies in this plan.`, + }; +}; + +export const createBlueprintRepoExportBundleDto = (plan: BlueprintPlanDto, options: { includeDrafts?: boolean; draftByNodeId?: Record } = {}) => { + const includeDrafts = options.includeDrafts ?? false; + const warnings: string[] = []; + const files: Array<{ path: string; title: string; nodeId: string; status: string; content: string; source: string }> = []; + for (const node of plan.nodes) { + if (node.status === "accepted" || node.status === "exported") { + files.push({ path: node.outputPath, title: node.title, nodeId: node.id, status: node.status, content: options.draftByNodeId?.[node.id] ?? createBlueprintNodeDraftMarkdownDto(plan, node), source: "accepted-document" }); + continue; + } + if (includeDrafts && node.status !== "blocked") { + files.push({ path: node.outputPath.replace(/\.md$/, ".draft.md"), title: `${node.title} (draft)`, nodeId: node.id, status: node.status, content: options.draftByNodeId?.[node.id] ?? createBlueprintNodeDraftMarkdownDto(plan, node), source: "draft-preview" }); + continue; + } + warnings.push(`${node.title} skipped because status is ${node.status}.`); + } + const manifestMarkdown = [ + "# Blueprint repo export", + "", + `- Graph: ${plan.id}`, + `- Archetype: ${plan.archetype.label}`, + `- Depth: ${plan.depth}`, + `- Generated: ${new Date().toISOString()}`, + `- Include drafts: ${includeDrafts ? "yes" : "no"}`, + "", + "## Files", + "", + ...files.map((file) => `- ${file.path} — ${file.title} (${file.source})`), + "", + "## Warnings", + "", + ...(warnings.length ? warnings.map((warning) => `- ${warning}`) : ["- None"]), + ].join("\n"); + files.unshift({ path: "docs/_blueprint/export-index.md", title: "Blueprint Export Index", nodeId: "blueprint-export-index", status: "accepted", content: manifestMarkdown, source: "index" }); + files.push({ path: "docs/_blueprint/export-manifest.json", title: "Blueprint Export Manifest", nodeId: "blueprint-export-manifest", status: "accepted", content: JSON.stringify({ graphId: plan.id, generatedAt: new Date().toISOString(), files: files.map((file) => ({ path: file.path, source: file.source, nodeId: file.nodeId })) }, null, 2), source: "manifest" }); + return { + id: `bpx-${plan.id}-${Date.now().toString(36)}`, + projectId: plan.projectId, + graphId: plan.id, + generatedAt: new Date().toISOString(), + includeDrafts, + acceptedCount: plan.nodes.filter((node) => node.status === "accepted" || node.status === "exported").length, + draftCount: files.filter((file) => file.source === "draft-preview").length, + skippedCount: warnings.length, + files, + manifestMarkdown, + warnings, + }; +}; + + +const crc32Table = Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value >>> 0; +}); + +const crc32ForBlueprintZip = (buffer: Buffer): number => { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = crc32Table[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +}; + +const writeUInt16 = (value: number) => { + const buffer = Buffer.alloc(2); + buffer.writeUInt16LE(value & 0xffff, 0); + return buffer; +}; + +const writeUInt32 = (value: number) => { + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value >>> 0, 0); + return buffer; +}; + +const dosDateTime = (date = new Date()) => { + const year = Math.max(1980, date.getFullYear()); + const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { dosDate, dosTime }; +}; + +const normalizeZipPath = (value: string) => value.replace(/^\/+/, "").replace(/\\/g, "/"); + +export const createBlueprintRepoExportZipBufferDto = (bundle: ReturnType): Buffer => { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + const { dosDate, dosTime } = dosDateTime(); + + for (const file of bundle.files) { + const fileName = Buffer.from(normalizeZipPath(file.path), "utf8"); + const content = Buffer.from(file.content, "utf8"); + const crc = crc32ForBlueprintZip(content); + + const localHeader = Buffer.concat([ + writeUInt32(0x04034b50), + writeUInt16(20), + writeUInt16(0x0800), + writeUInt16(0), + writeUInt16(dosTime), + writeUInt16(dosDate), + writeUInt32(crc), + writeUInt32(content.length), + writeUInt32(content.length), + writeUInt16(fileName.length), + writeUInt16(0), + fileName, + ]); + localParts.push(localHeader, content); + + const centralHeader = Buffer.concat([ + writeUInt32(0x02014b50), + writeUInt16(20), + writeUInt16(20), + writeUInt16(0x0800), + writeUInt16(0), + writeUInt16(dosTime), + writeUInt16(dosDate), + writeUInt32(crc), + writeUInt32(content.length), + writeUInt32(content.length), + writeUInt16(fileName.length), + writeUInt16(0), + writeUInt16(0), + writeUInt16(0), + writeUInt16(0), + writeUInt32(0), + writeUInt32(offset), + fileName, + ]); + centralParts.push(centralHeader); + offset += localHeader.length + content.length; + } + + const centralDirectory = Buffer.concat(centralParts); + const endOfCentralDirectory = Buffer.concat([ + writeUInt32(0x06054b50), + writeUInt16(0), + writeUInt16(0), + writeUInt16(bundle.files.length), + writeUInt16(bundle.files.length), + writeUInt32(centralDirectory.length), + writeUInt32(offset), + writeUInt16(0), + ]); + + return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory]); +}; diff --git a/backend/src/services/canonicalLifecycle.ts b/backend/src/services/canonicalLifecycle.ts new file mode 100644 index 0000000..ff8fb4d --- /dev/null +++ b/backend/src/services/canonicalLifecycle.ts @@ -0,0 +1,17 @@ +import type { CanonicalLifecycle } from "@projectbrain/contracts"; +import type { AutonomyState } from "../repositories/autonomyRepository.js"; + +/** + * Backend counterpart of the shared lifecycle contract. The durable autonomy + * state remains the source of truth; this function only projects it into the + * finite vocabulary exposed to portfolio and operational consumers. + */ +export const canonicalLifecycleFromAutonomy = (state: AutonomyState, now = Date.now()): CanonicalLifecycle => { + if (state.blocker?.kind === "human") return "needs_user"; + if (state.blocker?.kind === "retryable" && state.enabled) return "recovering"; + if (state.enabled && state.activeLease && Date.parse(state.activeLease.expiresAt) > now) return "running"; + if (state.enabled && state.roadmap?.phases.some((phase) => phase.items.some((item) => item.status !== "completed" && item.scope !== "future" && item.scope !== "stretch" && item.waitingExternal))) return "waiting"; + if (state.status === "completed" || state.roadmap?.status === "completed") return "done"; + if (state.status === "blocked") return "failed"; + return "ready"; +}; diff --git a/backend/src/services/commandIntent.ts b/backend/src/services/commandIntent.ts new file mode 100644 index 0000000..935dc12 --- /dev/null +++ b/backend/src/services/commandIntent.ts @@ -0,0 +1,64 @@ +import { prisma } from "../db/prisma.js"; +import { configureAutonomy, getAutonomyState, resumeAutonomy } from "../repositories/autonomyRepository.js"; + +export type CommandIntent = { + objective: "continue" | "release" | "pause" | "prioritize_blockers" | "unknown"; + constraints: string[]; + allowedScope: Array<"required" | "committed" | "stretch" | "future">; + policy: { deploy: "allowed" | "blocked" | "unchanged"; pauseAfterCurrent: boolean; automaticRecovery: boolean }; + requestedCompletionState: "next_runnable" | "definition_of_done" | "paused" | "unknown"; + source: "deterministic_command_parser"; + confidence: number; + interpretation: string; +}; + +const json = (value: unknown) => JSON.parse(JSON.stringify(value)); + +export const parseCommandIntent = (input: string): CommandIntent => { + const text = input.trim().toLocaleLowerCase("nl-BE"); + const continueWork = /\b(continue|doorgaan|verder|hervat|resume)\b/.test(text); + const release = /\b(release|releas|opleveren|production|productie|ship)\b/.test(text); + const pause = /\b(pause|pauzeer|stop|stopt)\b/.test(text); + const blockers = /\b(blocker|blokker|belangrijkste|important|critical|kritiek)\b/.test(text); + const noDeploy = /\b(don['’]?t|do not|geen|niet|zonder)\b[^.]{0,30}\b(deploy|deployment|deployen|uitrollen)\b/.test(text); + const explicitDeploy = /\b(deploy|deployment|deployen|uitrollen)\b/.test(text) && !noDeploy; + const pauseAfterCurrent = pause && /\b(after|na|current|huidig|huidige)\b/.test(text); + const objective: CommandIntent["objective"] = release ? "release" : blockers ? "prioritize_blockers" : pause ? "pause" : continueWork ? "continue" : "unknown"; + const requestedCompletionState: CommandIntent["requestedCompletionState"] = release ? "definition_of_done" : pause ? "paused" : objective === "unknown" ? "unknown" : "next_runnable"; + const constraints = [ + ...(noDeploy ? ["Do not deploy or change deployment state."] : []), + ...(pauseAfterCurrent ? ["Pause after the currently leased work item completes."] : []), + ...(blockers ? ["Prioritize work with the highest downstream blocking impact."] : []), + ]; + return { + objective, + constraints, + allowedScope: ["required", "committed"], + policy: { deploy: noDeploy ? "blocked" : explicitDeploy ? "allowed" : "unchanged", pauseAfterCurrent, automaticRecovery: !pause }, + requestedCompletionState, + source: "deterministic_command_parser", + confidence: objective === "unknown" ? 35 : 90, + interpretation: objective === "unknown" ? "No supported operating command was detected; no action should be taken." : `Interpretation: ${objective.replaceAll("_", " ")}. ${constraints.length ? constraints.join(" ") : "Use normal ProjectBrain policy."}`, + }; +}; + +export const applyCommandIntent = async (projectIdOrSlug: string, intent: CommandIntent, actor = "owner") => { + if (intent.objective === "unknown") throw new Error("COMMAND_INTENT_UNSUPPORTED"); + const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, select: { id: true } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + let state = await getAutonomyState(project.id); + if (intent.objective === "continue" || intent.objective === "release") { + if (state.status === "blocked") await resumeAutonomy(project.id, actor); + state = await configureAutonomy(project.id, { enabled: true, pauseAfterCurrent: false }, actor); + } else if (intent.objective === "pause") { + state = await configureAutonomy(project.id, { enabled: !intent.policy.pauseAfterCurrent, pauseAfterCurrent: intent.policy.pauseAfterCurrent }, actor); + } else if (intent.objective === "prioritize_blockers") { + state = await configureAutonomy(project.id, { enabled: true, pauseAfterCurrent: false, schedulingPolicy: "downstream-impact" }, actor); + } + const workspace = await prisma.workspaceState.findUnique({ where: { projectId: project.id }, select: { id: true, notesJson: true } }); + const notes = workspace?.notesJson && typeof workspace.notesJson === "object" && !Array.isArray(workspace.notesJson) ? workspace.notesJson as Record : {}; + const automation = notes.automation && typeof notes.automation === "object" && !Array.isArray(notes.automation) ? notes.automation as Record : {}; + const nextAutomation = { ...automation, ...(intent.policy.deploy !== "unchanged" ? { deploy: intent.policy.deploy === "allowed" } : {}), ...(intent.objective === "prioritize_blockers" ? { scheduling: "downstream-impact" } : {}) }; + if (workspace) await prisma.workspaceState.update({ where: { id: workspace.id }, data: { notesJson: json({ ...notes, automation: nextAutomation }), updatedBy: actor, sourceType: "command_control" } }); + return { intent, applied: true, state }; +}; diff --git a/backend/src/services/contextPackSelector.ts b/backend/src/services/contextPackSelector.ts new file mode 100644 index 0000000..664019b --- /dev/null +++ b/backend/src/services/contextPackSelector.ts @@ -0,0 +1,172 @@ +import type { ContextPackDto, ContextPackSelectionRequestDto, ContextPackSelectionResponseDto, ContextPackSelectionCandidateDto } from "../types/api.js"; + +const tokenize = (value: string | undefined): string[] => + (value ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .split(" ") + .map((token) => token.trim()) + .filter((token) => token.length >= 3); + +const unique = (values: T[]): T[] => [...new Set(values)]; + +const textForPack = (pack: ContextPackDto): string => + [ + pack.title, + pack.category, + pack.summary, + ...(pack.assumptions ?? []), + ...(pack.constraints ?? []), + ...(pack.signals ?? []), + ...(pack.architectureNotes ?? []), + ...(pack.risks ?? []), + ...(pack.regressions ?? []), + pack.currentFocus?.title, + pack.currentFocus?.purpose, + pack.generatedBy, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + +const hasAny = (values: string[] | undefined, target: string | undefined): boolean => + Boolean(target && values?.includes(target)); + +const addReason = (reasons: string[], reason: string) => { + if (!reasons.includes(reason)) reasons.push(reason); +}; + +const clampScore = (score: number): number => Math.max(0, Math.min(100, Math.round(score))); + +const scorePack = ( + pack: ContextPackDto, + request: ContextPackSelectionRequestDto, + activeContextPackId?: string, +): ContextPackSelectionCandidateDto => { + const mode = request.mode ?? "workspace"; + const targetType = request.targetType; + const targetId = request.targetId; + const intentTokens = tokenize(request.intent); + const packText = textForPack(pack); + const reasons: string[] = []; + const warnings: string[] = []; + let score = 8; + + if (pack.id === activeContextPackId) { + score += 12; + addReason(reasons, "Current active context pack: keeps continuity unless a more specific pack clearly wins."); + } + + const qualityScore = pack.quality?.score ?? 50; + score += qualityScore * 0.22; + if (qualityScore >= 80) addReason(reasons, "High context-pack quality score."); + if (qualityScore < 60) warnings.push("Quality score is below the normal handoff threshold."); + + score += pack.trust.confidence * 0.2; + if (pack.trust.confidence >= 85) addReason(reasons, "High trust/confidence metadata."); + if (pack.trust.approvalState !== "approved") warnings.push(`Approval state is ${pack.trust.approvalState}; keep review-before-truth discipline.`); + + const tokenMatches = unique(intentTokens.filter((token) => packText.includes(token))); + if (tokenMatches.length > 0) { + score += Math.min(20, tokenMatches.length * 4); + addReason(reasons, `Intent match: ${tokenMatches.slice(0, 5).join(", ")}.`); + } + + if (targetType === "feature" && hasAny(pack.featureIds, targetId)) { + score += 28; + addReason(reasons, "Direct feature match."); + } + if (targetType === "bug" && hasAny(pack.bugIds, targetId)) { + score += 26; + addReason(reasons, "Direct bug match."); + } + if (targetType === "task" && hasAny(pack.taskIds, targetId)) { + score += 24; + addReason(reasons, "Direct task match."); + } + if (targetType === "decision" && hasAny(pack.decisionIds, targetId)) { + score += 22; + addReason(reasons, "Direct decision/rule match."); + } + if (targetType === "memory-rule" && hasAny(pack.memoryRuleIds, targetId)) { + score += 22; + addReason(reasons, "Direct memory-rule match."); + } + + if (mode === "ai" || mode === "codex") { + if (pack.quality?.dnaIncluded) score += 5; + if (pack.quality?.featuresIncluded) score += 5; + if (pack.quality?.evidenceIncluded) score += 6; + if (pack.quality?.memoryIncluded) score += 5; + if (pack.quality?.risksIncluded) score += 4; + if (pack.quality?.doNotBreakIncluded) score += 4; + addReason(reasons, "AI/Codex mode prefers packs with DNA, evidence, memory rules, risks and do-not-break context."); + } + + if (mode === "documentation") { + if (pack.category === "business" || pack.category === "quality" || pack.category === "memory") score += 8; + if ((pack.constraints?.length ?? 0) > 0) score += 4; + addReason(reasons, "Documentation mode favors stable constraints, memory and quality context."); + } + + if (mode === "validation") { + if (pack.category === "quality" || pack.category === "risk") score += 10; + if ((pack.risks?.length ?? 0) > 0 || (pack.regressions?.length ?? 0) > 0) score += 8; + addReason(reasons, "Validation mode favors risk, regression and quality context."); + } + + if (pack.category === "architecture" && /architect|backend|frontend|api|database|docker|prisma|route|component/.test(request.intent ?? "")) { + score += 12; + addReason(reasons, "Architecture keyword match."); + } + + if (!request.includeUnapproved && pack.trust.approvalState === "rejected") { + score -= 50; + warnings.push("Rejected packs are heavily deprioritized by default."); + } + + return { + contextPackId: pack.id, + title: pack.title, + category: pack.category, + score: clampScore(score), + reasons: reasons.length ? reasons : ["General project-context match."], + warnings, + qualityScore: pack.quality?.score, + confidence: pack.trust.confidence, + approvalState: pack.trust.approvalState, + generatedBy: pack.generatedBy, + }; +}; + +export const selectContextPacks = ( + projectId: string, + contextPacks: ContextPackDto[], + request: ContextPackSelectionRequestDto, + activeContextPackId?: string, +): ContextPackSelectionResponseDto => { + const maxPacks = Math.max(1, Math.min(request.maxPacks ?? 3, 8)); + const mode = request.mode ?? "workspace"; + const scored = contextPacks + .filter((pack) => pack.projectId === projectId) + .map((pack) => scorePack(pack, { ...request, mode }, activeContextPackId)) + .sort((left, right) => right.score - left.score || right.confidence - left.confidence); + + const selected = scored.slice(0, maxPacks); + const primary = selected[0]; + + return { + projectId, + mode, + intent: request.intent?.trim() || "General workspace context", + targetType: request.targetType, + targetId: request.targetId, + selectedContextPackIds: selected.map((candidate) => candidate.contextPackId), + primaryContextPackId: primary?.contextPackId, + candidates: scored, + decisionSummary: primary + ? `Selected '${primary.title}' as primary pack for ${mode} mode with score ${primary.score}.` + : "No context packs are available for this project.", + generatedAt: new Date().toISOString(), + }; +}; diff --git a/backend/src/services/contextResolver.ts b/backend/src/services/contextResolver.ts new file mode 100644 index 0000000..f1d0380 --- /dev/null +++ b/backend/src/services/contextResolver.ts @@ -0,0 +1,128 @@ +import { createHash } from "node:crypto"; +import { prisma } from "../db/prisma.js"; + +export type ContextResolverInput = { + focus?: string; + maxItems?: number; + cursor?: string; + characterBudget?: number; +}; + +const boundedText = (value: string | null | undefined, maximum: number) => { + if (!value) return value; + return value.length <= maximum ? value : `${value.slice(0, maximum - 1)}…`; +}; + +const cursorOffset = (cursor?: string) => { + const value = Number.parseInt(cursor ?? "0", 10); + return Number.isSafeInteger(value) && value >= 0 ? value : 0; +}; + +type ResumeDelivery = { + id: string; + title: string; + status: string; + approvalState: string; + runs?: Array<{ branch?: string | null; headSha?: string | null; pullRequestNumber?: number | null; status?: string | null }>; +}; +type ResumeRepository = { id: string; owner: string; repository: string; accessMode: string; defaultBranch: string }; +type ResumeReview = { id: string; priority: string; updatedAt: Date | string }; + +const completedDeliveryStatuses = new Set(["verified", "released", "cancelled", "superseded"]); + +export const buildProjectContextResume = ( + repositories: ResumeRepository[], + deliveries: ResumeDelivery[], + pendingReviews: ResumeReview[], + projectUpdatedAt?: Date | string, +) => { + const priority: Record = { pull_request_open: 0, validating: 1, executing: 2, merged: 3, approved: 4, awaiting_approval: 5 }; + const activeDelivery = deliveries.filter((delivery) => !completedDeliveryStatuses.has(delivery.status)) + .sort((left, right) => (priority[left.status] ?? 6) - (priority[right.status] ?? 6))[0]; + const run = activeDelivery?.runs?.[0]; + let nextAction: Record; + if (!repositories.length) { + nextAction = { type: "onboard-repository", reason: "This project has no linked Gitea repository." }; + } else if (activeDelivery?.approvalState === "pending" || activeDelivery?.status === "awaiting_approval") { + nextAction = { type: "review-delivery-plan", deliveryId: activeDelivery.id, reason: "The next delivery plan is waiting for owner approval." }; + } else if (activeDelivery?.status === "approved") { + nextAction = { type: "start-delivery", deliveryId: activeDelivery.id, reason: "The delivery is approved and ready for an isolated branch." }; + } else if (activeDelivery?.status === "executing") { + nextAction = { type: "continue-implementation", deliveryId: activeDelivery.id, branch: run?.branch, reason: "Resume the active delivery from durable repository state." }; + } else if (activeDelivery?.status === "validating") { + nextAction = { type: "sync-validation", deliveryId: activeDelivery.id, branch: run?.branch, reason: "Wait for or synchronize managed validation for the exact branch head." }; + } else if (activeDelivery?.status === "pull_request_open") { + nextAction = { type: "review-and-merge", deliveryId: activeDelivery.id, pullRequestNumber: run?.pullRequestNumber, reason: "The delivery has an open pull request ready for review." }; + } else if (activeDelivery?.status === "merged") { + nextAction = { type: "verify-merge", deliveryId: activeDelivery.id, reason: "Post-merge verification is still pending." }; + } else if (activeDelivery) { + nextAction = { type: "inspect-delivery", deliveryId: activeDelivery.id, reason: `The active delivery is ${activeDelivery.status}.` }; + } else if (pendingReviews.length) { + nextAction = { type: "review-project-candidates", reviewId: pendingReviews[0].id, reason: "ProjectBrain has reviewable candidate knowledge." }; + } else { + nextAction = { type: "select-or-plan-delivery", reason: "No active delivery or pending review needs attention." }; + } + + const state = { + repositories: repositories.map(({ id, accessMode, defaultBranch }) => ({ id, accessMode, defaultBranch })), + deliveries: deliveries.map(({ id, status, approvalState, runs }) => ({ id, status, approvalState, run: runs?.[0] })), + pendingReviews: pendingReviews.map(({ id, priority, updatedAt }) => ({ id, priority, updatedAt })), + projectUpdatedAt, + }; + return { + stateVersion: createHash("sha256").update(JSON.stringify(state)).digest("hex").slice(0, 24), + repositoryCount: repositories.length, + pendingReviewCount: pendingReviews.length, + activeDelivery: activeDelivery ? { id: activeDelivery.id, title: activeDelivery.title, status: activeDelivery.status, approvalState: activeDelivery.approvalState, branch: run?.branch, headSha: run?.headSha, pullRequestNumber: run?.pullRequestNumber } : null, + nextAction, + }; +}; + +export const resolveProjectContext = async (projectId: string, input: ContextResolverInput = {}) => { + const take = Math.min(Math.max(input.maxItems ?? 10, 1), 25); + const skip = cursorOffset(input.cursor); + const characterBudget = Math.min(Math.max(input.characterBudget ?? 24_000, 4_000), 80_000); + const focus = input.focus?.trim(); + const titleFilter = focus ? { contains: focus, mode: "insensitive" as const } : undefined; + + const [project, repositories, contextPacks, deliveries, memoryRules, knowledge, documentation, pendingReviews, openPullRequest] = await Promise.all([ + prisma.project.findUnique({ where: { id: projectId }, select: { + id: true, slug: true, name: true, description: true, domain: true, status: true, updatedAt: true, + dna: { select: { vision: true, goalsJson: true, nonGoalsJson: true, targetAudience: true, principlesJson: true, metricsJson: true, maturityStage: true, revision: true, updatedAt: true } }, + workspaceState: { select: { activeContextPackId: true, primaryObjective: true, activeSection: true, notesJson: true, updatedAt: true } }, + } }), + prisma.giteaRepositoryLink.findMany({ where: { projectId }, orderBy: [{ isPrimary: "desc" }, { role: "asc" }], select: { id: true, owner: true, repository: true, role: true, isPrimary: true, defaultBranch: true, accessMode: true, syncStatus: true, lastSyncAt: true } }), + prisma.contextPack.findMany({ where: { projectId, deletedAt: null, ...(titleFilter ? { title: titleFilter } : {}) }, orderBy: [{ approvalState: "desc" }, { updatedAt: "desc" }], skip, take, select: { id: true, slug: true, title: true, summary: true, category: true, confidence: true, approvalState: true, updatedAt: true } }), + prisma.deliveryPlan.findMany({ where: { projectId, ...(titleFilter ? { title: titleFilter } : {}) }, orderBy: { updatedAt: "desc" }, skip, take, select: { id: true, slug: true, title: true, intent: true, status: true, approvalState: true, version: true, updatedAt: true, runs: { orderBy: { createdAt: "desc" }, take: 1, select: { branch: true, headSha: true, pullRequestNumber: true, pullRequestUrl: true, status: true } } } }), + prisma.memoryRule.findMany({ where: { projectId, deletedAt: null, includeInContextPacks: true, approvalState: "approved", ...(titleFilter ? { OR: [{ title: titleFilter }, { rule: titleFilter }] } : {}) }, orderBy: [{ severity: "desc" }, { updatedAt: "desc" }], skip, take, select: { id: true, title: true, rule: true, scope: true, category: true, severity: true, confidence: true, updatedAt: true } }), + prisma.knowledgeItem.findMany({ where: { projectId, deletedAt: null, approvalState: "approved", ...(titleFilter ? { OR: [{ title: titleFilter }, { content: titleFilter }] } : {}) }, orderBy: [{ confidence: "desc" }, { updatedAt: "desc" }], skip, take, select: { id: true, title: true, category: true, content: true, source: true, confidence: true, verificationState: true, updatedAt: true } }), + prisma.documentationItem.findMany({ where: { projectId, deletedAt: null, approvalState: "approved", ...(titleFilter ? { OR: [{ title: titleFilter }, { content: titleFilter }] } : {}) }, orderBy: { updatedAt: "desc" }, skip, take, select: { id: true, title: true, kind: true, status: true, content: true, updatedAt: true } }), + prisma.reviewQueueItem.findMany({ where: { projectId, deletedAt: null, status: "pending", ...(titleFilter ? { OR: [{ title: titleFilter }, { description: titleFilter }] } : {}) }, orderBy: [{ priority: "asc" }, { updatedAt: "desc" }], skip, take, select: { id: true, title: true, description: true, type: true, priority: true, confidence: true, suggestedAction: true, updatedAt: true } }), + prisma.deliveryPlan.findFirst({ where: { projectId, status: "pull_request_open" }, orderBy: { updatedAt: "asc" }, select: { id: true, title: true, status: true, approvalState: true, runs: { orderBy: { createdAt: "desc" }, take: 1, select: { branch: true, headSha: true, pullRequestNumber: true, status: true } } } }), + ]); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + + const hasMore = [contextPacks, deliveries, memoryRules, knowledge, documentation, pendingReviews] + .some((items) => items.length === take); + const resume = buildProjectContextResume(repositories, openPullRequest ? [openPullRequest, ...deliveries.filter((delivery) => delivery.id !== openPullRequest.id)] : deliveries, pendingReviews, project.updatedAt); + + const payload = { + resolver: { focus: focus || null, characterBudget, pageSize: take, cursor: String(skip), ...(hasMore ? { nextCursor: String(skip + take) } : {}), generatedAt: new Date().toISOString(), truthPolicy: "Only approved knowledge, documentation and memory rules are included as project truth; pending reviews remain candidates." }, + resume, + project, + repositories, + contextPacks: contextPacks.map((item) => ({ ...item, summary: boundedText(item.summary, 1_500) })), + deliveries, + acceptedTruth: { + memoryRules: memoryRules.map((item) => ({ ...item, rule: boundedText(item.rule, 1_500) })), + knowledge: knowledge.map((item) => ({ ...item, content: boundedText(item.content, 2_000) })), + documentation: documentation.map((item) => ({ ...item, content: boundedText(item.content, 2_000) })), + }, + candidates: { pendingReviews: pendingReviews.map((item) => ({ ...item, description: boundedText(item.description, 1_200), suggestedAction: boundedText(item.suggestedAction, 800) })) }, + }; + + const serialized = JSON.stringify(payload); + return serialized.length <= characterBudget + ? { ...payload, resolver: { ...payload.resolver, approximateCharacters: serialized.length, truncated: false } } + : { ...payload, acceptedTruth: { memoryRules: payload.acceptedTruth.memoryRules.slice(0, 3), knowledge: payload.acceptedTruth.knowledge.slice(0, 3), documentation: payload.acceptedTruth.documentation.slice(0, 3) }, candidates: { pendingReviews: payload.candidates.pendingReviews.slice(0, 3) }, resolver: { ...payload.resolver, approximateCharacters: serialized.length, truncated: true, truncationReason: "character_budget", nextCursor: String(skip + Math.min(take, 3)) } }; +}; diff --git a/backend/src/services/deliveryReconciliation.ts b/backend/src/services/deliveryReconciliation.ts new file mode 100644 index 0000000..b935a36 --- /dev/null +++ b/backend/src/services/deliveryReconciliation.ts @@ -0,0 +1,44 @@ +import { prisma } from "../db/prisma.js"; +import { GiteaApiError, type GiteaClient } from "../integrations/gitea/client.js"; +import { getFeatureDelivery, reconcileFeatureDeliveryPullRequest } from "../repositories/featureDeliveryRepository.js"; +import { resolveProjectContext, type ContextResolverInput } from "./contextResolver.js"; + +export const resolveReconciledProjectContext = async (client: GiteaClient, projectId: string, input: ContextResolverInput) => { + const context = await resolveProjectContext(projectId, input); + if (!context.resume.activeDelivery) return context; + try { + const delivery = await refreshDeliveryFromGitea(client, projectId, context.resume.activeDelivery.id); + return delivery.spec.status === context.resume.activeDelivery.status ? context : resolveProjectContext(projectId, input); + } catch { + return { ...context, resume: { ...context.resume, nextAction: { type: "reconcile-delivery", deliveryId: context.resume.activeDelivery.id, reason: "Repository state could not be verified. Inspect the existing delivery and PR before writing or creating another branch." } } }; + } +}; + +export const refreshDeliveryFromGitea = async (client: GiteaClient, projectId: string, deliveryId: string) => { + const delivery = await getFeatureDelivery(projectId, deliveryId); + if (["verified", "released", "superseded", "cancelled"].includes(delivery.spec.status)) return delivery; + const plan = await prisma.deliveryPlan.findFirstOrThrow({ where: { project: { OR: [{ id: projectId }, { slug: projectId }] }, OR: [{ id: deliveryId }, { slug: deliveryId }] }, include: { runs: { orderBy: { createdAt: "desc" }, take: 1, include: { repositoryLink: true } } } }); + const run = plan.runs[0]; + if (!run) return delivery; + const link = run.repositoryLink; + if (run.pullRequestNumber) { + const pr = await client.getPullRequest(link.owner, link.repository, run.pullRequestNumber); + if (pr.head.ref !== run.branch || pr.base.ref !== link.defaultBranch) return delivery; + if (pr.merged || pr.state === "closed") return await reconcileFeatureDeliveryPullRequest({ owner: link.owner, repository: link.repository, number: pr.number, state: "closed", merged: pr.merged, headSha: pr.head.sha, mergeCommitSha: pr.merge_commit_sha, mergedAt: pr.merged_at, actor: "system:gitea-reconciliation" }) ?? delivery; + return delivery; + } + if (!run.headSha || !["executing", "validating"].includes(delivery.spec.status)) return delivery; + try { await client.getBranch(link.owner, link.repository, run.branch); return delivery; } + catch (error) { if (!(error instanceof GiteaApiError && error.status === 404)) throw error; } + const base = await client.getBranch(link.owner, link.repository, link.defaultBranch); + // An empty two-dot comparison proves the recorded head is already reachable + // from main. A missing branch alone is never completion evidence. + const comparison = await client.compareCommits(link.owner, link.repository, base.commit.id, run.headSha); + if (comparison.total_commits !== 0) return delivery; + await prisma.$transaction(async (tx) => { + const updated = await tx.deliveryRun.updateMany({ where: { id: run.id, status: run.status, headSha: run.headSha }, data: { status: "superseded", mergeCommitSha: base.commit.id, completedAt: new Date(), updatedBy: "system:gitea-reconciliation" } }); + if (!updated.count) return; + await tx.deliveryPlan.update({ where: { id: plan.id }, data: { status: "superseded", reviewFeedback: `Recorded work ${run.headSha} is included in default branch ${base.commit.id}; the original branch was removed. Separate delivery validation is not inferred.`, updatedBy: "system:gitea-reconciliation" } }); + }); + return getFeatureDelivery(projectId, deliveryId); +}; diff --git a/backend/src/services/diagnostics.ts b/backend/src/services/diagnostics.ts new file mode 100644 index 0000000..ef66d0f --- /dev/null +++ b/backend/src/services/diagnostics.ts @@ -0,0 +1,224 @@ +import type { Prisma } from "@prisma/client"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { reclaimExpiredWork, getProjectWorkGraph } from "./workGraph.js"; +import { getGiteaClient } from "../integrations/gitea/service.js"; + +export type HealthDimension = "process" | "transport" | "authentication" | "authorization" | "api" | "functional_read" | "write_readiness"; +export type InterpretedHealth = "healthy" | "degraded" | "unavailable"; + +export type DiagnosticCheck = { + id: string; + label: string; + status: "pass" | "warn" | "fail" | "skip"; + summary: string; + detail?: string; + repairAction?: string; + technical?: Record; +}; + +export type DiagnosticReport = { + id?: string; + generatedAt: string; + scope: "platform" | "project"; + status: InterpretedHealth; + summary: string; + checks: DiagnosticCheck[]; + connectorHealth: { + status: InterpretedHealth; + dimensions: Record; + latencyMs?: number; + latestSuccessfulProbe?: string; + }; + stuck: Array<{ kind: string; summary: string; repairable: boolean; referenceId?: string }>; + slo: Array<{ id: string; target: string; observed: string; status: "met" | "at_risk" | "missed" | "unknown" }>; +}; + +export type DiagnosticHistoryEntry = Pick; + +const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const nowIso = () => new Date().toISOString(); +const asRecord = (value: unknown): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +const safeError = (error: unknown) => error instanceof Error ? error.message.slice(0, 300) : "Unknown diagnostic error"; + +export const interpretConnectorHealth = (input: Partial>): InterpretedHealth => { + const values = Object.values(input); + if (values.length === 0 || input.process === false || input.api === false) return "unavailable"; + if (values.some((value) => value === false)) return "degraded"; + return "healthy"; +}; + +export const detectStuckConditions = (input: { + now?: number; + staleAfterMs?: number; + running?: Array<{ id: string; lastProgressAt?: string; leaseExpiresAt?: string }>; + queued?: Array<{ id: string; queuedAt: string; consumerPresent: boolean }>; + validatedButStale?: Array<{ id: string; ageMs: number }>; + functionalProbeFailed?: boolean; +}) => { + const now = input.now ?? Date.now(); + const staleAfterMs = input.staleAfterMs ?? 8 * 60_000; + const stuck: Array<{ kind: string; summary: string; repairable: boolean; referenceId?: string }> = []; + for (const item of input.running ?? []) { + if (item.leaseExpiresAt && Date.parse(item.leaseExpiresAt) <= now) stuck.push({ kind: "expired_lease", summary: `Work ${item.id} has an expired lease and can be reclaimed.`, repairable: true, referenceId: item.id }); + else if (!item.lastProgressAt || now - Date.parse(item.lastProgressAt) > staleAfterMs) stuck.push({ kind: "stale_progress", summary: `Work ${item.id} has no meaningful progress within the expected interval.`, repairable: true, referenceId: item.id }); + } + for (const item of input.queued ?? []) if (!item.consumerPresent && now - Date.parse(item.queuedAt) > staleAfterMs) stuck.push({ kind: "queue_without_consumer", summary: `Queued work ${item.id} has no active consumer.`, repairable: true, referenceId: item.id }); + for (const item of input.validatedButStale ?? []) if (item.ageMs > staleAfterMs) stuck.push({ kind: "stale_delivery_state", summary: `Delivery ${item.id} has green validation evidence but stale internal state.`, repairable: true, referenceId: item.id }); + if (input.functionalProbeFailed) stuck.push({ kind: "functional_connector_failure", summary: "The connector process is reachable, but a safe functional read probe failed.", repairable: false }); + return stuck; +}; + +const check = (id: string, label: string, status: DiagnosticCheck["status"], summary: string, detail?: string, technical?: Record, repairAction?: string): DiagnosticCheck => ({ id, label, status, summary, ...(detail ? { detail } : {}), ...(technical ? { technical } : {}), ...(repairAction ? { repairAction } : {}) }); + +const connectorDimensions = (env: AppEnv, input: { database: boolean; projectRead: boolean; latencyMs: number; giteaConfigured: boolean; giteaProbe: boolean; writeConfigured: boolean; workerFresh: boolean; executorFresh: boolean }) => { + const checkedAt = nowIso(); + const dimensions: Record = { + process: { status: "healthy", summary: "ProjectBrain API process is responding.", checkedAt }, + transport: { status: "healthy", summary: "The current API transport accepted this diagnostic request.", checkedAt }, + authentication: { status: env.PROJECTBRAIN_MCP_ENABLED && !env.PROJECTBRAIN_HUB_SERVICE_TOKEN ? "degraded" : "healthy", summary: env.PROJECTBRAIN_MCP_ENABLED && !env.PROJECTBRAIN_HUB_SERVICE_TOKEN ? "MCP is enabled but hub authentication is not configured." : "Authentication configuration is present.", checkedAt }, + authorization: { status: input.giteaConfigured ? "healthy" : "degraded", summary: input.giteaConfigured ? "Connector credentials are configured for capability checks." : "No Gitea connector credentials are configured.", checkedAt }, + api: { status: input.database && (!input.giteaConfigured || input.giteaProbe) ? "healthy" : "unavailable", summary: input.database && (!input.giteaConfigured || input.giteaProbe) ? "ProjectBrain API and configured connector API accepted safe reads." : "The API or configured connector did not accept a safe read.", checkedAt }, + functional_read: { status: input.projectRead && (!input.giteaConfigured || input.giteaProbe) ? "healthy" : "unavailable", summary: input.projectRead && (!input.giteaConfigured || input.giteaProbe) ? "Safe project and connector read probes succeeded." : "A safe functional read probe failed.", checkedAt }, + write_readiness: { status: input.writeConfigured ? "healthy" : "degraded", summary: input.writeConfigured ? "Configured connector capabilities include write readiness." : "Write capability is not configured; read-only diagnostics remain safe.", checkedAt }, + }; + const status = interpretConnectorHealth(Object.fromEntries(Object.entries(dimensions).map(([key, value]) => [key, value.status !== "unavailable" && value.status !== "degraded"]))) as InterpretedHealth; + return { status, dimensions, latencyMs: input.latencyMs, latestSuccessfulProbe: input.projectRead ? checkedAt : undefined }; +}; + +export const runDiagnostics = async (env: AppEnv, options: { projectId?: string; repairSafe?: boolean; actor?: string } = {}): Promise => { + const started = Date.now(); + const checks: DiagnosticCheck[] = []; + let database = false; + let projectRead = false; + let projectId: string | undefined; + try { + await prisma.$queryRaw`SELECT 1`; + database = true; + checks.push(check("database", "Database", "pass", "Database is reachable and accepted a safe read.")); + } catch (error) { + checks.push(check("database", "Database", "fail", "ProjectBrain cannot reach the database.", safeError(error))); + } + if (database) { + try { + const projectRow = await prisma.project.findFirst({ where: { deletedAt: null, ...(options.projectId ? { OR: [{ id: options.projectId }, { slug: options.projectId }, { readableId: options.projectId }] } : {}) }, select: { id: true, slug: true } }); + projectId = projectRow?.id; + projectRead = Boolean(projectRow || !options.projectId); + checks.push(check("project-read", "Project state", projectRead ? "pass" : "fail", projectRead ? "Project state can be read safely." : "The requested project could not be found.", projectRead ? undefined : "PROJECT_NOT_FOUND")); + } catch (error) { + checks.push(check("project-read", "Project state", "fail", "Project state could not be read.", safeError(error))); + } + try { + const migrationRows = await prisma.$queryRaw>`SELECT COUNT(*)::bigint AS count FROM "_prisma_migrations" WHERE finished_at IS NOT NULL`; + checks.push(check("migrations", "Migrations", Number(migrationRows[0]?.count ?? 0) > 0 ? "pass" : "warn", Number(migrationRows[0]?.count ?? 0) > 0 ? "Database migrations are present." : "No completed migrations were found.", undefined, { count: Number(migrationRows[0]?.count ?? 0) })); + } catch (error) { + checks.push(check("migrations", "Migrations", "warn", "Migration state could not be inspected.", safeError(error))); + } + } + + let workerFresh = false; + let executorFresh = false; + try { + const [worker, executor] = await Promise.all([ + prisma.analysisWorkerHeartbeat.findUnique({ where: { id: "primary" } }), + prisma.autonomyExecutorHeartbeat.findFirst({ orderBy: { lastSeenAt: "desc" } }), + ]); + workerFresh = Boolean(worker && worker.lastSeenAt.getTime() > Date.now() - env.PROJECTBRAIN_ANALYSIS_STALE_SECONDS * 1_000); + executorFresh = Boolean(executor && executor.lastSeenAt.getTime() > Date.now() - env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS * 1_000); + checks.push(check("workers", "Workers and scheduler", workerFresh || executorFresh ? "pass" : "warn", workerFresh || executorFresh ? "At least one orchestration worker is reporting fresh progress." : "Worker heartbeats are missing or stale; durable work remains recoverable.", undefined, { analysisWorker: worker?.status ?? "missing", executor: executor?.status ?? "missing" }, "reclaim_stale_work")); + } catch (error) { + checks.push(check("workers", "Workers and scheduler", "warn", "Worker state could not be inspected.", safeError(error))); + } + + const now = Date.now(); + try { + const [staleJobs, queuedJobs, orphanedDeliveries, staleDeliveries, mergedActiveDeliveries, repositoryLinks, staleSources] = await Promise.all([ + prisma.analysisJob.findMany({ where: { status: { in: ["queued", "running"] }, OR: [{ heartbeatAt: null }, { heartbeatAt: { lt: new Date(now - env.PROJECTBRAIN_ANALYSIS_STALE_SECONDS * 1_000) } }] }, select: { id: true, status: true, heartbeatAt: true }, take: 20 }), + prisma.analysisJob.findMany({ where: { status: "queued" }, select: { id: true, queuedAt: true }, orderBy: { queuedAt: "asc" }, take: 20 }), + prisma.deliveryPlan.findMany({ where: { ...(projectId ? { projectId } : {}), status: { in: ["executing", "validating", "pull_request_open"] }, runs: { none: {} } }, select: { id: true, title: true, status: true }, take: 20 }), + prisma.deliveryPlan.findMany({ where: { ...(projectId ? { projectId } : {}), status: { in: ["executing", "validating", "pull_request_open"] }, runs: { some: { mergeCommitSha: { not: null } } } }, select: { id: true, title: true, status: true }, take: 20 }), + prisma.deliveryPlan.findMany({ where: { ...(projectId ? { projectId } : {}), status: "merged", runs: { some: { validations: { some: { status: "passed" } } } } }, select: { id: true, title: true, status: true }, take: 20 }), + prisma.giteaRepositoryLink.findMany({ where: projectId ? { projectId } : {}, select: { id: true, syncStatus: true, lastVerifiedAt: true, lastSyncAt: true, owner: true, repository: true }, take: 100 }), + prisma.projectSource.count({ where: { ...(projectId ? { projectId } : {}), OR: [{ lastSyncedAt: null }, { lastSyncedAt: { lt: new Date(now - 24 * 60 * 60 * 1_000) } }] } }), + ]); + if (staleJobs.length) checks.push(check("stale-analysis-jobs", "Analysis jobs", "warn", `${staleJobs.length} analysejob(s) hebben geen verse heartbeat en kunnen veilig worden gecontroleerd of gereclaimd.`, undefined, { jobs: staleJobs.map((job) => ({ id: job.id, status: job.status, heartbeatAt: job.heartbeatAt })) }, "reclaim_stale_work")); + else checks.push(check("stale-analysis-jobs", "Analysis jobs", "pass", "Queued and running analysis jobs have a recent heartbeat.")); + const queueWithoutConsumer = queuedJobs.filter((job) => !workerFresh && now - job.queuedAt.getTime() > 8 * 60_000); + if (queueWithoutConsumer.length) checks.push(check("queue-consumer", "Analysewachtrij", "warn", `${queueWithoutConsumer.length} analysejob(s) wachten al langer dan verwacht zonder actieve consumer.`, undefined, { queued: queueWithoutConsumer.map((job) => job.id) }, "restart_analysis_worker")); + else checks.push(check("queue-consumer", "Analysewachtrij", "pass", "De analysewachtrij heeft een actieve consumer of bevat geen te lang wachtende jobs.")); + if (orphanedDeliveries.length) checks.push(check("orphaned-deliveries", "Delivery state", "warn", `${orphanedDeliveries.length} actieve delivery(s) hebben nog geen run; ProjectBrain kan ze opnieuw plannen.`, orphanedDeliveries.map((delivery) => `${delivery.title} (${delivery.status})`).join("; "), undefined, "reconcile_delivery_state")); + else checks.push(check("orphaned-deliveries", "Delivery state", "pass", "Actieve deliveries hebben een gekoppelde uitvoeringsrun of zijn niet actief.")); + if (staleDeliveries.length) checks.push(check("merged-active-deliveries", "Delivery-reconciliatie", "warn", `${staleDeliveries.length} actieve delivery(s) bevatten al mergebewijs en moeten opnieuw met de externe staat worden vergeleken.`, staleDeliveries.map((delivery) => `${delivery.title} (${delivery.status})`).join("; "), undefined, "reconcile_delivery_state")); + else checks.push(check("merged-active-deliveries", "Delivery-reconciliatie", "pass", "Geen actieve delivery met onverwacht mergebewijs gevonden.")); + if (mergedActiveDeliveries.length) checks.push(check("merged-validation-state", "Validatie naar delivery", "warn", `${mergedActiveDeliveries.length} gemergde delivery(s) hebben groene validatie maar staan nog niet als geverifieerd of uitgebracht geregistreerd.`, mergedActiveDeliveries.map((delivery) => delivery.title).join("; "), undefined, "reconcile_delivery_state")); + else checks.push(check("merged-validation-state", "Validatie naar delivery", "pass", "Groene validatie en deliverystatus zijn consistent in de gecontroleerde scope.")); + const staleLinks = repositoryLinks.filter((link) => !link.lastSyncAt || link.lastSyncAt.getTime() < now - 24 * 60 * 60 * 1_000); + checks.push(check("repository-sync", "Repository sync", staleLinks.length ? "warn" : "pass", staleLinks.length ? `${staleLinks.length} repositorykoppeling(en) zijn ouder dan één dag.` : "Repositorykoppelingen zijn recent gesynchroniseerd.", undefined, { linked: repositoryLinks.length, stale: staleLinks.map((link) => `${link.owner}/${link.repository}`) })); + checks.push(check("source-freshness", "Bronfreshness", staleSources ? "warn" : "pass", staleSources ? `${staleSources} bron(nen) zijn ouder dan één dag of nog niet gesynchroniseerd.` : "Projectbronnen zijn recent gesynchroniseerd.")); + } catch (error) { + checks.push(check("orchestration-integrity", "Orchestration integrity", "warn", "Queue, delivery or repository-sync state could not be fully inspected.", safeError(error))); + } + + let graph: Awaited> | undefined; + let reclaimedCount = 0; + if (projectId) { + try { + if (options.repairSafe) reclaimedCount = (await reclaimExpiredWork(projectId, options.actor ?? "diagnostics")).reclaimedCount; + graph = await getProjectWorkGraph(projectId); + checks.push(check("work-graph", "Cross-domain work graph", "pass", `${graph.counts.ready} work item(s) are ready, ${graph.counts.waiting} waiting and ${graph.counts.blocked} blocked.`, undefined, { counts: graph.counts }, reclaimedCount ? "reclaim_stale_work" : undefined)); + } catch (error) { + checks.push(check("work-graph", "Cross-domain work graph", "warn", "Work graph state is not fully available yet.", safeError(error))); + } + } + const giteaConfigured = Boolean(env.PROJECTBRAIN_GITEA_URL && env.PROJECTBRAIN_GITEA_TOKEN); + let giteaProbe = false; + let giteaProbeDetail: string | undefined; + if (giteaConfigured) { + try { await getGiteaClient(env).getCurrentUser(); giteaProbe = true; } + catch (error) { giteaProbeDetail = safeError(error); } + } + checks.push(check("gitea", "Gitea connection", !giteaConfigured ? "warn" : giteaProbe ? "pass" : "fail", !giteaConfigured ? "Gitea is not configured; repository work cannot be verified remotely." : giteaProbe ? "Gitea accepted a safe authenticated read probe." : "Gitea credentials or API reachability failed the safe read probe.", giteaProbeDetail)); + const mcpReady = env.PROJECTBRAIN_MCP_ENABLED && Boolean(env.PROJECTBRAIN_HUB_SERVICE_TOKEN) && Boolean(env.PROJECTBRAIN_PUBLIC_URL); + checks.push(check("mcp", "MCP connector", !env.PROJECTBRAIN_MCP_ENABLED ? "skip" : mcpReady ? "pass" : "warn", !env.PROJECTBRAIN_MCP_ENABLED ? "MCP is disabled in this environment." : mcpReady ? "MCP transport, public callback and hub authentication are configured." : "MCP is enabled but the hub token or public callback is missing.", undefined, undefined, mcpReady ? undefined : "configure_mcp_capability")); + checks.push(check("webhook", "Gitea webhook path", env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET && env.PROJECTBRAIN_PUBLIC_URL ? "pass" : "warn", env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET && env.PROJECTBRAIN_PUBLIC_URL ? "Signed Gitea webhook processing is configured." : "Webhook signing or public callback configuration is missing; repository events may arrive late.")); + checks.push(check("backup", "Backup and recovery evidence", "warn", "Backup evidence is project-specific and must be captured before a release that requires it.", undefined, undefined, "capture_backup_evidence")); + checks.push(check("deployment", "Deployment identity", "warn", "Deployment identity requires a deployed revision probe; local diagnostics do not invent one.", undefined, undefined, "verify_deployment_identity")); + + const connectorHealth = connectorDimensions(env, { database, projectRead, latencyMs: Date.now() - started, giteaConfigured, giteaProbe, writeConfigured: Boolean(env.PROJECTBRAIN_GITEA_WRITE_TOKEN), workerFresh, executorFresh }); + const stuck = detectStuckConditions({ + functionalProbeFailed: connectorHealth.status === "unavailable" && database, + running: graph?.nodes.filter((node) => node.executionEligibility === "running").map((node) => ({ id: node.id, lastProgressAt: node.lastMeaningfulProgressAt, leaseExpiresAt: node.leaseExpiresAt })), + queued: graph?.nodes.filter((node) => node.executionEligibility === "ready").map((node) => ({ id: node.id, queuedAt: node.createdAt, consumerPresent: executorFresh })), + }); + if (reclaimedCount) stuck.push({ kind: "reclaimed_lease", summary: `${reclaimedCount} stale work lease(s) were safely reclaimed.`, repairable: true }); + if (stuck.length) checks.push(check("stuck-detector", "Stuck work detector", "warn", `${stuck.length} situation(s) need recovery or inspection.`, stuck.map((item) => item.summary).join(" "), undefined, stuck.some((item) => item.repairable) ? "reclaim_stale_work" : undefined)); + else checks.push(check("stuck-detector", "Stuck work detector", "pass", "No stuck work pattern was detected in the inspected scope.")); + + const status: InterpretedHealth = checks.some((item) => item.status === "fail") || connectorHealth.status === "unavailable" ? "unavailable" : checks.some((item) => item.status === "warn") || connectorHealth.status === "degraded" ? "degraded" : "healthy"; + const summary = status === "healthy" ? "ProjectBrain diagnostics found no actionable platform issue." : `${checks.filter((item) => item.status === "fail" || item.status === "warn").length} issue(s) detected; safe repairs and technical evidence are available behind the details.`; + const report: DiagnosticReport = { generatedAt: nowIso(), scope: options.projectId ? "project" : "platform", status, summary, checks, connectorHealth, stuck, slo: [ + { id: "claim-latency", target: "Runnable work claimed within 5 minutes", observed: graph ? `${graph.counts.ready} runnable item(s) currently visible` : "not observed", status: graph ? "met" : "unknown" }, + { id: "freshness", target: "Portfolio state visibly fresh within 60 seconds", observed: `${Date.now() - started}ms diagnostic latency`, status: Date.now() - started < 60_000 ? "met" : "at_risk" }, + { id: "stale-lease", target: "Expired leases reclaimed on the next diagnostic cycle", observed: reclaimedCount ? `${reclaimedCount} reclaimed` : "none found", status: "met" }, + { id: "connector-outage", target: "Connector capability failure detected by functional probe", observed: connectorHealth.status, status: connectorHealth.status === "healthy" ? "met" : "at_risk" }, + ] }; + try { + const saved = await prisma.diagnosticRun.create({ data: { ...(projectId ? { projectId } : {}), scope: report.scope, status: report.status, summary: report.summary, checksJson: json(report.checks), repairPlanJson: json(report.stuck.filter((item) => item.repairable)), createdBy: options.actor ?? "diagnostics", completedAt: new Date() } }); + report.id = saved.id; + } catch { + // Diagnostics remain useful during a migration window even when the new + // history table is not available yet. + } + return report; +}; + +export const listDiagnosticHistory = async (projectIdOrSlug?: string, limit = 10): Promise => { + let projectId: string | undefined; + if (projectIdOrSlug) { + const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, select: { id: true } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + projectId = project.id; + } + const rows = await prisma.diagnosticRun.findMany({ where: projectId ? { projectId } : {}, orderBy: { createdAt: "desc" }, take: Math.max(1, Math.min(50, Math.trunc(limit))), select: { id: true, createdAt: true, scope: true, status: true, summary: true } }); + return rows.map((row) => ({ id: row.id, generatedAt: row.createdAt.toISOString(), scope: row.scope === "project" ? "project" : "platform", status: row.status === "healthy" || row.status === "degraded" || row.status === "unavailable" ? row.status : "degraded", summary: row.summary ?? "Diagnose opgeslagen zonder samenvatting." })); +}; diff --git a/backend/src/services/evidenceGraph.ts b/backend/src/services/evidenceGraph.ts new file mode 100644 index 0000000..b087aa0 --- /dev/null +++ b/backend/src/services/evidenceGraph.ts @@ -0,0 +1,76 @@ +import { prisma } from "../db/prisma.js"; + +export type EvidenceGraphNode = { id: string; type: string; label: string; status: string; reference?: string; evidence?: string[] }; +export type EvidenceGraphEdge = { from: string; to: string; relation: string }; + +export type EvidenceGraph = { + generatedAt: string; + nodes: EvidenceGraphNode[]; + edges: EvidenceGraphEdge[]; + chains: Array<{ workId: string; title: string; status: string; stages: Array<{ type: string; label: string; status: string; nodeId: string }> }>; +}; + +const asRecord = (value: unknown): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +const containsReference = (value: unknown, reference: string): boolean => { + if (typeof value === "string") return value === reference; + if (Array.isArray(value)) return value.some((item) => containsReference(item, reference)); + if (value && typeof value === "object") return Object.values(value as Record).some((item) => containsReference(item, reference)); + return false; +}; + +export const getProjectEvidenceGraph = async (projectIdOrSlug: string): Promise => { + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }, select: { id: true } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + const [work, deliveries, releaseEvidence] = await Promise.all([ + prisma.workGraphNode.findMany({ where: { projectId: project.id, scope: { in: ["required", "committed"] } }, include: { evidence: true }, orderBy: { updatedAt: "desc" }, take: 300 }), + prisma.deliveryPlan.findMany({ where: { projectId: project.id }, orderBy: { updatedAt: "desc" }, take: 200, include: { runs: { orderBy: { createdAt: "desc" }, take: 3, include: { validations: true, changes: true } } } }), + prisma.releaseEvidenceRecord.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { capturedAt: "desc" }, take: 200 }), + ]); + const nodes: EvidenceGraphNode[] = []; + const edges: EvidenceGraphEdge[] = []; + const addNode = (node: EvidenceGraphNode) => { if (!nodes.some((existing) => existing.id === node.id)) nodes.push(node); }; + const addEdge = (from: string, to: string, relation: string) => { if (!edges.some((edge) => edge.from === from && edge.to === to && edge.relation === relation)) edges.push({ from, to, relation }); }; + const chains: EvidenceGraph["chains"] = []; + for (const item of work) { + const workId = `work:${item.id}`; + addNode({ id: workId, type: "work", label: item.title, status: item.canonicalLifecycle, reference: item.sourceId, evidence: item.evidence.map((evidence) => evidence.summary) }); + const metadata = asRecord(item.metadataJson); + const linkedDeliveryId = [metadata.deliveryId, metadata.linkedDeliveryId, metadata.initiativeId].find((value): value is string => typeof value === "string"); + const matchingDeliveries = deliveries.filter((delivery) => delivery.id === item.sourceId || delivery.id === linkedDeliveryId || delivery.title === item.title || delivery.initiativeId === item.sourceId); + const stages: EvidenceGraph["chains"][number]["stages"] = [{ type: "work", label: item.title, status: item.canonicalLifecycle, nodeId: workId }]; + for (const delivery of matchingDeliveries) { + const deliveryId = `delivery:${delivery.id}`; + addNode({ id: deliveryId, type: "delivery", label: delivery.title, status: delivery.status, reference: delivery.id }); + addEdge(workId, deliveryId, "delivered-by"); + stages.push({ type: "delivery", label: delivery.title, status: delivery.status, nodeId: deliveryId }); + for (const run of delivery.runs) { + const headSha = run.headSha; + const commitId = headSha ? `commit:${headSha}` : undefined; + if (headSha && commitId) { addNode({ id: commitId, type: "commit", label: headSha, status: run.status, reference: headSha }); addEdge(deliveryId, commitId, "implemented-as"); stages.push({ type: "commit", label: headSha, status: run.status, nodeId: commitId }); } + for (const validation of run.validations) { + const validationId = `validation:${validation.id}`; + addNode({ id: validationId, type: "validation", label: validation.command, status: validation.status, reference: validation.id }); + addEdge(commitId ?? deliveryId, validationId, "validated-by"); + stages.push({ type: "validation", label: validation.command, status: validation.status, nodeId: validationId }); + } + if (run.pullRequestNumber) { + const prId = `pr:${delivery.id}:${run.pullRequestNumber}`; + addNode({ id: prId, type: "pull_request", label: `PR #${run.pullRequestNumber}`, status: run.pullRequestState ?? run.status, reference: run.pullRequestUrl ?? undefined }); + addEdge(commitId ?? deliveryId, prId, "reviewed-by"); stages.push({ type: "pull_request", label: `PR #${run.pullRequestNumber}`, status: run.pullRequestState ?? run.status, nodeId: prId }); + } + if (run.mergeCommitSha) { + const mergeId = `merge:${run.mergeCommitSha}`; + addNode({ id: mergeId, type: "merge", label: run.mergeCommitSha, status: "merged", reference: run.mergeCommitSha }); + addEdge(commitId ?? deliveryId, mergeId, "merged-as"); stages.push({ type: "merge", label: run.mergeCommitSha, status: "merged", nodeId: mergeId }); + } + } + for (const evidence of releaseEvidence.filter((record) => containsReference(record.metadataJson, delivery.id)).slice(0, 5)) { + const evidenceId = `release-evidence:${evidence.id}`; + addNode({ id: evidenceId, type: evidence.kind, label: evidence.title, status: evidence.verificationState, reference: evidence.id }); + addEdge(deliveryId, evidenceId, "proved-by"); stages.push({ type: evidence.kind, label: evidence.title, status: evidence.verificationState, nodeId: evidenceId }); + } + } + chains.push({ workId, title: item.title, status: item.canonicalLifecycle, stages }); + } + return { generatedAt: new Date().toISOString(), nodes, edges, chains }; +}; diff --git a/backend/src/services/featureDeliveryFlow.ts b/backend/src/services/featureDeliveryFlow.ts new file mode 100644 index 0000000..37ec066 --- /dev/null +++ b/backend/src/services/featureDeliveryFlow.ts @@ -0,0 +1,36 @@ +import type { FeatureDeliveryDto } from "../repositories/featureDeliveryRepository.js"; +import { GiteaApiError, type GiteaClient } from "../integrations/gitea/client.js"; + +export const cleanupDeliveryBranch = async (client: GiteaClient, repository: { owner: string; repository: string }, branch: string, expectedHead: string, enabled: boolean) => { + if (!enabled) return { branchDeleted: false }; + try { + const current = await client.getBranch(repository.owner, repository.repository, branch); + if (current.commit.id !== expectedHead) return { branchDeleted: false, branchCleanupError: "FEATURE_DELIVERY_MERGE_HEAD_CONFLICT" }; + await client.deleteBranch(repository.owner, repository.repository, branch); + return { branchDeleted: true }; + } catch (error) { + if (error instanceof GiteaApiError && error.status === 404) return { branchDeleted: true }; + return { branchDeleted: false, branchCleanupError: error instanceof GiteaApiError ? error.code : "BRANCH_CLEANUP_FAILED" }; + } +}; + +export const canRepairFeatureDelivery = (status: string) => ["executing", "validating", "pull_request_open"].includes(status); + +export const assertDeliveryWriteAllowed = async (delivery: FeatureDeliveryDto, branch: string, client: GiteaClient, repository: { owner: string; repository: string; defaultBranch: string }) => { + if (delivery.approvalState !== "approved" || delivery.spec.branch !== branch || !canRepairFeatureDelivery(delivery.spec.status)) throw new Error("FEATURE_DELIVERY_WRITE_FORBIDDEN"); + if (delivery.spec.pullRequest) { + const pr = await client.getPullRequest(repository.owner, repository.repository, delivery.spec.pullRequest.number); + if (pr.merged || pr.state !== "open" || pr.head.ref !== branch || pr.base.ref !== repository.defaultBranch) throw new Error("FEATURE_DELIVERY_PULL_REQUEST_NOT_WRITABLE"); + } +}; + +export const deliveryContinuation = (delivery: FeatureDeliveryDto) => { + const { spec } = delivery; + if (["superseded", "cancelled"].includes(spec.status)) return { action: "reload_project_context", detail: "This delivery is no longer active. Its retained history explains why; do not recreate its removed branch." }; + if (["merged", "verified", "released"].includes(spec.status)) return { action: "report_success", detail: "Merge is recorded. Verify the requested result and report this delivery_id to the active work lease." }; + if (delivery.approvalState !== "approved") return { action: "owner_decision", detail: "Resolve the existing delivery's actual approval decision; do not create a replacement plan." }; + if (!spec.branch) return { action: "start_feature_delivery", detail: "Start this existing approved delivery on one isolated branch." }; + if (!spec.validationEvidence) return { action: "validate_existing_branch", detail: "Resume this branch. Inspect failed-job evidence, repair the cause, run managed validation and sync_feature_validation for its current head." }; + if (!spec.pullRequest) return { action: "complete_feature_delivery", detail: "Open the PR using current verified evidence, then continue to required PR checks and merge." }; + return { action: "merge_feature_delivery", detail: "Refresh the branch head, sync current evidence, wait for required PR checks and merge this PR with expected_head_sha. Repair failures on this same branch. An open PR is not completion." }; +}; diff --git a/backend/src/services/gitSourceSync.ts b/backend/src/services/gitSourceSync.ts new file mode 100644 index 0000000..bffb37a --- /dev/null +++ b/backend/src/services/gitSourceSync.ts @@ -0,0 +1,87 @@ +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { getGiteaClient } from "../integrations/gitea/service.js"; +import type { GiteaContent } from "../integrations/gitea/types.js"; + +const MAX_FILES = 60; +const MAX_FILE_BYTES = 160_000; +const MAX_TOTAL_BYTES = 1_500_000; +const MAX_DEPTH = 5; +const SAFE_ROOT_FILES = new Set(["readme.md", "agents.md", "contributing.md", "architecture.md", "roadmap.md", "status.md", "handoff.md", "implementation-plan.md", "package.json", "pyproject.toml", "cargo.toml", "go.mod", "composer.json", "docker-compose.yml", "docker-compose.yaml"]); +const SAFE_DOC_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]); +const SAFE_STRUCTURED_PLAN_NAMES = new Set(["backlog.json", "roadmap.json", "implementation-plan.json", "implementation_plan.json"]); +const BLOCKED_PARTS = /(^|\/)(\.git|node_modules|vendor|dist|build|coverage|\.next|secrets?|credentials?)(\/|$)/i; +const SECRET_NAME = /(^|\/)(\.env($|\.)|.*\.(pem|key|p12|pfx|jks|keystore)$|id_rsa)/i; + +const extension = (path: string) => path.includes(".") ? `.${path.split(".").pop()!.toLowerCase()}` : ""; +export const isSafeGitProjectSourcePath = (path: string) => { + const normalized = path.replace(/\\/g, "/"); + if (BLOCKED_PARTS.test(normalized) || SECRET_NAME.test(normalized)) return false; + const parts = normalized.split("/"); + if (parts.length === 1) return SAFE_ROOT_FILES.has(normalized.toLowerCase()); + const inDocumentationTree = /(^|\/)(docs?|documentation|adr|architecture|decisions)(\/|$)/i.test(normalized); + if (!inDocumentationTree) return false; + const fileName = parts.at(-1)?.toLowerCase() ?? ""; + return SAFE_DOC_EXTENSIONS.has(extension(normalized)) || SAFE_STRUCTURED_PLAN_NAMES.has(fileName); +}; +const decode = (file: GiteaContent) => Buffer.from((file.content ?? "").replace(/\s/g, ""), "base64").toString("utf8"); +const summary = (text: string) => text.replace(/[#>*_`[\]]/g, " ").replace(/\s+/g, " ").trim().slice(0, 240) || "Git-bron zonder tekstuele samenvatting."; + +export type GitSourceSyncResult = { projects: number; repositories: number; created: number; updated: number; archived: number; skipped: number; errors: Array<{ repository: string; code: string }> }; + +export const syncGitSources = async (env: AppEnv, input: { projectId?: string; repositoryLinkId?: string } = {}): Promise => { + const links = await prisma.giteaRepositoryLink.findMany({ + where: { + ...(input.repositoryLinkId ? { id: input.repositoryLinkId } : {}), + project: { deletedAt: null, ...(input.projectId ? { OR: [{ id: input.projectId }, { slug: input.projectId }] } : {}) }, + }, + select: { id: true, projectId: true, owner: true, repository: true, defaultBranch: true }, + }); + const client = getGiteaClient(env); + const result: GitSourceSyncResult = { projects: new Set(links.map((link) => link.projectId)).size, repositories: links.length, created: 0, updated: 0, archived: 0, skipped: 0, errors: [] }; + for (const link of links) { + const syncedAt = new Date(); + try { + const branch = await client.getBranch(link.owner, link.repository, link.defaultBranch); + const selected: GiteaContent[] = []; + let totalBytes = 0; + const walk = async (path = "", depth = 0): Promise => { + if (depth > MAX_DEPTH || selected.length >= MAX_FILES || totalBytes >= MAX_TOTAL_BYTES) return; + const contents = await client.getContents(link.owner, link.repository, path, link.defaultBranch); + const entries = Array.isArray(contents) ? contents : [contents]; + for (const entry of entries) { + if (selected.length >= MAX_FILES || totalBytes >= MAX_TOTAL_BYTES) break; + if (entry.type === "dir") { + const mayContainSources = depth === 0 || /(^|\/)(docs?|documentation|adr|architecture|decisions)(\/|$)/i.test(entry.path); + if (mayContainSources && !BLOCKED_PARTS.test(entry.path)) await walk(entry.path, depth + 1); + } else if (entry.type === "file" && isSafeGitProjectSourcePath(entry.path) && entry.size <= MAX_FILE_BYTES && totalBytes + entry.size <= MAX_TOTAL_BYTES) { + selected.push(entry); totalBytes += entry.size; + } else if (entry.type === "file" && isSafeGitProjectSourcePath(entry.path)) result.skipped += 1; + } + }; + await walk(); + const seen: string[] = []; + for (const item of selected) { + const file = item.content ? item : await client.getContents(link.owner, link.repository, item.path, link.defaultBranch); + if (Array.isArray(file) || file.type !== "file") continue; + const content = decode(file); + if (content.includes("\u0000")) { result.skipped += 1; continue; } + seen.push(file.path); + const existing = await prisma.projectSource.findUnique({ where: { repositoryLinkId_path: { repositoryLinkId: link.id, path: file.path } }, select: { id: true, contentSha: true } }); + await prisma.projectSource.upsert({ + where: { repositoryLinkId_path: { repositoryLinkId: link.id, path: file.path } }, + create: { projectId: link.projectId, repositoryLinkId: link.id, title: file.path, path: file.path, url: file.html_url, content, summary: summary(content), branch: link.defaultBranch, contentSha: file.sha, commitSha: branch.commit.id, lastSyncedAt: syncedAt }, + update: { title: file.path, url: file.html_url, content, summary: summary(content), status: "processed", branch: link.defaultBranch, contentSha: file.sha, commitSha: branch.commit.id, lastSyncedAt: syncedAt }, + }); + if (existing) result.updated += Number(existing.contentSha !== file.sha); + else result.created += 1; + } + const archived = await prisma.projectSource.updateMany({ where: { repositoryLinkId: link.id, inputMode: "git", status: { not: "archived" }, ...(seen.length ? { path: { notIn: seen } } : {}) }, data: { status: "archived", lastSyncedAt: syncedAt } }); + result.archived += archived.count; + await prisma.giteaRepositoryLink.update({ where: { id: link.id }, data: { lastSyncAt: syncedAt, syncStatus: "ready" } }); + } catch (error) { + result.errors.push({ repository: `${link.owner}/${link.repository}`, code: error instanceof Error ? error.message : "GIT_SOURCE_SYNC_FAILED" }); + } + } + return result; +}; diff --git a/backend/src/services/giteaAutonomyExecution.ts b/backend/src/services/giteaAutonomyExecution.ts new file mode 100644 index 0000000..bd57633 --- /dev/null +++ b/backend/src/services/giteaAutonomyExecution.ts @@ -0,0 +1,343 @@ +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { assertSuccessfulActionRuns } from "../integrations/gitea/policy.js"; +import { getGiteaClient } from "../integrations/gitea/service.js"; +import { + appendAutonomyEvent, + createAutonomyEvent, + getAutonomyProject, + getAutonomyState, + hasGiteaAutonomyCompletionEvidence, + saveAutonomyState, + type AutonomyRoadmapItem, + type AutonomyState, + type AutonomyExecutionTarget, +} from "../repositories/autonomyRepository.js"; +import { evaluateProjectCompletion } from "./projectCompletion.js"; +import { isAutonomyItemInExecutionScope } from "./autonomyWork.js"; + +export const GITEA_AUTO_BRANCH_PREFIX = "projectbrain/auto/"; + +const normalizeWorkId = (value: string) => + value + .normalize("NFKD") + .replace(/[^\p{ASCII}]/gu, "") + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[._-]+|[._-]+$/g, "") + .replace(/-+/g, "-") + .slice(0, 120) || "work"; + +export const autonomyGiteaBranch = (itemId: string) => + `${GITEA_AUTO_BRANCH_PREFIX}${normalizeWorkId(itemId)}`; + +export const isGiteaAutoExecutionBranch = (branch: string) => + branch.startsWith(GITEA_AUTO_BRANCH_PREFIX); + +export const getGiteaAutonomyExecutionTarget = async ( + projectIdOrSlug: string, + itemId: string, +): Promise => { + const project = await getAutonomyProject(projectIdOrSlug); + const link = await prisma.giteaRepositoryLink.findFirst({ + where: { projectId: project.id }, + orderBy: [{ isPrimary: "desc" }, { updatedAt: "desc" }], + select: { + id: true, + owner: true, + repository: true, + defaultBranch: true, + accessMode: true, + }, + }); + if (!link || link.accessMode !== "read_write") return undefined; + return { + surface: "gitea", + claimMode: "branch", + completionMode: "validated_merge", + repositoryLinkId: link.id, + owner: link.owner, + repository: link.repository, + fullName: `${link.owner}/${link.repository}`, + defaultBranch: link.defaultBranch, + branch: autonomyGiteaBranch(itemId), + }; +}; + +const completionEvidence = ( + branch: string, + pullRequestNumber: number, + headSha: string, + mergeCommitSha: string, + validationRunIds: number[], +) => [ + `gitea:auto:branch:${branch}`, + `gitea:auto:pr:${pullRequestNumber}`, + `gitea:auto:head:${headSha}`, + `gitea:auto:merge:${mergeCommitSha}`, + ...validationRunIds.map((runId) => `gitea:auto:validation:${runId}`), +]; + +const appendUnique = (values: string[] | undefined, incoming: string[]) => + Array.from(new Set([...(values ?? []), ...incoming])); + +const markLeaseReconciled = (state: AutonomyState, item: AutonomyRoadmapItem, summary: string) => { + const lease = state.activeLease; + if (!lease || lease.itemId !== item.id) return false; + if (!state.completedLeases.some((entry) => entry.token === lease.token)) { + state.completedLeases = [ + ...state.completedLeases, + { + token: lease.token, + outcome: "success" as const, + summary, + addedTasks: [], + completedAt: new Date().toISOString(), + }, + ].slice(-50); + } + if (lease.runId) { + const run = state.runs.find((entry) => entry.id === lease.runId); + if (run) run.reportedWorkItems += 1; + else state.runs = [...state.runs, { id: lease.runId, actor: lease.actor, startedAt: lease.createdAt, reportedWorkItems: 1 }].slice(-50); + } + delete state.activeLease; + return true; +}; + +const advanceValidatedPhases = (state: AutonomyState) => { + const roadmap = state.roadmap; + if (!roadmap) return { changed: false, finalValidationReady: false }; + let changed = false; + let finalValidationReady = false; + + while (roadmap.currentPhaseIndex < roadmap.phases.length) { + const phase = roadmap.phases[roadmap.currentPhaseIndex]; + if (!phase) break; + const scoped = phase.items.filter((item) => isAutonomyItemInExecutionScope(item)); + const complete = scoped.length > 0 && scoped.every((item) => item.status === "completed"); + if (!complete) break; + + if (roadmap.currentPhaseIndex < roadmap.phases.length - 1) { + if (phase.status !== "completed") { + phase.status = "completed"; + phase.brainstormConverged = true; + phase.validationFailures = 0; + changed = true; + } + roadmap.currentPhaseIndex += 1; + const next = roadmap.phases[roadmap.currentPhaseIndex]; + if (next && next.status !== "completed" && next.status !== "active") { + next.status = "active"; + changed = true; + } + continue; + } + + finalValidationReady = true; + if (phase.status !== "validating") { + phase.status = "validating"; + phase.brainstormConverged = true; + phase.validationFailures = 0; + changed = true; + } + break; + } + + return { changed, finalValidationReady }; +}; + +const markProjectCompleteIfEligible = async ( + projectId: string, + actor: string, +) => { + const completion = await evaluateProjectCompletion(projectId, actor); + if (completion.status !== "complete") return completion; + + let state = await getAutonomyState(projectId); + const project = await getAutonomyProject(projectId); + const roadmap = state.roadmap; + if (!roadmap) return completion; + const finalPhase = roadmap.phases[roadmap.phases.length - 1]; + const scoped = finalPhase?.items.filter((item) => isAutonomyItemInExecutionScope(item)) ?? []; + if (!finalPhase || scoped.length === 0 || scoped.some((item) => item.status !== "completed")) return completion; + + finalPhase.status = "completed"; + roadmap.status = "completed"; + roadmap.completedAt = roadmap.completedAt ?? new Date().toISOString(); + state.status = "completed"; + state = appendAutonomyEvent( + state, + createAutonomyEvent( + "autonomy.gitea.definition_of_done.reached", + actor, + "Gitea validated-merge evidence and downstream ProjectBrain state satisfy the configured Definition of Done.", + ), + ); + await saveAutonomyState(project, state, actor); + return completion; +}; + +export const reconcileGiteaAutonomyExecution = async ( + env: AppEnv, + projectIdOrSlug: string, + actor = "system:gitea-auto-reconcile", + retry = true, +): Promise<{ + state: AutonomyState; + changed: boolean; + reason: "auto_mode_inactive" | "auto_mode_completed" | "repository_not_linked" | "primary_repository_read_only" | "gitea_execution_reconciled" | "gitea_execution_current"; + repository?: string; + completedItems?: number; + activeItems?: number; + completion?: Awaited>; +}> => { + const project = await getAutonomyProject(projectIdOrSlug); + let state = await getAutonomyState(project.id); + if (!state.enabled || !state.roadmap) { + return { state, changed: false, reason: "auto_mode_inactive" as const }; + } + if (state.status === "completed" || state.roadmap.status === "completed") { + return { state, changed: false, reason: "auto_mode_completed" as const }; + } + + const link = await prisma.giteaRepositoryLink.findFirst({ + where: { projectId: project.id }, + orderBy: [{ isPrimary: "desc" }, { updatedAt: "desc" }], + select: { + id: true, + owner: true, + repository: true, + defaultBranch: true, + accessMode: true, + }, + }); + if (!link) return { state, changed: false, reason: "repository_not_linked" as const }; + if (link.accessMode !== "read_write") return { state, changed: false, reason: "primary_repository_read_only" as const }; + + const client = getGiteaClient(env); + const [pullRequests, branches] = await Promise.all([ + client.listPullRequests(link.owner, link.repository, "all"), + client.listBranches(link.owner, link.repository), + ]); + const branchNames = new Set(branches.map((branch) => branch.name)); + let changed = false; + let completedItems = 0; + let activeItems = 0; + + for (const phase of state.roadmap.phases) { + for (const item of phase.items) { + if (item.waitingExternal || !isAutonomyItemInExecutionScope(item)) continue; + const branch = autonomyGiteaBranch(item.id); + const pullRequest = pullRequests + .filter((entry) => entry.head.ref === branch && entry.base.ref === link.defaultBranch) + .sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at))[0]; + + if (pullRequest?.merged && pullRequest.merge_commit_sha) { + const runs = await client.listActionRuns(link.owner, link.repository, { + headSha: pullRequest.head.sha, + limit: 50, + }); + try { + const validatedRuns = assertSuccessfulActionRuns(runs.workflow_runs, branch, pullRequest.head.sha); + const evidence = completionEvidence( + branch, + pullRequest.number, + pullRequest.head.sha, + pullRequest.merge_commit_sha, + validatedRuns.map((run) => run.id), + ); + const summary = `Reconciled from Gitea PR #${pullRequest.number}: exact head ${pullRequest.head.sha.slice(0, 12)} validated and merged as ${pullRequest.merge_commit_sha.slice(0, 12)}.`; + const previousEvidence = item.completionEvidence ?? []; + if (item.status !== "completed" || !hasGiteaAutonomyCompletionEvidence(previousEvidence) || evidence.some((entry) => !previousEvidence.includes(entry))) { + item.status = "completed"; + item.lastResult = summary; + delete item.lastError; + item.completionEvidence = appendUnique(previousEvidence, evidence); + completedItems += 1; + changed = true; + } + if (state.blocker?.itemId === item.id) { + delete state.blocker; + state.roadmap.status = "active"; + if (phase.status === "blocked") phase.status = "active"; + changed = true; + } + if (markLeaseReconciled(state, item, summary)) changed = true; + continue; + } catch (error) { + if (!(error instanceof Error) || error.message !== "FEATURE_DELIVERY_CI_NOT_SUCCESSFUL") throw error; + const summary = `Gitea PR #${pullRequest.number} is merged, but exact-head validation evidence is not green yet.`; + if (item.status === "pending") { + item.status = "in_progress"; + changed = true; + } + if (item.lastResult !== summary) { + item.lastResult = summary; + changed = true; + } + activeItems += 1; + continue; + } + } + + if (pullRequest?.state === "open" || branchNames.has(branch)) { + const summary = pullRequest?.state === "open" + ? `Gitea PR #${pullRequest.number} is the active execution surface for this Auto work item.` + : `Gitea branch '${branch}' atomically claims this Auto work item.`; + if (item.status === "pending") { + item.status = "in_progress"; + changed = true; + } + if (item.lastResult !== summary) { + item.lastResult = summary; + changed = true; + } + activeItems += 1; + } + } + } + + const phaseProgress = advanceValidatedPhases(state); + if (phaseProgress.changed) changed = true; + if (changed && !state.blocker) state.status = "running"; + + if (changed) { + state = appendAutonomyEvent( + state, + createAutonomyEvent( + "autonomy.gitea.reconciled", + actor, + `Reconciled Gitea execution evidence: ${completedItems} newly completed item(s), ${activeItems} active branch/PR item(s).`, + ), + ); + try { + const freshProject = await getAutonomyProject(project.id); + state = await saveAutonomyState(freshProject, state, actor); + } catch (error) { + if (retry && error instanceof Error && error.message === "AUTO_MODE_CONFLICT") { + return reconcileGiteaAutonomyExecution(env, projectIdOrSlug, actor, false); + } + throw error; + } + } + + const finalPhase = state.roadmap?.phases.at(-1); + const finalScoped = finalPhase?.items.filter((item) => isAutonomyItemInExecutionScope(item)) ?? []; + const finalValidationReady = phaseProgress.finalValidationReady + || Boolean(finalPhase && finalScoped.length > 0 && finalScoped.every((item) => item.status === "completed")); + const completion = finalValidationReady + ? await markProjectCompleteIfEligible(project.id, actor) + : undefined; + if (completion?.status === "complete") state = await getAutonomyState(project.id); + + return { + state, + changed, + reason: changed ? "gitea_execution_reconciled" as const : "gitea_execution_current" as const, + repository: `${link.owner}/${link.repository}`, + completedItems, + activeItems, + completion, + }; +}; diff --git a/backend/src/services/portfolioControlPlane.ts b/backend/src/services/portfolioControlPlane.ts new file mode 100644 index 0000000..3310e26 --- /dev/null +++ b/backend/src/services/portfolioControlPlane.ts @@ -0,0 +1,549 @@ +import { + FindingSeverity, + FindingStatus, + InvestigationStatus, + IssueSyncMode, + PortfolioRepositoryStatus, +} from "@prisma/client"; +import { summarizeHumanBlocker, summarizeTechnicalBlocker } from "@projectbrain/contracts"; +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { getGiteaClient, linkRepository, listLinkedRepositories, PROJECTBRAIN_GITEA_WEBHOOK_EVENTS, recordMcpAudit } from "../integrations/gitea/service.js"; +import { createProject, listProjects } from "../repositories/projectsRepository.js"; +import { humanAttentionReviewWhere } from "../repositories/reviewAttentionPolicy.js"; +import { loadAutonomyRuntime, type AutonomyProjectRuntime } from "./autonomyRuntime.js"; + +const ACTIVE_FINDING_STATUSES = [ + FindingStatus.new, + FindingStatus.triaged, + FindingStatus.issue_open, + FindingStatus.investigating, + FindingStatus.planned, + FindingStatus.fixing, + FindingStatus.verifying, + FindingStatus.regressed, +] as const; + +const ACTIVE_INVESTIGATION_STATUSES = [ + InvestigationStatus.collecting, + InvestigationStatus.ready, + InvestigationStatus.concluded, + InvestigationStatus.planned, + InvestigationStatus.fixing, + InvestigationStatus.verifying, +] as const; + +const WEBHOOK_ALIGNMENT_BATCH_SIZE = 10; +export const PORTFOLIO_CATALOG_CACHE_MS = 5 * 60_000; + +const slugify = (value: string) => value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "project"; + +type ProjectRepositoryIdentity = { owner: string; repository: string }; + +export const summarizeDeliveryActivity = (counts: Record) => ({ + active: (counts.executing ?? 0) + (counts.validating ?? 0), + queued: (counts.draft ?? 0) + (counts.approved ?? 0), + waitingExternal: counts.pull_request_open ?? 0, + mergedAwaitingVerification: counts.merged ?? 0, + completed: (counts.verified ?? 0) + (counts.released ?? 0), +}); + +// Delivery lifecycle labels are durable bookkeeping, not executor liveness. +export const summarizeLiveExecution = (projects: AutonomyProjectRuntime[], staleSeconds: number, now = Date.now()) => { + const enabled = projects.filter((project) => project.enabled); + const active = enabled.filter((project) => { + const seen = Date.parse(project.executorLastSeenAt ?? ""); + const expiry = Date.parse(project.leaseExpiresAt ?? ""); + return project.status === "running" && Boolean(project.activeWorkKind) + && Boolean(project.activeRunId) && project.activeRunId === project.executorRunId + && project.executorStatus === "working" && Number.isFinite(seen) + && seen <= now && seen >= now - staleSeconds * 1000 && expiry > now; + }); + return { verifiedActiveProjects: active.length, enabledProjects: enabled.length, + unconfirmedLeaseProjects: enabled.filter((project) => Boolean(project.activeWorkKind) && !active.includes(project)).length, + checkedAt: new Date(now).toISOString() }; +}; + +export const buildAutoHumanAttentionPriority = (projects: AutonomyProjectRuntime[]) => { + const blocked = projects.filter((project) => project.enabled && (project.lifecycle === "needs_user" || project.blockerKind === "human")); + const first = blocked[0]; + if (!first) return undefined; + return { + id: "auto-human-blockers", + projectId: first.projectId, + severity: "critical" as const, + requiresHumanAttention: true, + count: blocked.length, + label: `${blocked.length} Auto Mode blokkade${blocked.length === 1 ? "" : "s"} vragen jouw aandacht`, + description: `${first.projectName}: ${summarizeHumanBlocker(first.blockerReason)}`, + actionLabel: "Beslissing openen", + route: `/projects/${first.projectSlug}/attention`, + }; +}; + +export const canReuseProjectForRepository = ( + links: ProjectRepositoryIdentity[], + owner: string, + repository: string, +) => links.length === 0 || links.some((link) => + link.owner.toLowerCase() === owner.toLowerCase() + && link.repository.toLowerCase() === repository.toLowerCase(), +); + +export const resolvePortfolioRepositoryStatus = (linked: boolean, previous?: PortfolioRepositoryStatus) => + linked + ? PortfolioRepositoryStatus.onboarded + : previous === PortfolioRepositoryStatus.excluded + ? PortfolioRepositoryStatus.excluded + : PortfolioRepositoryStatus.discovered; + +const serializeRepository = (row: { + id: string; + owner: string; + repository: string; + fullName?: string; + projectId: string | null; + description: string | null; + defaultBranch: string; + htmlUrl: string | null; + private: boolean; + archived: boolean; + canPush: boolean; + status: PortfolioRepositoryStatus; + exclusionReason: string | null; + firstSeenAt: Date; + lastSeenAt: Date; + onboardedAt: Date | null; + excludedAt: Date | null; + project?: { slug: string; name: string } | null; +}) => ({ + id: row.id, + owner: row.owner, + repository: row.repository, + fullName: `${row.owner}/${row.repository}`, + projectId: row.projectId ?? undefined, + projectSlug: row.project?.slug, + projectName: row.project?.name, + description: row.description ?? undefined, + defaultBranch: row.defaultBranch, + htmlUrl: row.htmlUrl ?? undefined, + private: row.private, + archived: row.archived, + canPush: row.canPush, + status: row.status, + exclusionReason: row.exclusionReason ?? undefined, + firstSeenAt: row.firstSeenAt.toISOString(), + lastSeenAt: row.lastSeenAt.toISOString(), + onboardedAt: row.onboardedAt?.toISOString(), + excludedAt: row.excludedAt?.toISOString(), +}); + +const readPortfolioCatalog = () => prisma.portfolioRepository.findMany({ + include: { project: { select: { slug: true, name: true } } }, + orderBy: [{ status: "asc" as const }, { owner: "asc" as const }, { repository: "asc" as const }], +}); + +/** + * The command center is a read model. Re-listing and upserting every Gitea + * repository on every page visit made the first viewport depend on a remote + * inventory round trip. Reuse a recent durable catalog and reserve the remote + * refresh for stale/empty state or the explicit repository-management flow. + */ +export const shouldRefreshPortfolioCatalog = (lastSeenAt?: Date, now = Date.now()) => + !lastSeenAt || !Number.isFinite(lastSeenAt.getTime()) || now - lastSeenAt.getTime() > PORTFOLIO_CATALOG_CACHE_MS; + +export const listPortfolioProjectPins = async (ownerKey: string) => + (await prisma.projectPortfolioPin.findMany({ + where: { ownerKey }, + orderBy: { createdAt: "desc" }, + select: { project: { select: { slug: true } } }, + })).map((pin) => pin.project.slug); + +export const setPortfolioProjectPin = async (ownerKey: string, projectIdOrSlug: string, pinned: boolean) => { + const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }] }, select: { id: true, slug: true } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + if (pinned) { + await prisma.projectPortfolioPin.upsert({ + where: { ownerKey_projectId: { ownerKey, projectId: project.id } }, + create: { ownerKey, projectId: project.id }, + update: {}, + }); + } else { + await prisma.projectPortfolioPin.deleteMany({ where: { ownerKey, projectId: project.id } }); + } + return { projectId: project.slug, pinned }; +}; + +export const refreshPortfolioCatalog = async (env: AppEnv) => { + const startedAt = new Date(); + const [repositories, links, existing] = await Promise.all([ + getGiteaClient(env).listRepositories(), + listLinkedRepositories(), + prisma.portfolioRepository.findMany(), + ]); + const linkByName = new Map(links.map((link) => [link.fullName.toLowerCase(), link])); + const existingByName = new Map(existing.map((row) => [`${row.owner}/${row.repository}`.toLowerCase(), row])); + + for (const repository of repositories) { + const fullName = repository.full_name.toLowerCase(); + const link = linkByName.get(fullName); + const previous = existingByName.get(fullName); + const nextStatus = resolvePortfolioRepositoryStatus(Boolean(link), previous?.status); + const excluded = nextStatus === PortfolioRepositoryStatus.excluded; + await prisma.portfolioRepository.upsert({ + where: { owner_repository: { owner: repository.owner.login, repository: repository.name } }, + create: { + owner: repository.owner.login, + repository: repository.name, + projectId: link?.projectId, + description: repository.description, + defaultBranch: repository.default_branch || "main", + htmlUrl: repository.html_url, + private: repository.private, + archived: repository.archived, + canPush: Boolean(repository.permissions?.push || repository.permissions?.admin), + status: nextStatus, + lastSeenAt: startedAt, + onboardedAt: link ? startedAt : undefined, + }, + update: { + projectId: link?.projectId ?? (excluded ? null : previous?.projectId), + description: repository.description, + defaultBranch: repository.default_branch || "main", + htmlUrl: repository.html_url, + private: repository.private, + archived: repository.archived, + canPush: Boolean(repository.permissions?.push || repository.permissions?.admin), + status: nextStatus, + lastSeenAt: startedAt, + ...(link ? { onboardedAt: previous?.onboardedAt ?? startedAt, exclusionReason: null, excludedAt: null } : {}), + }, + }); + } + + await prisma.portfolioRepository.updateMany({ + where: { lastSeenAt: { lt: startedAt }, status: { not: PortfolioRepositoryStatus.excluded } }, + data: { status: PortfolioRepositoryStatus.unavailable }, + }); + + return readPortfolioCatalog(); +}; + +export const listPortfolioRepositories = async (env: AppEnv) => + (await refreshPortfolioCatalog(env)).map(serializeRepository); + +const getPortfolioRepositorySnapshot = async (env: AppEnv) => { + const persisted = await readPortfolioCatalog(); + const lastSeenAt = persisted.reduce((latest, row) => !latest || row.lastSeenAt > latest ? row.lastSeenAt : latest, undefined); + if (persisted.length && !shouldRefreshPortfolioCatalog(lastSeenAt)) { + return { repositories: persisted.map(serializeRepository), catalogDegraded: false }; + } + try { + return { repositories: await listPortfolioRepositories(env), catalogDegraded: false }; + } catch { + return { repositories: persisted.map(serializeRepository), catalogDegraded: true }; + } +}; + +const resolveProjectForRepository = async (row: { owner: string; repository: string; projectId: string | null; description: string | null }) => { + if (row.projectId) { + const project = await prisma.project.findUnique({ where: { id: row.projectId }, select: { id: true, slug: true } }); + if (project) return project; + } + const preferredSlug = slugify(row.repository); + const findReusableProject = async (slug: string) => { + const project = await prisma.project.findFirst({ + where: { slug, deletedAt: null }, + select: { id: true, slug: true, giteaRepositories: { select: { owner: true, repository: true } } }, + }); + if (!project || !canReuseProjectForRepository(project.giteaRepositories, row.owner, row.repository)) return undefined; + return { id: project.id, slug: project.slug }; + }; + const existing = await findReusableProject(preferredSlug); + if (existing) return existing; + + const ownerQualifiedSlug = slugify(`${row.owner}-${row.repository}`); + const ownerQualified = await findReusableProject(ownerQualifiedSlug); + if (ownerQualified) return ownerQualified; + + let projectSlug = preferredSlug; + if (await prisma.project.findUnique({ where: { slug: projectSlug }, select: { id: true } })) projectSlug = ownerQualifiedSlug; + let suffix = 2; + while (await prisma.project.findUnique({ where: { slug: projectSlug }, select: { id: true } })) { + projectSlug = `${ownerQualifiedSlug}-${suffix}`; + suffix += 1; + } + const created = await createProject({ + id: projectSlug, + name: row.repository, + description: row.description?.trim() || `Centraal ProjectBrain-beheer voor ${row.owner}/${row.repository}.`, + domain: "gitea", + primaryGoal: `Beheer, analyseer en verbeter ${row.owner}/${row.repository} via de gecontroleerde ProjectBrain-loop.`, + targetAudience: "Repository maintainers", + }); + return prisma.project.findUniqueOrThrow({ where: { slug: created.id }, select: { id: true, slug: true } }); +}; + +export type PortfolioReconcileOptions = { dryRun?: boolean; repositoryFullNames?: string[] }; + +export const isPortfolioManagedPolicy = (updatedBy?: string | null) => + !updatedBy || updatedBy === "system" || updatedBy.startsWith("system:"); + +const portfolioPolicyData = (env: AppEnv) => ({ + enabled: true, + issueSyncMode: IssueSyncMode.automatic, + minimumSeverity: FindingSeverity.critical, + minimumOccurrences: 2, + maxIssuesPerRun: 2, + autoCloseResolved: true, + autoReopenRegressed: true, + scheduledAnalysis: true, + scheduleMinutes: env.PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES || 1_440, + updatedBy: "system:portfolio-control-plane", +}); + +const alignPortfolioFindingPolicies = async (env: AppEnv, apply: boolean) => { + const repositories = await prisma.portfolioRepository.findMany({ where: { status: PortfolioRepositoryStatus.onboarded, projectId: { not: null } }, select: { projectId: true } }); + const projectIds = [...new Set(repositories.flatMap((row) => row.projectId ? [row.projectId] : []))]; + const policies = projectIds.length ? await prisma.findingAutomationPolicy.findMany({ where: { projectId: { in: projectIds } } }) : []; + const policyByProject = new Map(policies.map((policy) => [policy.projectId, policy])); + const desired = portfolioPolicyData(env); + let policyCandidateCount = 0; + let policyAlignedCount = 0; + let policyPreservedCount = 0; + + for (const projectId of projectIds) { + const current = policyByProject.get(projectId); + if (current && !isPortfolioManagedPolicy(current.updatedBy)) { policyPreservedCount += 1; continue; } + const alreadyAligned = current + && current.enabled === desired.enabled + && current.issueSyncMode === desired.issueSyncMode + && current.minimumSeverity === desired.minimumSeverity + && current.minimumOccurrences === desired.minimumOccurrences + && current.maxIssuesPerRun === desired.maxIssuesPerRun + && current.autoCloseResolved === desired.autoCloseResolved + && current.autoReopenRegressed === desired.autoReopenRegressed + && current.scheduledAnalysis === desired.scheduledAnalysis + && current.scheduleMinutes === desired.scheduleMinutes; + if (alreadyAligned) continue; + policyCandidateCount += 1; + if (!apply) continue; + await prisma.findingAutomationPolicy.upsert({ where: { projectId }, create: { projectId, ...desired }, update: desired }); + policyAlignedCount += 1; + } + + return { policyCandidateCount, policyAlignedCount, policyPreservedCount }; +}; + +const alignPortfolioRepositoryWebhooks = async (env: AppEnv, actor: string, apply: boolean) => { + if (!env.PROJECTBRAIN_GITEA_WEBHOOK_SECRET || !env.PROJECTBRAIN_PUBLIC_URL) return { webhookCandidateCount: 0, webhookAlignedCount: 0, webhookFailedCount: 0, webhookRemainingCount: 0 }; + const links = await listLinkedRepositories(); + const candidates = links.filter((link) => { + const events = Array.isArray(link.capabilities?.webhookEvents) ? link.capabilities.webhookEvents : []; + return !link.capabilities?.webhookConfigured || PROJECTBRAIN_GITEA_WEBHOOK_EVENTS.some((event) => !events.includes(event)); + }); + if (!apply) return { webhookCandidateCount: candidates.length, webhookAlignedCount: 0, webhookFailedCount: 0, webhookRemainingCount: candidates.length }; + let webhookAlignedCount = 0; + let webhookFailedCount = 0; + for (const link of candidates.slice(0, WEBHOOK_ALIGNMENT_BATCH_SIZE)) { + try { + await linkRepository(env, link.projectId, link.fullName, link.accessMode, actor, { role: link.role, isPrimary: link.isPrimary }); + webhookAlignedCount += 1; + } catch { webhookFailedCount += 1; } + } + return { webhookCandidateCount: candidates.length, webhookAlignedCount, webhookFailedCount, webhookRemainingCount: Math.max(0, candidates.length - webhookAlignedCount) }; +}; + +export const reconcilePortfolioRepositories = async (env: AppEnv, actor: string, options: PortfolioReconcileOptions = {}) => { + const catalog = await refreshPortfolioCatalog(env); + const selected = new Set((options.repositoryFullNames ?? []).map((value) => value.toLowerCase())); + const candidates = catalog.filter((row) => row.status === PortfolioRepositoryStatus.discovered && (!selected.size || selected.has(`${row.owner}/${row.repository}`.toLowerCase()))); + if (options.dryRun !== false) { + const [policyAlignment, webhookAlignment] = await Promise.all([alignPortfolioFindingPolicies(env, false), alignPortfolioRepositoryWebhooks(env, actor, false)]); + return { + dryRun: true, + candidateCount: candidates.length, + onboardedCount: 0, + failedCount: 0, + ...policyAlignment, + ...webhookAlignment, + candidates: candidates.map((row) => ({ id: row.id, fullName: `${row.owner}/${row.repository}`, accessMode: row.canPush ? "read_write" : "read_only", action: "create_or_match_project_and_link" })), + }; + } + + const results: Array> = []; + for (const row of candidates) { + const fullName = `${row.owner}/${row.repository}`; + const started = Date.now(); + try { + const project = await resolveProjectForRepository(row); + const link = await linkRepository(env, project.id, fullName, row.canPush ? "read_write" : "read_only", actor, { role: "primary", isPrimary: true }); + await prisma.portfolioRepository.update({ where: { id: row.id }, data: { projectId: project.id, status: PortfolioRepositoryStatus.onboarded, onboardedAt: row.onboardedAt ?? new Date(), exclusionReason: null, excludedAt: null } }); + await recordMcpAudit({ projectId: project.id, actor, toolName: "portfolio.repository.onboard", repository: fullName, outcome: "succeeded", durationMs: Date.now() - started }); + results.push({ fullName, status: "onboarded", projectId: project.slug, repositoryId: link.id, accessMode: link.accessMode }); + } catch (error) { + const code = error instanceof Error ? error.message : "PORTFOLIO_ONBOARD_FAILED"; + await recordMcpAudit({ actor, toolName: "portfolio.repository.onboard", repository: fullName, outcome: "failed", errorCode: code, durationMs: Date.now() - started }).catch(() => undefined); + results.push({ fullName, status: "failed", errorCode: code }); + } + } + const [policyAlignment, webhookAlignment] = await Promise.all([alignPortfolioFindingPolicies(env, true), alignPortfolioRepositoryWebhooks(env, actor, true)]); + return { + dryRun: false, + candidateCount: candidates.length, + onboardedCount: results.filter((result) => result.status === "onboarded").length, + failedCount: results.filter((result) => result.status === "failed").length, + ...policyAlignment, + ...webhookAlignment, + results, + }; +}; + +export const excludePortfolioRepository = async (repositoryId: string, reason: string) => { + const row = await prisma.portfolioRepository.findUnique({ where: { id: repositoryId } }); + if (!row) throw new Error("PORTFOLIO_REPOSITORY_NOT_FOUND"); + if (row.status === PortfolioRepositoryStatus.onboarded || row.projectId) throw new Error("PORTFOLIO_REPOSITORY_ALREADY_ONBOARDED"); + return serializeRepository(await prisma.portfolioRepository.update({ where: { id: repositoryId }, data: { status: PortfolioRepositoryStatus.excluded, exclusionReason: reason, excludedAt: new Date() } })); +}; + +export const restorePortfolioRepository = async (repositoryId: string) => { + const row = await prisma.portfolioRepository.findUnique({ where: { id: repositoryId } }); + if (!row) throw new Error("PORTFOLIO_REPOSITORY_NOT_FOUND"); + return serializeRepository(await prisma.portfolioRepository.update({ where: { id: repositoryId }, data: { status: PortfolioRepositoryStatus.discovered, exclusionReason: null, excludedAt: null } })); +}; + +export const getPortfolioControlPlane = async (env: AppEnv, ownerKey = "owner") => { + const [repositorySnapshot, projects, pinnedProjectIds, findingsBySeverity, findingsByStatus, openIssues, activeInvestigations, deliveriesByStatus, awaitingApprovalCount, pendingReviewCount, topReview, graphAttentionCount, topGraphAttention, latestJob, worker, topCriticalFinding, topRegression, topApproval, autonomyRuntime, signalFindings] = await Promise.all([ + getPortfolioRepositorySnapshot(env), + listProjects(), + listPortfolioProjectPins(ownerKey), + prisma.repositoryFinding.groupBy({ by: ["severity"], where: { projectId: { not: null }, status: { in: [...ACTIVE_FINDING_STATUSES] } }, _count: { _all: true } }), + prisma.repositoryFinding.groupBy({ by: ["status"], where: { projectId: { not: null } }, _count: { _all: true } }), + prisma.giteaIssueLink.count({ where: { state: "open", finding: { projectId: { not: null } } } }), + prisma.findingInvestigation.count({ where: { status: { in: [...ACTIVE_INVESTIGATION_STATUSES] } } }), + prisma.deliveryPlan.groupBy({ by: ["status"], _count: { _all: true } }), + prisma.deliveryPlan.count({ where: { status: "awaiting_approval", approvalState: "pending" } }), + prisma.reviewQueueItem.count({ where: { ...humanAttentionReviewWhere, project: { deletedAt: null } } }), + prisma.reviewQueueItem.findFirst({ where: { ...humanAttentionReviewWhere, project: { deletedAt: null } }, orderBy: { createdAt: "asc" }, select: { title: true, project: { select: { id: true, slug: true, name: true } } } }), + prisma.workGraphNode.count({ where: { project: { deletedAt: null }, source: { notIn: ["attention", "delivery"] }, OR: [{ executionEligibility: "needs_user" }, { blockReason: { contains: "escalat", mode: "insensitive" } }] } }), + prisma.workGraphNode.findFirst({ where: { project: { deletedAt: null }, source: { notIn: ["attention", "delivery"] }, OR: [{ executionEligibility: "needs_user" }, { blockReason: { contains: "escalat", mode: "insensitive" } }] }, orderBy: [{ priority: "desc" }, { updatedAt: "asc" }], select: { title: true, blockReason: true, project: { select: { id: true, slug: true, name: true } } } }), + prisma.analysisJob.findFirst({ orderBy: { createdAt: "desc" }, select: { id: true, status: true, progressCurrent: true, progressTotal: true, summaryText: true, errorCode: true, createdAt: true, completedAt: true } }), + prisma.analysisWorkerHeartbeat.findUnique({ where: { id: "primary" } }), + prisma.repositoryFinding.findFirst({ where: { severity: FindingSeverity.critical, status: { in: [...ACTIVE_FINDING_STATUSES] }, projectId: { not: null } }, orderBy: [{ riskScore: "desc" }, { lastSeenAt: "desc" }, { id: "asc" }], select: { id: true, title: true, project: { select: { id: true, slug: true, name: true } } } }), + prisma.repositoryFinding.findFirst({ where: { status: FindingStatus.regressed, projectId: { not: null } }, orderBy: [{ riskScore: "desc" }, { lastSeenAt: "desc" }, { id: "asc" }], select: { id: true, title: true, severity: true, project: { select: { id: true, slug: true, name: true } } } }), + prisma.deliveryPlan.findFirst({ where: { status: "awaiting_approval", approvalState: "pending" }, orderBy: { createdAt: "asc" }, select: { slug: true, title: true, project: { select: { id: true, slug: true, name: true } } } }), + loadAutonomyRuntime(env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS), + prisma.repositoryFinding.findMany({ where: { projectId: { not: null }, status: { in: [...ACTIVE_FINDING_STATUSES] } }, take: 200, orderBy: [{ riskScore: "desc" }, { id: "asc" }], select: { + id: true, title: true, message: true, severity: true, ruleCode: true, firstSeenAt: true, lastSeenAt: true, + project: { select: { slug: true } }, + occurrences: { where: { observed: true }, orderBy: { createdAt: "desc" }, take: 1, select: { detailJson: true } }, + } }), + ]); + const { repositories, catalogDegraded } = repositorySnapshot; + const [recentDeliveries, releaseReadyDeliveries, needsDeploymentDeliveries] = await Promise.all([ + prisma.deliveryPlan.findMany({ where: { status: { in: ["merged", "verified", "released"] } }, orderBy: { updatedAt: "desc" }, take: 8, select: { id: true, title: true, status: true, updatedAt: true, project: { select: { id: true, slug: true, name: true } } } }), + prisma.deliveryPlan.findMany({ where: { status: { in: ["verified"] } }, orderBy: { updatedAt: "desc" }, take: 8, select: { id: true, title: true, status: true, updatedAt: true, project: { select: { id: true, slug: true, name: true } } } }), + prisma.deliveryPlan.findMany({ where: { status: "merged" }, orderBy: { updatedAt: "desc" }, take: 8, select: { id: true, title: true, status: true, updatedAt: true, project: { select: { id: true, slug: true, name: true } } } }), + ]); + const repositoryCounts = Object.fromEntries(Object.values(PortfolioRepositoryStatus).map((status) => [status, repositories.filter((row) => row.status === status).length])); + const severityCounts = Object.fromEntries(Object.values(FindingSeverity).map((severity) => [severity, findingsBySeverity.find((row) => row.severity === severity)?._count._all ?? 0])); + const statusCounts = Object.fromEntries(Object.values(FindingStatus).map((status) => [status, findingsByStatus.find((row) => row.status === status)?._count._all ?? 0])); + const deliveryCounts = Object.fromEntries(deliveriesByStatus.map((row) => [row.status, row._count._all])); + const deliveryActivity = summarizeDeliveryActivity(deliveryCounts); + const execution = summarizeLiveExecution(autonomyRuntime.projects, env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS); + const topSignalByProject = new Map(); + for (const finding of signalFindings) { + if (!finding.project || topSignalByProject.has(finding.project.slug)) continue; + const detail = finding.occurrences[0]?.detailJson; + const evidence = detail && typeof detail === "object" && !Array.isArray(detail) ? detail.evidence : undefined; + const completedAt = evidence && typeof evidence === "object" && !Array.isArray(evidence) ? evidence.completedAt : undefined; + topSignalByProject.set(finding.project.slug, { + id: finding.id, title: finding.title, message: finding.message, severity: finding.severity, ruleCode: finding.ruleCode, + firstSeenAt: finding.firstSeenAt.toISOString(), lastSeenAt: finding.lastSeenAt.toISOString(), + ...(typeof completedAt === "string" && Number.isFinite(Date.parse(completedAt)) ? { sourceCompletedAt: completedAt } : {}), + }); + } + const autoHumanAttention = buildAutoHumanAttentionPriority(autonomyRuntime.projects); + const priorities = [ + ...(autoHumanAttention ? [autoHumanAttention] : []), + ...(repositoryCounts.discovered ? [{ id: "onboard-repositories", severity: "critical" as const, requiresHumanAttention: true, count: repositoryCounts.discovered, label: `${repositoryCounts.discovered} repository(s) wachten op onboarding`, description: "Controleer de voorselectie en koppel alleen repositories die centraal beheerd moeten worden.", actionLabel: "Onboarding bekijken", route: "/portfolio?focus=onboarding" }] : []), + ...(pendingReviewCount && topReview?.project ? [{ id: "review-queue", projectId: topReview.project.id, severity: "warning" as const, requiresHumanAttention: true, count: pendingReviewCount, label: `${pendingReviewCount} projectbeslissing(en) wachten op review`, description: `${topReview.project.name}: ${topReview.title}`, actionLabel: "Review openen", route: `/projects/${topReview.project.slug}/attention` }] : []), + ...(graphAttentionCount && topGraphAttention?.project ? [{ id: "escalated-work", projectId: topGraphAttention.project.id, severity: "critical" as const, requiresHumanAttention: true, count: graphAttentionCount, label: `${graphAttentionCount} werkitem(s) vragen menselijke actie`, description: `${topGraphAttention.project.name}: ${summarizeHumanBlocker(topGraphAttention.blockReason ?? topGraphAttention.title)}`, actionLabel: "Aandacht openen", route: `/projects/${topGraphAttention.project.slug}/attention` }] : []), + ...(statusCounts.regressed && topRegression?.project ? [{ id: "regressions", projectId: topRegression.project.id, severity: topRegression.severity, requiresHumanAttention: false, count: statusCounts.regressed, label: `${statusCounts.regressed} finding(s) zijn teruggekeerd`, description: `${topRegression.project.name}: ${topRegression.title}`, actionLabel: "Regressie onderzoeken", route: `/projects/${topRegression.project.slug}/findings?status=regressed&finding=${topRegression.id}` }] : []), + ...(severityCounts.critical && topCriticalFinding?.project ? [{ id: "critical-findings", projectId: topCriticalFinding.project.id, severity: "critical" as const, requiresHumanAttention: false, count: severityCounts.critical, label: `${severityCounts.critical} kritieke finding(s) vereisen opvolging`, description: `Hoogste risico: ${topCriticalFinding.project.name} · ${topCriticalFinding.title}`, actionLabel: "Hoogste risico openen", route: `/projects/${topCriticalFinding.project.slug}/findings?severity=critical&finding=${topCriticalFinding.id}` }] : []), + ...(awaitingApprovalCount && topApproval?.project ? [{ id: "approvals", projectId: topApproval.project.id, severity: "warning" as const, requiresHumanAttention: true, count: awaitingApprovalCount, label: `${awaitingApprovalCount} deliveryplan(nen) wachten op goedkeuring`, description: `Oudste wachtende plan: ${topApproval.project.name} · ${topApproval.title}`, actionLabel: "Plan beoordelen", route: `/projects/${topApproval.project.slug}/initiatives/${topApproval.slug}` }] : []), + ].sort((left, right) => Number(right.requiresHumanAttention) - Number(left.requiresHumanAttention) + || ({ critical: 0, warning: 1, info: 2 }[left.severity] - { critical: 0, warning: 1, info: 2 }[right.severity])).slice(0, 8); + const healthyAction = { id: "portfolio-healthy", severity: "success" as const, requiresHumanAttention: false, count: 0, label: "Alles onder controle", description: "Er zijn geen urgente portfolioacties. Open een project of start desgewenst een actuele analyse.", actionLabel: "Projecten openen", route: "/projects" }; + const nextAction = priorities[0] ?? healthyAction; + const now = new Date().toISOString(); + const workerAvailable = Boolean(worker && worker.lastSeenAt.getTime() >= Date.now() - 30_000); + const notifications = [ + ...(catalogDegraded ? [{ id: "gitea-catalog", severity: "warning" as const, title: "Gitea-catalogus tijdelijk niet ververst", description: "ProjectBrain toont de laatst bewaarde portfoliotoestand en probeert later opnieuw.", route: "/portfolio?focus=repositories", occurredAt: now }] : []), + ...(!workerAvailable || worker?.status === "degraded" ? [{ id: "analysis-worker", severity: "critical" as const, title: "Analyseworker vereist aandacht", description: worker?.detail ?? "De analyseworker heeft geen recente gezonde heartbeat.", route: "/portfolio?focus=analysis", occurredAt: worker?.lastSeenAt.toISOString() ?? now }] : []), + ...(latestJob?.status === "failed" ? [{ id: `analysis-job:${latestJob.id}`, severity: "critical" as const, title: "Portfolioanalyse mislukt", description: latestJob.errorCode ?? latestJob.summaryText ?? "Open de analyse om de foutdetails te bekijken.", route: `/portfolio?analysis=${latestJob.id}`, occurredAt: latestJob.completedAt?.toISOString() ?? latestJob.createdAt.toISOString() }] : []), + ...(latestJob?.status === "partial" ? [{ id: `analysis-job:${latestJob.id}`, severity: "warning" as const, title: "Portfolioanalyse gedeeltelijk voltooid", description: latestJob.summaryText ?? "Een deel van de repositories kon niet worden geanalyseerd. Bekijk de resultaten en foutdetails.", route: `/portfolio?analysis=${latestJob.id}`, occurredAt: latestJob.completedAt?.toISOString() ?? latestJob.createdAt.toISOString() }] : []), + ...priorities.map((priority) => ({ id: `priority:${priority.id}`, severity: priority.severity, title: priority.label, description: priority.description, route: priority.route, occurredAt: now })), + ...(repositoryCounts.unavailable ? [{ id: "repositories-unavailable", severity: "warning" as const, title: `${repositoryCounts.unavailable} repository(s) niet bereikbaar`, description: "Controleer Gitea-bereikbaarheid of toegangsrechten.", route: "/portfolio?focus=repositories", occurredAt: now }] : []), + ].slice(0, 12); + const linkedProjectSlugs = new Set(repositories.flatMap((repository) => repository.status === PortfolioRepositoryStatus.onboarded && repository.projectSlug ? [repository.projectSlug] : [])); + const autonomySections = { + needsYou: autonomyRuntime.projects.filter((project) => project.enabled && (project.lifecycle === "needs_user" || project.blockerKind === "human")).map((project) => ({ id: `attention:${project.projectId}`, projectId: project.projectId, title: project.projectName, summary: summarizeHumanBlocker(project.blockerReason), route: `/projects/${project.projectSlug}/attention` })), + running: autonomyRuntime.projects.filter((project) => project.enabled && project.lifecycle === "running").map((project) => ({ id: `running:${project.projectId}`, projectId: project.projectId, title: project.projectName, summary: `${project.pendingWorkCount} werkitem(s) open.`, route: `/projects/${project.projectSlug}` })), + recovering: autonomyRuntime.projects.filter((project) => project.enabled && project.lifecycle === "recovering").map((project) => ({ id: `recovering:${project.projectId}`, projectId: project.projectId, title: project.projectName, summary: summarizeTechnicalBlocker(project.blockerReason), route: `/projects/${project.projectSlug}` })), + stalled: autonomyRuntime.projects.filter((project) => project.enabled && project.stalled).map((project) => ({ id: `stalled:${project.projectId}`, projectId: project.projectId, title: project.projectName, summary: "Geen betekenisvolle executorvoortgang binnen de verwachte tijd.", route: `/projects/${project.projectSlug}` })), + atRisk: priorities.filter((priority) => priority.severity === "critical" || priority.severity === "warning").map((priority) => ({ id: priority.id, projectId: priority.projectId, title: priority.label, summary: priority.description, route: priority.route })), + recentlyDelivered: recentDeliveries.map((delivery) => ({ id: delivery.id, projectId: delivery.project.id, title: delivery.title, summary: `${delivery.project.name} · ${delivery.status}`, route: `/projects/${delivery.project.slug}/results`, updatedAt: delivery.updatedAt.toISOString() })), + releaseReady: releaseReadyDeliveries.map((delivery) => ({ id: delivery.id, projectId: delivery.project.id, title: delivery.title, summary: `${delivery.project.name} · ${delivery.status}`, route: `/projects/${delivery.project.slug}/results`, updatedAt: delivery.updatedAt.toISOString() })), + needsDeployment: needsDeploymentDeliveries.map((delivery) => ({ id: delivery.id, projectId: delivery.project.id, title: delivery.title, summary: `${delivery.project.name} · deployment- of runtimeverificatie ontbreekt`, route: `/projects/${delivery.project.slug}/results`, updatedAt: delivery.updatedAt.toISOString() })), + autonomousCoverage: { enabledProjects: autonomyRuntime.counts.enabledProjects, activeProjects: autonomyRuntime.counts.activeProjects, claimableProjects: autonomyRuntime.counts.claimableProjects, blockedProjects: autonomyRuntime.counts.blockedProjects, stalledProjects: autonomyRuntime.counts.stalledProjects }, + }; + + return { + generatedAt: new Date().toISOString(), + pinnedProjectIds, + autoOnboardEnabled: env.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES, + coverage: { + visibleRepositories: repositories.filter((row) => row.status !== PortfolioRepositoryStatus.unavailable).length, + onboardedRepositories: repositoryCounts.onboarded, + discoveredRepositories: repositoryCounts.discovered, + excludedRepositories: repositoryCounts.excluded, + unavailableRepositories: repositoryCounts.unavailable, + coveragePercent: repositories.length ? Math.round((repositoryCounts.onboarded / repositories.length) * 100) : 100, + projects: projects.length, + repositoryBackedProjects: linkedProjectSlugs.size, + standaloneProjects: Math.max(0, projects.length - linkedProjectSlugs.size), + writableRepositories: repositories.filter((row) => row.canPush).length, + }, + findings: { active: Object.values(severityCounts).reduce((sum, count) => sum + count, 0), bySeverity: severityCounts, byStatus: statusCounts, openIssues, activeInvestigations }, + deliveries: { byStatus: deliveryCounts, awaitingApproval: awaitingApprovalCount, ...deliveryActivity }, + execution, + analysis: { + workerStatus: workerAvailable ? worker!.status : "unavailable", + activeJobId: worker?.activeJobId ?? undefined, + latestJob: latestJob ? { ...latestJob, createdAt: latestJob.createdAt.toISOString(), completedAt: latestJob.completedAt?.toISOString() } : undefined, + }, + nextAction, + priorities, + notifications, + sections: autonomySections, + repositories, + projects: projects.map((project) => ({ + id: project.id, + name: project.name, + health: project.health, + healthStatus: project.healthStatus, + lastSync: project.lastSync, + status: project.status, + activeFindingCount: project.resourceCounts?.activeFindings ?? 0, + criticalFindingCount: project.resourceCounts?.criticalFindings ?? 0, + warningFindingCount: project.resourceCounts?.warningFindings ?? 0, + regressedFindingCount: project.resourceCounts?.regressedFindings ?? 0, + topSignal: topSignalByProject.get(project.id), + })), + }; +}; diff --git a/backend/src/services/projectCompletion.ts b/backend/src/services/projectCompletion.ts new file mode 100644 index 0000000..d45b151 --- /dev/null +++ b/backend/src/services/projectCompletion.ts @@ -0,0 +1,171 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import { normalizeAutonomyState } from "../repositories/autonomyRepository.js"; +import { getProjectWorkGraph, type WorkGraphSnapshot } from "./workGraph.js"; + +export type CompletionRequirementKey = "committed_scope" | "validations_green" | "ux_verification" | "documentation_current" | "release_candidate" | "deployment_completed" | "runtime_verified" | "backup_rollback_evidence"; + +export type CompletionRequirement = { + key: CompletionRequirementKey; + label: string; + description: string; + required: boolean; + satisfied: boolean; + evidence: string[]; +}; + +export type ProjectCompletionEvaluation = { + status: "incomplete" | "complete"; + satisfiedCount: number; + requirementCount: number; + requirements: CompletionRequirement[]; + evaluatedAt: string; + lastEvidenceAt?: string; + summary: string; +}; + +export type AutonomySimulation = { + generatedAt: string; + phases: Array<{ title: string; status: string; committedWork: number; runnableWork: number; blockedWork: number }>; + committedWorkCount: number; + runnableWorkCount: number; + blockedWorkCount: number; + expectedRepositoryChanges: number; + likelyDeliveries: number; + highRiskOperations: number; + expectedHumanDecisions: number; + deploymentPermission: "not_requested" | "allowed" | "blocked"; + risks: string[]; + summary: string; +}; + +const defaultRequirements: Array> = [ + { key: "committed_scope", label: "Committed work complete", description: "Required and committed work has a completion state and evidence.", required: true }, + { key: "validations_green", label: "Validations green", description: "The validations attached to committed deliveries have passed.", required: true }, + { key: "ux_verification", label: "UX verified", description: "The user-facing flow has explicit verification evidence.", required: false }, + { key: "documentation_current", label: "Documentation current", description: "The documentation required by this project is current.", required: false }, + { key: "release_candidate", label: "Release candidate ready", description: "Committed repository changes have a merged or verified delivery.", required: false }, + { key: "deployment_completed", label: "Deployment completed", description: "The requested deployment has recorded evidence.", required: false }, + { key: "runtime_verified", label: "Runtime verified", description: "The deployed revision has representative runtime proof.", required: false }, + { key: "backup_rollback_evidence", label: "Backup and rollback evidence", description: "Backup or rollback proof exists where the project requires it.", required: false }, +]; + +const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const asRecord = (value: unknown): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +const asArray = (value: unknown) => Array.isArray(value) ? value : []; +const asBoolean = (value: unknown, fallback: boolean) => typeof value === "boolean" ? value : fallback; + +export const deliveryValidationsAreGreen = (validationCommands: unknown, validations: Array<{ status: string }>) => { + const commands = asArray(validationCommands); + if (!commands.length) return validations.every((validation) => validation.status === "passed"); + return validations.length > 0 && validations.every((validation) => validation.status === "passed"); +}; + +const getProject = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, + select: { + id: true, + workspaceState: { select: { notesJson: true } }, + definitionOfDone: true, + deliveryPlans: { select: { id: true, status: true, validationCommandsJson: true, runs: { select: { mergeCommitSha: true, validations: { select: { status: true, command: true } } } } } }, + evidence: { select: { type: true, verificationState: true, evidenceStrength: true, verifiedAt: true, lastSeen: true } }, + documentationItems: { select: { status: true, updatedAt: true, deletedAt: true } }, + releaseEvidenceRecords: { select: { kind: true, status: true, verificationState: true, capturedAt: true, verifiedAt: true } }, + }, + }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + return project; +}; + +const contractRequirements = (raw: unknown): Array> => { + const values = asArray(raw); + if (!values.length) return defaultRequirements; + const byKey = new Map(defaultRequirements.map((requirement) => [requirement.key, requirement])); + return values.map((value) => { + const row = asRecord(value); + const key = String(row.key) as CompletionRequirementKey; + const fallback = byKey.get(key); + return { + key, + label: typeof row.label === "string" ? row.label : fallback?.label ?? key, + description: typeof row.description === "string" ? row.description : fallback?.description ?? "Explicit project completion requirement.", + required: asBoolean(row.required, fallback?.required ?? true), + }; + }).filter((requirement) => defaultRequirements.some((candidate) => candidate.key === requirement.key)); +}; + +const evaluateRequirements = (contract: Array>, project: Awaited>, graph: WorkGraphSnapshot): CompletionRequirement[] => { + const committed = graph.nodes.filter((node) => node.scope === "required" || node.scope === "committed"); + const activeCommitted = committed.filter((node) => node.executionEligibility !== "superseded"); + const committedComplete = activeCommitted.length === 0 || activeCommitted.every((node) => node.executionEligibility === "done"); + const validations = project.deliveryPlans.flatMap((delivery) => delivery.runs.flatMap((run) => run.validations)); + const validationsGreen = project.deliveryPlans.length === 0 || project.deliveryPlans.filter((delivery) => !["draft", "cancelled"].includes(delivery.status)).every((delivery) => deliveryValidationsAreGreen(delivery.validationCommandsJson, delivery.runs.flatMap((run) => run.validations))); + const uxEvidence = project.evidence.filter((evidence) => /ux|browser|accessibility|mobile/i.test(evidence.type) && (evidence.verificationState === "verified" || evidence.evidenceStrength === "verified")); + const docsCurrent = project.documentationItems.length > 0 && project.documentationItems.every((item) => !item.deletedAt && !["stale", "draft"].includes(item.status)); + const releaseCandidate = project.deliveryPlans.length === 0 || project.deliveryPlans.filter((delivery) => !["draft", "cancelled"].includes(delivery.status)).every((delivery) => ["merged", "verified", "released"].includes(delivery.status)); + const deploymentEvidence = project.releaseEvidenceRecords.filter((record) => /deploy/i.test(record.kind) && record.verificationState === "verified"); + const runtimeEvidence = project.releaseEvidenceRecords.filter((record) => /runtime|smoke|health|readiness/i.test(record.kind) && record.verificationState === "verified"); + const backupEvidence = project.releaseEvidenceRecords.filter((record) => /backup|rollback/i.test(record.kind) && record.verificationState === "verified"); + const facts: Record = { + committed_scope: { satisfied: committedComplete, evidence: activeCommitted.filter((node) => node.executionEligibility === "done").map((node) => node.title) }, + validations_green: { satisfied: validationsGreen, evidence: validations.filter((validation) => validation.status === "passed").map((validation) => validation.command) }, + ux_verification: { satisfied: uxEvidence.length > 0, evidence: uxEvidence.map((evidence) => evidence.type) }, + documentation_current: { satisfied: docsCurrent, evidence: project.documentationItems.map((item) => item.status) }, + release_candidate: { satisfied: releaseCandidate, evidence: project.deliveryPlans.filter((delivery) => ["merged", "verified", "released"].includes(delivery.status)).map((delivery) => delivery.id) }, + deployment_completed: { satisfied: deploymentEvidence.length > 0, evidence: deploymentEvidence.map((record) => record.kind) }, + runtime_verified: { satisfied: runtimeEvidence.length > 0, evidence: runtimeEvidence.map((record) => record.kind) }, + backup_rollback_evidence: { satisfied: backupEvidence.length > 0, evidence: backupEvidence.map((record) => record.kind) }, + }; + return contract.map((requirement) => ({ ...requirement, ...facts[requirement.key] })); +}; + +export const evaluateProjectCompletion = async (projectIdOrSlug: string, actor = "system"): Promise => { + const project = await getProject(projectIdOrSlug); + const graph = await getProjectWorkGraph(project.id); + const contract = contractRequirements(project.definitionOfDone?.requirementsJson); + const requirements = evaluateRequirements(contract, project, graph); + const required = requirements.filter((requirement) => requirement.required); + const satisfiedCount = requirements.filter((requirement) => requirement.satisfied).length; + const status = required.every((requirement) => requirement.satisfied) ? "complete" : "incomplete"; + const evaluatedAt = new Date().toISOString(); + const evidenceDates = [...project.releaseEvidenceRecords.map((record) => record.verifiedAt ?? record.capturedAt), ...project.evidence.map((evidence) => evidence.verifiedAt ?? evidence.lastSeen)].map((date) => date.getTime()); + const lastEvidenceAt = evidenceDates.length ? new Date(Math.max(...evidenceDates)).toISOString() : undefined; + const summary = status === "complete" ? "Required and committed work meets the configured Definition of Done." : `${required.filter((requirement) => !requirement.satisfied).length} required completion requirement(s) remain.`; + const data = { status, satisfiedCount, requirementCount: requirements.length, requirements, evaluatedAt, ...(lastEvidenceAt ? { lastEvidenceAt } : {}), summary } satisfies ProjectCompletionEvaluation; + await prisma.projectDefinitionOfDone.upsert({ + where: { projectId: project.id }, + create: { projectId: project.id, requirementsJson: json(contract), scopePolicyJson: json({ automaticPromotionReasons: ["defect", "correctness", "security_blocker", "release_blocker", "committed_requirement"] }), status, satisfiedCount, requirementCount: requirements.length, lastEvaluatedAt: new Date(evaluatedAt), ...(lastEvidenceAt ? { lastEvidenceAt: new Date(lastEvidenceAt) } : {}), evaluationJson: json(data), createdBy: actor, updatedBy: actor }, + update: { status, satisfiedCount, requirementCount: requirements.length, lastEvaluatedAt: new Date(evaluatedAt), ...(lastEvidenceAt ? { lastEvidenceAt: new Date(lastEvidenceAt) } : {}), evaluationJson: json(data), updatedBy: actor }, + }); + return data; +}; + +export const saveProjectDefinitionOfDone = async (projectIdOrSlug: string, input: { requirements: Array<{ key: CompletionRequirementKey; label?: string; description?: string; required?: boolean }> }, actor = "owner") => { + const project = await getProject(projectIdOrSlug); + const requirements = contractRequirements(input.requirements); + await prisma.projectDefinitionOfDone.upsert({ + where: { projectId: project.id }, + create: { projectId: project.id, requirementsJson: json(requirements), scopePolicyJson: json({ automaticPromotionReasons: ["defect", "correctness", "security_blocker", "release_blocker", "committed_requirement"] }), createdBy: actor, updatedBy: actor }, + update: { requirementsJson: json(requirements), updatedBy: actor }, + }); + return evaluateProjectCompletion(project.id, actor); +}; + +export const simulateAutonomy = async (projectIdOrSlug: string): Promise => { + const project = await getProject(projectIdOrSlug); + const graph = await getProjectWorkGraph(project.id); + const state = normalizeAutonomyState(asRecord(asRecord(project.workspaceState?.notesJson).autonomy)); + const roadmapPhases = state.roadmap?.phases ?? []; + const phases = roadmapPhases.map((phase) => { + const nodes = graph.nodes.filter((node) => node.phase === phase.title); + return { title: phase.title, status: phase.status, committedWork: nodes.filter((node) => node.scope === "required" || node.scope === "committed").length, runnableWork: nodes.filter((node) => node.executionEligibility === "ready").length, blockedWork: nodes.filter((node) => ["blocked", "waiting", "needs_user"].includes(node.executionEligibility)).length }; + }); + const committed = graph.nodes.filter((node) => node.scope === "required" || node.scope === "committed"); + const risks = graph.nodes.filter((node) => ["high", "critical"].includes(node.risk) && node.executionEligibility !== "done").map((node) => node.title).slice(0, 10); + const deploymentPermission = asRecord(asRecord(project.workspaceState?.notesJson).automation).deploy === true ? "allowed" : asRecord(asRecord(project.workspaceState?.notesJson).automation).deploy === false ? "blocked" : "not_requested"; + const expectedRepositoryChanges = committed.filter((node) => ["feature", "bug", "remediation", "delivery", "release"].includes(node.type)).length; + const expectedHumanDecisions = graph.nodes.filter((node) => node.executionEligibility === "needs_user").length; + const summary = `${committed.length} committed work item(s), ${graph.ready.length} runnable now, ${graph.blockers.length} blocked or waiting, and ${expectedHumanDecisions} human decision(s) expected.`; + return { generatedAt: new Date().toISOString(), phases, committedWorkCount: committed.length, runnableWorkCount: graph.ready.length, blockedWorkCount: graph.blockers.length, expectedRepositoryChanges, likelyDeliveries: project.deliveryPlans.length || expectedRepositoryChanges, highRiskOperations: risks.length, expectedHumanDecisions, deploymentPermission, risks, summary }; +}; diff --git a/backend/src/services/projectHealthSemantics.ts b/backend/src/services/projectHealthSemantics.ts new file mode 100644 index 0000000..d1d82fa --- /dev/null +++ b/backend/src/services/projectHealthSemantics.ts @@ -0,0 +1,24 @@ +import type { HealthLevel } from "../types/api.js"; + +type HealthInput = { + repositoryFindings: ReadonlyArray<{ severity: string; status: string }>; + giteaRepositories: ReadonlyArray<{ syncStatus: string }>; +}; + +/** Recurrence is retained as evidence and a score penalty, not a severity upgrade. */ +export const projectHealth = (project: HealthInput) => { + const activeFindings = project.repositoryFindings.filter((finding) => !["resolved", "suppressed"].includes(finding.status)); + const criticalCount = activeFindings.filter((finding) => finding.severity === "critical").length; + const warningCount = activeFindings.filter((finding) => finding.severity === "warning").length; + const regressedCount = activeFindings.filter((finding) => finding.status === "regressed").length; + const unavailableRepositoryCount = project.giteaRepositories.filter((repository) => repository.syncStatus !== "ready").length; + const repositoryPenalty = Math.min(40, unavailableRepositoryCount * 20); + const findingPenalty = Math.min(80, criticalCount * 25 + warningCount * 5 + regressedCount * 15); + const score = Math.max(0, 100 - repositoryPenalty - findingPenalty); + const healthStatus: HealthLevel = criticalCount > 0 || score < 50 + ? "risk" + : warningCount > 0 || regressedCount > 0 || unavailableRepositoryCount > 0 + ? "warn" + : "healthy"; + return { score, healthStatus, activeFindingCount: activeFindings.length, criticalCount, warningCount, regressedCount }; +}; diff --git a/backend/src/services/projectKnowledge.ts b/backend/src/services/projectKnowledge.ts new file mode 100644 index 0000000..1d02ad3 --- /dev/null +++ b/backend/src/services/projectKnowledge.ts @@ -0,0 +1,178 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; + +export type KnowledgeFreshness = "fresh" | "watch" | "stale"; +export type KnowledgeAuthority = "runtime" | "repository" | "verified_evidence" | "approved_knowledge" | "memory" | "roadmap"; + +export type ProjectKnowledgeEntry = { + id: string; + type: string; + title: string; + summary: string; + source: string; + authority: KnowledgeAuthority; + freshness: KnowledgeFreshness; + confidence?: number; + lastVerified?: string; + updatedAt: string; + projectArea?: string; + conflicts: string[]; + contextPackEligible: boolean; + provenance: Record; +}; + +export type TruthCandidate = { + key: string; + claim: string; + source: string; + authority: KnowledgeAuthority; + confidence: number; + verifiedAt?: string; + entryId: string; +}; + +export type KnowledgeConflict = { + id: string; + key: string; + summary: string; + status: "reconciled" | "attention"; + winningEntryId?: string; + entryIds: string[]; + evidence: string[]; + provenance: string; +}; + +const authorityRank: Record = { + runtime: 6, + repository: 5, + verified_evidence: 4, + approved_knowledge: 3, + memory: 2, + roadmap: 1, +}; + +const asRecord = (value: Prisma.JsonValue | null | undefined): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +const stringArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +const normalized = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " "); +const freshnessFor = (updatedAt: Date, verifiedAt?: Date | null, now = Date.now()): KnowledgeFreshness => { + const age = now - Math.max(updatedAt.getTime(), verifiedAt?.getTime() ?? 0); + if (age <= 14 * 24 * 60 * 60 * 1000) return "fresh"; + if (age <= 60 * 24 * 60 * 60 * 1000) return "watch"; + return "stale"; +}; +const freshnessRank: Record = { fresh: 0, watch: 1, stale: 2 }; +const aggregateFreshness = (entries: ProjectKnowledgeEntry[], fallback: Date): KnowledgeFreshness => { + if (!entries.length) return freshnessFor(fallback); + return entries.reduce((worst, item) => freshnessRank[item.freshness] > freshnessRank[worst] ? item.freshness : worst, "fresh"); +}; + +const candidateFor = (entry: ProjectKnowledgeEntry, claim: string, key?: string): TruthCandidate => ({ + key: key ?? normalized(entry.title), + claim: normalized(claim).slice(0, 500), + source: entry.source, + authority: entry.authority, + confidence: entry.confidence ?? 0, + verifiedAt: entry.lastVerified, + entryId: entry.id, +}); + +/** Pure conflict reconciliation used by the API and deterministic tests. */ +export const reconcileTruthCandidates = (candidates: TruthCandidate[]): KnowledgeConflict[] => { + const groups = new Map(); + for (const candidate of candidates) groups.set(candidate.key, [...(groups.get(candidate.key) ?? []), candidate]); + const conflicts: KnowledgeConflict[] = []; + for (const [key, group] of groups) { + const claims = [...new Set(group.map((candidate) => candidate.claim).filter(Boolean))]; + if (claims.length < 2) continue; + const ranked = [...group].sort((left, right) => (authorityRank[right.authority] - authorityRank[left.authority]) || (Number(Boolean(right.verifiedAt)) - Number(Boolean(left.verifiedAt))) || (right.confidence - left.confidence) || left.entryId.localeCompare(right.entryId)); + const winner = ranked[0]; + const safelyReconciled = authorityRank[winner.authority] - authorityRank[ranked[ranked.length - 1].authority] >= 2 && Boolean(winner.verifiedAt); + conflicts.push({ + id: `truth-conflict:${key}`, + key, + summary: safelyReconciled ? `Verified ${winner.source} truth supersedes ${ranked.length - 1} older or weaker statement(s).` : `${group.length} project truth sources disagree about “${key}”.`, + status: safelyReconciled ? "reconciled" : "attention", + winningEntryId: safelyReconciled ? winner.entryId : undefined, + entryIds: [...new Set(group.map((candidate) => candidate.entryId))], + evidence: ranked.map((candidate) => `${candidate.source}: ${candidate.claim}`).slice(0, 8), + provenance: safelyReconciled ? `Authority hierarchy selected ${winner.authority} over lower-ranked evidence.` : "No source had enough authority and verification evidence to reconcile automatically.", + }); + } + return conflicts; +}; + +const entry = (input: Omit): ProjectKnowledgeEntry => ({ ...input, conflicts: [] }); + +export const getProjectKnowledge = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ where: { OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }], deletedAt: null }, select: { id: true, slug: true, name: true, domain: true, description: true, updatedAt: true, giteaRepositories: { select: { id: true, owner: true, repository: true, defaultBranch: true, syncStatus: true, capabilitiesJson: true, lastVerifiedAt: true, lastSyncAt: true, updatedAt: true } }, features: { where: { deletedAt: null, archivedAt: null }, orderBy: { updatedAt: "desc" }, take: 200, select: { id: true, name: true, description: true, status: true, lifecycle: true, confidence: true, approvalState: true, verificationState: true, verifiedAt: true, updatedAt: true, sourceType: true } } } }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + const [dna, workspace, knowledge, memory, documentation, evidence, contextPacks, findings, deliveries, sources] = await Promise.all([ + prisma.projectDNA.findUnique({ where: { projectId: project.id } }), + prisma.workspaceState.findUnique({ where: { projectId: project.id } }), + prisma.knowledgeItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { updatedAt: "desc" }, take: 200 }), + prisma.memoryRule.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { updatedAt: "desc" }, take: 200 }), + prisma.documentationItem.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { updatedAt: "desc" }, take: 200 }), + prisma.evidence.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { updatedAt: "desc" }, take: 200 }), + prisma.contextPack.findMany({ where: { projectId: project.id, deletedAt: null }, orderBy: { updatedAt: "desc" }, take: 50, select: { id: true, title: true, summary: true, approvalState: true, confidence: true, updatedAt: true, provenanceJson: true } }), + prisma.repositoryFinding.findMany({ where: { projectId: project.id, status: { notIn: ["resolved", "suppressed"] } }, orderBy: { lastSeenAt: "desc" }, take: 100, select: { id: true, title: true, message: true, severity: true, lastSeenAt: true, updatedAt: true, fingerprint: true } }), + prisma.deliveryPlan.findMany({ where: { projectId: project.id }, orderBy: { updatedAt: "desc" }, take: 100, select: { id: true, title: true, status: true, updatedAt: true } }), + prisma.projectSource.findMany({ where: { projectId: project.id }, orderBy: { updatedAt: "desc" }, take: 200, select: { id: true, title: true, kind: true, inputMode: true, content: true, summary: true, status: true, path: true, url: true, branch: true, commitSha: true, contentSha: true, lastSyncedAt: true, updatedAt: true } }), + ]); + const entries: ProjectKnowledgeEntry[] = []; + const candidates: TruthCandidate[] = []; + const add = (value: ProjectKnowledgeEntry, claim: string, key?: string) => { entries.push(value); candidates.push(candidateFor(value, claim, key)); }; + + if (dna) { + const goals = stringArray(asRecord(dna.goalsJson).items ?? dna.goalsJson); + const dnaEntry = entry({ id: `dna:${dna.id}`, type: "project_dna", title: "Project DNA", summary: dna.vision, source: dna.sourceType, authority: "roadmap", freshness: freshnessFor(dna.updatedAt), confidence: 80, updatedAt: dna.updatedAt.toISOString(), projectArea: dna.maturityStage, contextPackEligible: true, provenance: { revision: dna.revision, goals } }); + add(dnaEntry, dna.vision, "project-vision"); + } + if (workspace) add(entry({ id: `workspace:${workspace.id}`, type: "workspace", title: "Current objective", summary: workspace.primaryObjective, source: workspace.sourceType, authority: "roadmap", freshness: freshnessFor(workspace.updatedAt), updatedAt: workspace.updatedAt.toISOString(), projectArea: workspace.activeSection, contextPackEligible: true, provenance: { activeContextPackId: workspace.activeContextPackId } }), workspace.primaryObjective, "current-objective"); + for (const item of knowledge) { + const metadata = asRecord(item.metadataJson); + const value = entry({ id: `knowledge:${item.id}`, type: "knowledge", title: item.title, summary: item.content.slice(0, 500), source: item.source, authority: item.verificationState === "verified" ? "verified_evidence" : item.approvalState === "approved" ? "approved_knowledge" : "roadmap", freshness: freshnessFor(item.updatedAt, item.verifiedAt), confidence: item.confidence, lastVerified: item.verifiedAt?.toISOString(), updatedAt: item.updatedAt.toISOString(), projectArea: item.category, contextPackEligible: item.includeInContextPacks, provenance: { sourceType: item.sourceType, metadata, approvalState: item.approvalState, verificationState: item.verificationState } }); + add(value, item.content, typeof metadata.truthKey === "string" ? metadata.truthKey : undefined); + } + for (const item of memory) { + const metadata = asRecord(item.provenanceJson); + const value = entry({ id: `memory:${item.id}`, type: "memory", title: item.title, summary: item.rule, source: item.sourceType, authority: item.verificationState === "verified" ? "verified_evidence" : "memory", freshness: freshnessFor(item.updatedAt, item.verifiedAt), confidence: item.confidence, lastVerified: item.verifiedAt?.toISOString(), updatedAt: item.updatedAt.toISOString(), projectArea: item.category, contextPackEligible: item.includeInContextPacks, provenance: { sourceType: item.sourceType, metadata, approvalState: item.approvalState, verificationState: item.verificationState } }); + add(value, item.rule, typeof metadata.truthKey === "string" ? metadata.truthKey : undefined); + } + for (const item of documentation) { + const metadata = asRecord(item.metadataJson); + const value = entry({ id: `documentation:${item.id}`, type: "documentation", title: item.title, summary: item.content?.slice(0, 500) ?? item.status, source: item.sourceType, authority: item.approvalState === "approved" ? "approved_knowledge" : "roadmap", freshness: freshnessFor(item.updatedAt), updatedAt: item.updatedAt.toISOString(), projectArea: item.kind, contextPackEligible: false, provenance: { status: item.status, metadata, approvalState: item.approvalState } }); + add(value, item.content ?? item.status, typeof metadata.truthKey === "string" ? metadata.truthKey : undefined); + } + for (const item of evidence) { + const metadata = asRecord(item.metadataJson); + const value = entry({ id: `evidence:${item.id}`, type: "evidence", title: item.type, summary: item.reason, source: item.source, authority: item.verificationState === "verified" || item.evidenceStrength === "verified" ? "verified_evidence" : "repository", freshness: freshnessFor(item.updatedAt, item.verifiedAt), confidence: item.confidence, lastVerified: item.verifiedAt?.toISOString(), updatedAt: item.updatedAt.toISOString(), projectArea: item.type, contextPackEligible: false, provenance: { metadata, approvalState: item.approvalState, verificationState: item.verificationState, evidenceStrength: item.evidenceStrength } }); + add(value, item.reason, typeof metadata.truthKey === "string" ? metadata.truthKey : undefined); + } + for (const item of contextPacks) entries.push(entry({ id: `context-pack:${item.id}`, type: "context_pack", title: item.title, summary: item.summary, source: "generated execution snapshot", authority: "approved_knowledge", freshness: freshnessFor(item.updatedAt), confidence: item.confidence, updatedAt: item.updatedAt.toISOString(), contextPackEligible: false, provenance: { approvalState: item.approvalState, provenance: asRecord(item.provenanceJson) } })); + for (const item of findings) entries.push(entry({ id: `finding:${item.id}`, type: "finding", title: item.title, summary: item.message, source: "verified repository analysis", authority: "repository", freshness: freshnessFor(item.updatedAt, item.lastSeenAt), updatedAt: item.updatedAt.toISOString(), projectArea: item.severity, contextPackEligible: false, provenance: { fingerprint: item.fingerprint, severity: item.severity } })); + for (const item of deliveries) { + const value = entry({ id: `delivery:${item.id}`, type: "delivery", title: item.title, summary: item.status, source: "delivery state", authority: "runtime", freshness: freshnessFor(item.updatedAt), updatedAt: item.updatedAt.toISOString(), projectArea: item.status, contextPackEligible: false, provenance: { status: item.status } }); + add(value, `${item.title}: ${item.status}`, `work:${normalized(item.title)}`); + } + for (const item of project.features) { + const verified = item.verificationState === "verified" || item.lifecycle === "production_proven"; + const value = entry({ id: `feature:${item.id}`, type: "feature", title: item.name, summary: item.description, source: item.sourceType, authority: verified ? "verified_evidence" : "roadmap", freshness: freshnessFor(item.updatedAt, item.verifiedAt), confidence: item.confidence, lastVerified: item.verifiedAt?.toISOString(), updatedAt: item.updatedAt.toISOString(), projectArea: item.lifecycle, contextPackEligible: false, provenance: { status: item.status, lifecycle: item.lifecycle, approvalState: item.approvalState, verificationState: item.verificationState } }); + add(value, `${item.name}: ${item.lifecycle}:${item.status}`, `work:${normalized(item.name)}`); + } + for (const item of project.giteaRepositories) { + const verifiedAt = item.lastVerifiedAt ?? item.lastSyncAt; + const value = entry({ id: `repository:${item.id}`, type: "repository", title: `${item.owner}/${item.repository}`, summary: `Default branch ${item.defaultBranch} · sync ${item.syncStatus}`, source: "repository link", authority: "repository", freshness: freshnessFor(item.updatedAt, verifiedAt), lastVerified: verifiedAt?.toISOString(), updatedAt: item.updatedAt.toISOString(), projectArea: item.defaultBranch, contextPackEligible: false, provenance: { syncStatus: item.syncStatus, capabilities: asRecord(item.capabilitiesJson), lastSyncAt: item.lastSyncAt?.toISOString() } }); + add(value, `${item.owner}/${item.repository}: ${item.syncStatus}:${item.defaultBranch}`, `repository:${item.owner}/${item.repository}`); + } + for (const item of sources) { + const authority: KnowledgeAuthority = item.inputMode === "git" || item.commitSha ? "repository" : "approved_knowledge"; + const verifiedAt = item.lastSyncedAt ?? (item.commitSha ? item.updatedAt : undefined); + const value = entry({ id: `source:${item.id}`, type: "source", title: item.title, summary: (item.summary || item.content).slice(0, 500), source: item.inputMode, authority, freshness: freshnessFor(item.updatedAt, verifiedAt), lastVerified: verifiedAt?.toISOString(), updatedAt: item.updatedAt.toISOString(), projectArea: item.kind, contextPackEligible: false, provenance: { status: item.status, path: item.path, url: item.url, branch: item.branch, commitSha: item.commitSha, contentSha: item.contentSha } }); + add(value, item.summary || item.content, `source:${item.path ?? item.title}`); + } + + const conflicts = reconcileTruthCandidates(candidates); + for (const conflict of conflicts) for (const entryId of conflict.entryIds) { const target = entries.find((item) => item.id === entryId); if (target) target.conflicts = [...target.conflicts, conflict.id]; } + const unresolved = conflicts.filter((conflict) => conflict.status === "attention"); + return { project: { id: project.id, slug: project.slug, name: project.name, domain: project.domain, description: project.description }, generatedAt: new Date().toISOString(), freshness: aggregateFreshness(entries, project.updatedAt), entries, conflicts, unresolvedConflictCount: unresolved.length }; +}; diff --git a/backend/src/services/projectRepositoryProvisioning.ts b/backend/src/services/projectRepositoryProvisioning.ts new file mode 100644 index 0000000..a29bd09 --- /dev/null +++ b/backend/src/services/projectRepositoryProvisioning.ts @@ -0,0 +1,294 @@ +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { GiteaApiError } from "../integrations/gitea/client.js"; +import { + finishExternalOperation, + getGiteaClient, + linkRepository, + markExternalOperationExecuting, + prepareExternalOperation, + recordMcpAudit, + type LinkedRepository, +} from "../integrations/gitea/service.js"; + +export const PROJECT_REPOSITORY_PROVISION_ACTION = "gitea.repository.create"; +const RETRYABLE_OPERATION_STATUSES = new Set(["prepared", "executing", "failed", "unknown"]); +const EXECUTING_STALE_AFTER_MS = 5 * 60_000; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +export const existingRepositoryFromProjectSetup = (setupState: unknown): string | undefined => { + if (!isRecord(setupState) || !isRecord(setupState.firstRun)) return undefined; + const profile = setupState.firstRun.repositoryProfile; + if (!isRecord(profile) || typeof profile.repository !== "string") return undefined; + const repository = profile.repository.trim(); + return repository.includes("/") ? repository : undefined; +}; + +export const projectSlugFromInput = (id: string | undefined, name: string) => + (id || name) + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "project"; + +export const giteaRepositoryNameForProject = (slug: string) => + slug + .toLowerCase() + .trim() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[._-]+|[._-]+$/g, "") + .slice(0, 100) || "project"; + +export const giteaRepositoryNameCandidate = (base: string, index: number) => { + if (index <= 1) return base; + const suffix = `-${index}`; + return `${base.slice(0, 100 - suffix.length)}${suffix}`; +}; + +const errorCode = (error: unknown) => + error instanceof GiteaApiError ? error.code : error instanceof Error ? error.message : "GITEA_REPOSITORY_PROVISIONING_FAILED"; + +const operationCanRetry = (operation: { status: string; errorCode: string | null; updatedAt: Date }, now = Date.now()) => + RETRYABLE_OPERATION_STATUSES.has(operation.status) + && (operation.status !== "executing" || now - operation.updatedAt.getTime() >= EXECUTING_STALE_AFTER_MS); + +const operationRepositoryName = (target: unknown) => + isRecord(target) && typeof target.repositoryName === "string" ? target.repositoryName : undefined; + +const findAvailableRepositoryName = async (client: ReturnType, owner: string, base: string) => { + for (let index = 1; index <= 25; index += 1) { + const candidate = giteaRepositoryNameCandidate(base, index); + try { + await client.getRepository(owner, candidate); + } catch (error) { + if (error instanceof GiteaApiError && error.status === 404) return candidate; + throw error; + } + } + throw new Error("GITEA_REPOSITORY_NAME_EXHAUSTED"); +}; + +const latestProvisioningOperation = (projectId: string) => prisma.externalOperation.findFirst({ + where: { projectId, action: PROJECT_REPOSITORY_PROVISION_ACTION }, + orderBy: { createdAt: "desc" }, + select: { id: true, status: true, errorCode: true, updatedAt: true, targetJson: true }, +}); + +export const hasProjectRepositoryProvisioning = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }] }, + select: { id: true }, + }); + if (!project) return false; + return Boolean(await latestProvisioningOperation(project.id)); +}; + +export type ProjectRepositoryProvisioningResult = { + status: "ready"; + created: boolean; + repository: LinkedRepository; +}; + +export const provisionProjectRepository = async ( + env: AppEnv, + projectIdOrSlug: string, + actor: string, +): Promise => { + const project = await prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }] }, + select: { + id: true, + slug: true, + name: true, + description: true, + giteaRepositories: { select: { id: true }, orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }], take: 1 }, + }, + }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + if (project.giteaRepositories.length) { + const linked = await linkRepositoryForExistingProject(project.id); + if (!linked) throw new Error("GITEA_REPOSITORY_LINK_NOT_FOUND"); + return { status: "ready", created: false, repository: linked }; + } + + const baseRepositoryName = giteaRepositoryNameForProject(project.slug); + const previous = await latestProvisioningOperation(project.id); + const mayReusePreviousRemote = Boolean(previous && operationCanRetry(previous)); + const prepared = await prepareExternalOperation({ + projectId: project.id, + actor, + action: PROJECT_REPOSITORY_PROVISION_ACTION, + target: { repositoryName: baseRepositoryName, selection: "first_available", visibility: "private", defaultBranch: "main" }, + }); + const startedAt = Date.now(); + let fullName = baseRepositoryName; + let remoteMutationReturned = false; + + try { + await markExternalOperationExecuting(prepared.operationId); + const client = getGiteaClient(env); + const identity = await client.getCurrentUser(); + const repositoryName = mayReusePreviousRemote + ? operationRepositoryName(previous?.targetJson) ?? baseRepositoryName + : await findAvailableRepositoryName(client, identity.login, baseRepositoryName); + await prisma.externalOperation.update({ + where: { operationId: prepared.operationId }, + data: { targetJson: { repositoryName, selection: "first_available", visibility: "private", defaultBranch: "main" } }, + }); + fullName = `${identity.login}/${repositoryName}`; + let remote; + + if (mayReusePreviousRemote) { + try { + remote = await client.getRepository(identity.login, repositoryName); + } catch (error) { + if (!(error instanceof GiteaApiError && error.status === 404)) throw error; + } + } + + if (!remote) { + try { + remote = await client.createRepository({ + name: repositoryName, + description: project.description, + private: true, + autoInit: true, + defaultBranch: "main", + }); + remoteMutationReturned = true; + } catch (error) { + if (error instanceof GiteaApiError && error.status === 409) { + if (!mayReusePreviousRemote) throw new Error("GITEA_REPOSITORY_NAME_CONFLICT", { cause: error }); + remote = await client.getRepository(identity.login, repositoryName); + } else { + throw error; + } + } + } + + fullName = remote.full_name || `${identity.login}/${remote.name}`; + const link = await linkRepository(env, project.id, fullName, "read_write", actor, { role: "primary", isPrimary: true }); + const now = new Date(); + await prisma.portfolioRepository.upsert({ + where: { owner_repository: { owner: link.owner, repository: link.repository } }, + create: { + owner: link.owner, + repository: link.repository, + projectId: project.id, + description: project.description, + defaultBranch: link.defaultBranch, + htmlUrl: link.htmlUrl, + private: remote.private, + archived: remote.archived, + canPush: true, + status: "onboarded", + onboardedAt: now, + lastSeenAt: now, + }, + update: { + projectId: project.id, + description: project.description, + defaultBranch: link.defaultBranch, + htmlUrl: link.htmlUrl, + private: remote.private, + archived: remote.archived, + canPush: true, + status: "onboarded", + exclusionReason: null, + excludedAt: null, + onboardedAt: now, + lastSeenAt: now, + }, + }); + await finishExternalOperation(prepared.operationId, { + status: "succeeded", + result: { repository: fullName, repositoryId: link.id, created: remoteMutationReturned }, + }); + await recordMcpAudit({ + operationId: prepared.operationId, + projectId: project.id, + actor, + toolName: PROJECT_REPOSITORY_PROVISION_ACTION, + repository: fullName, + outcome: "succeeded", + durationMs: Date.now() - startedAt, + metadata: { created: remoteMutationReturned, visibility: "private" }, + }).catch(() => undefined); + return { status: "ready", created: remoteMutationReturned, repository: link }; + } catch (error) { + const code = errorCode(error); + await finishExternalOperation(prepared.operationId, { + status: remoteMutationReturned ? "unknown" : "failed", + errorCode: code, + }).catch(() => undefined); + await recordMcpAudit({ + operationId: prepared.operationId, + projectId: project.id, + actor, + toolName: PROJECT_REPOSITORY_PROVISION_ACTION, + repository: fullName, + outcome: "failed", + errorCode: code, + durationMs: Date.now() - startedAt, + }).catch(() => undefined); + throw error; + } +}; + +const linkRepositoryForExistingProject = async (projectId: string) => { + const link = await prisma.giteaRepositoryLink.findFirst({ + where: { projectId }, + orderBy: [{ isPrimary: "desc" }, { createdAt: "asc" }], + include: { project: { select: { slug: true, name: true } } }, + }); + if (!link) return undefined; + return { + id: link.id, + projectId: link.projectId, + projectSlug: link.project.slug, + projectName: link.project.name, + owner: link.owner, + repository: link.repository, + fullName: `${link.owner}/${link.repository}`, + defaultBranch: link.defaultBranch, + accessMode: link.accessMode, + role: link.role, + isPrimary: link.isPrimary, + syncStatus: link.syncStatus, + ...(isRecord(link.capabilitiesJson) ? { capabilities: link.capabilitiesJson } : {}), + ...(link.lastVerifiedAt ? { lastVerifiedAt: link.lastVerifiedAt.toISOString() } : {}), + ...(link.lastSyncAt ? { lastSyncAt: link.lastSyncAt.toISOString() } : {}), + } satisfies LinkedRepository; +}; + +export const retryPendingProjectRepositoryProvisioning = async ( + env: AppEnv, + actor = "system:repository-provisioning", + batchSize = 5, +) => { + const operations = await prisma.externalOperation.findMany({ + where: { action: PROJECT_REPOSITORY_PROVISION_ACTION, status: { in: ["prepared", "executing", "failed", "unknown"] } }, + orderBy: { createdAt: "asc" }, + select: { projectId: true }, + take: 100, + }); + const projectIds = [...new Set(operations.map((operation) => operation.projectId))]; + const results: Array<{ projectId: string; status: "ready" | "failed"; errorCode?: string }> = []; + + for (const projectId of projectIds) { + if (results.length >= batchSize) break; + const latest = await latestProvisioningOperation(projectId); + if (!latest || !operationCanRetry(latest)) continue; + const linked = await prisma.giteaRepositoryLink.count({ where: { projectId } }); + if (linked) continue; + try { + await provisionProjectRepository(env, projectId, actor); + results.push({ projectId, status: "ready" }); + } catch (error) { + results.push({ projectId, status: "failed", errorCode: errorCode(error) }); + } + } + return results; +}; diff --git a/backend/src/services/pulse.ts b/backend/src/services/pulse.ts new file mode 100644 index 0000000..342e664 --- /dev/null +++ b/backend/src/services/pulse.ts @@ -0,0 +1,17 @@ +export type PulseEvent = { at: string; kind: string; title: string; summary: string; meaningful?: boolean }; + +export const buildPulse = (events: PulseEvent[], now = Date.now(), windowMs = 7 * 24 * 60 * 60_000) => { + const recent = events.filter((event) => event.meaningful !== false && now - Date.parse(event.at) <= windowMs).sort((left, right) => right.at.localeCompare(left.at)); + const completed = recent.filter((event) => /complete|merged|delivered|resolved|deployed|released|verified|passed/i.test(`${event.kind} ${event.title}`)); + const blockers = recent.filter((event) => /block|fail|stalled|recover/i.test(`${event.kind} ${event.title} ${event.summary}`)); + const active = recent.filter((event) => /running|started|claim|execut/i.test(`${event.kind} ${event.title}`)); + return { + generatedAt: new Date(now).toISOString(), + windowMs, + counts: { meaningful: recent.length, completed: completed.length, active: active.length, blockers: blockers.length }, + completed: completed.slice(0, 5), + active: active.slice(0, 5), + blockers: blockers.slice(0, 5), + nextLikelyAction: blockers[0]?.summary ?? active[0]?.summary ?? completed[0]?.summary ?? "Inspect the current project state for the next runnable action.", + }; +}; diff --git a/backend/src/services/recoveryPolicy.ts b/backend/src/services/recoveryPolicy.ts new file mode 100644 index 0000000..206ca75 --- /dev/null +++ b/backend/src/services/recoveryPolicy.ts @@ -0,0 +1,35 @@ +export type RecoveryStage = "retry" | "cooldown" | "alternate_recovery" | "escalated"; + +export type RecoveryDecision = { + stage: RecoveryStage; + attempt: number; + retryable: boolean; + exhausted: boolean; + retryAfterMinutes?: number; + reason: string; +}; + +/** + * A bounded recovery ladder. The counter is durable and the wall-clock limit + * is evaluated on every transition, so a technical blocker cannot remain in a + * retry loop indefinitely. + */ +export const decideRecovery = (input: { + failureCount: number; + recoveryAttempts?: number; + recoveryStartedAt?: string; + maxRecoveryMinutes: number; + now?: number; +}): RecoveryDecision => { + const now = input.now ?? Date.now(); + const attempt = Math.max(1, Math.trunc(input.recoveryAttempts ?? input.failureCount ?? 1)); + const startedAt = input.recoveryStartedAt ? Date.parse(input.recoveryStartedAt) : Number.NaN; + const overWallClockBudget = Number.isFinite(startedAt) + && now - startedAt >= Math.max(15, input.maxRecoveryMinutes) * 60_000; + if (overWallClockBudget || attempt >= 6) { + return { stage: "escalated", attempt, retryable: false, exhausted: true, reason: overWallClockBudget ? "recovery_wall_clock_limit" : "recovery_attempt_limit" }; + } + if (attempt === 5) return { stage: "alternate_recovery", attempt, retryable: true, exhausted: false, retryAfterMinutes: 5, reason: "alternate_recovery_required" }; + if (attempt === 3) return { stage: "cooldown", attempt, retryable: true, exhausted: false, retryAfterMinutes: 60, reason: "cooldown_after_repeated_failure" }; + return { stage: "retry", attempt, retryable: true, exhausted: false, retryAfterMinutes: Math.min(1_440, 15 * (2 ** Math.max(0, attempt - 1))), reason: "automatic_retry" }; +}; diff --git a/backend/src/services/repositoryAnalysis.ts b/backend/src/services/repositoryAnalysis.ts new file mode 100644 index 0000000..6bc17e4 --- /dev/null +++ b/backend/src/services/repositoryAnalysis.ts @@ -0,0 +1,556 @@ +import type { AppEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { GiteaApiError, GiteaClient } from "../integrations/gitea/client.js"; +import { getGiteaClient, getGiteaIssueClient, type LinkedRepository } from "../integrations/gitea/service.js"; +import type { GiteaActionRun, GiteaContent, GiteaPullRequest } from "../integrations/gitea/types.js"; +import type { PortfolioAnalysisRequest } from "../repositories/analysisJobRepository.js"; +import { chooseAnalysisStrategy } from "./analysisStrategy.js"; +import { closeAutomaticallyResolvedFindingIssues, finalizeFindingVerificationForJob, reconcileRepositoryFindings, syncAutomaticFindingIssues, type DetectedFinding } from "../repositories/findingRepository.js"; + +export const AGENT_BRANCH_PATTERN = /^(chatgpt|projectbrain|codex)\//; +const FAILED_ACTION_CONCLUSIONS = new Set(["failure", "timed_out", "startup_failure", "action_required"]); +const MAX_CURRENT_WORKFLOW_EVENT_FILES = 25; + +type ActionEvidenceScope = "default_branch" | "scheduled" | "pull_request"; +type ActionFailureEvidence = { + kind: "gitea_action"; + scope: ActionEvidenceScope; + runId: number; + runNumber: number; + runUrl: string; + branch: string; + workflow: string; + event: string; + status: string; + conclusion: string; + completedAt?: string; + consecutiveFailures: number; +}; + +type ActiveWorkflowEvents = ReadonlyMap | undefined>; + +export type RepositoryAnalysisDetail = { + repositoryId: string; + projectId: string; + projectSlug: string; + repository: string; + defaultBranch: string; + accessMode: string; + archived: boolean; + private: boolean; + branchSummary?: { total: number; protected: number; chatgptWorkBranches: number; defaultProtected: boolean }; + pullRequestSummary?: { open: number }; + actionsSummary?: { totalInspected: number; defaultBranchInspected: number; latestStatus?: string; latestConclusion?: string; latestUrl?: string; defaultLatestUrl?: string }; + findings: DetectedFinding[]; + analysisStrategy: "diff" | "targeted" | "full"; + evaluatedRuleCodes: string[]; + analyzedAt: string; +}; + +const errorCode = (error: unknown) => error instanceof GiteaApiError ? error.code : error instanceof Error ? error.message : "ANALYSIS_REPOSITORY_FAILED"; + +const runTimestamp = (run: GiteaActionRun) => { + const parsed = Date.parse(run.completed_at ?? run.started_at ?? ""); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +const newestFirst = (runs: GiteaActionRun[]) => [...runs].sort((left, right) => { + // A slow older run can finish after its replacement. Execution sequence, not + // completion time, determines whether a newer result supersedes a failure. + if (left.run_number !== right.run_number) return right.run_number - left.run_number; + if (left.id !== right.id) return right.id - left.id; + const leftTimestamp = runTimestamp(left); + const rightTimestamp = runTimestamp(right); + return leftTimestamp !== undefined && rightTimestamp !== undefined && leftTimestamp !== rightTimestamp + ? rightTimestamp - leftTimestamp + : right.run_number - left.run_number; +}); +const workflowFor = (run: GiteaActionRun) => run.path.split("@")[0]?.trim() || run.display_title.trim() || `workflow-${run.id}`; +const pullRequestNumberFor = (run: GiteaActionRun) => run.pull_requests?.[0]?.number ?? (Number.parseInt(/@refs\/pull\/(\d+)\//.exec(run.path)?.[1] ?? "", 10) || undefined); +const isPullRequestRun = (run: GiteaActionRun) => run.event === "pull_request" || run.event === "pull_request_target" || /@refs\/pull\//.test(run.path); +const branchFor = (run: GiteaActionRun, pullRequests: GiteaPullRequest[] = []) => { + if (isPullRequestRun(run)) { + const number = pullRequestNumberFor(run); + const sourceBranch = pullRequests.find((pullRequest) => pullRequest.number === number)?.head.ref; + if (sourceBranch) return sourceBranch; + if (number) return `pull/${number}`; + } + if (run.head_branch?.trim()) return run.head_branch.trim(); + const headRef = /@refs\/heads\/(.+)$/.exec(run.path)?.[1]; + if (headRef) return headRef; + const pullRequestNumber = pullRequestNumberFor(run); + return pullRequests.find((pullRequest) => pullRequest.number === pullRequestNumber)?.head.ref ?? (pullRequestNumber ? `pull/${pullRequestNumber}` : "unknown"); +}; + +const normalizeWorkflowEvent = (value: string) => { + const normalized = value.trim().replace(/^['"]|['"]$/g, ""); + return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(normalized) ? normalized : undefined; +}; + +const stripYamlComment = (value: string) => value.replace(/\s+#.*$/, "").trim(); + +export const parseWorkflowTriggerEvents = (text: string): ReadonlySet | undefined => { + const lines = text.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const match = /^(?:on|["']on["'])\s*:\s*(.*)$/.exec(lines[index] ?? ""); + if (!match) continue; + const inline = stripYamlComment(match[1] ?? ""); + if (inline) { + if (inline.startsWith("[") && inline.endsWith("]")) { + const events = inline.slice(1, -1).split(",").map(normalizeWorkflowEvent).filter((event): event is string => Boolean(event)); + return events.length > 0 ? new Set(events) : undefined; + } + const event = normalizeWorkflowEvent(inline); + return event ? new Set([event]) : undefined; + } + + const events = new Set(); + let eventIndent: number | undefined; + for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) { + const raw = lines[childIndex] ?? ""; + if (!raw.trim() || raw.trimStart().startsWith("#")) continue; + const indent = raw.match(/^\s*/)?.[0].length ?? 0; + if (indent === 0) break; + if (eventIndent === undefined) eventIndent = indent; + if (indent !== eventIndent) continue; + const child = stripYamlComment(raw.trim()); + const key = /^(?:["']?([A-Za-z_][A-Za-z0-9_-]*)["']?)\s*:/.exec(child)?.[1] + ?? /^-\s*(?:["']?([A-Za-z_][A-Za-z0-9_-]*)["']?)$/.exec(child)?.[1]; + const event = key ? normalizeWorkflowEvent(key) : undefined; + if (!event) return undefined; + events.add(event); + } + return events.size > 0 ? events : undefined; + } + return undefined; +}; + +const workflowContentText = (content: GiteaContent | GiteaContent[]) => { + if (Array.isArray(content) || content.type !== "file" || typeof content.content !== "string") return undefined; + try { + if (content.encoding === "base64") return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8"); + if (!content.encoding || content.encoding === "utf8" || content.encoding === "utf-8") return content.content; + } catch { + return undefined; + } + return undefined; +}; + +export const loadActiveWorkflowEvents = async ( + client: GiteaClient, + owner: string, + repository: string, + defaultBranch: string, + workflowContents: GiteaContent | GiteaContent[], +): Promise | undefined> | undefined> => { + const entries = (Array.isArray(workflowContents) ? workflowContents : [workflowContents]) + .filter((entry) => entry.type === "file" && /\.ya?ml$/i.test(entry.name)); + if (entries.length > MAX_CURRENT_WORKFLOW_EVENT_FILES) return undefined; + const active = new Map | undefined>(); + for (const entry of entries) { + try { + const current = await client.getContents(owner, repository, entry.path, defaultBranch); + const text = workflowContentText(current); + active.set(entry.name, text === undefined ? undefined : parseWorkflowTriggerEvents(text)); + } catch { + active.set(entry.name, undefined); + } + } + return active; +}; + +const actionEvidence = ( + run: GiteaActionRun, + scope: ActionEvidenceScope, + branch: string, + consecutiveFailures: number, +): ActionFailureEvidence => ({ + kind: "gitea_action", + scope, + runId: run.id, + runNumber: run.run_number, + runUrl: run.html_url, + branch, + workflow: workflowFor(run), + event: run.event, + status: run.status, + conclusion: run.conclusion ?? "unknown", + ...(run.completed_at ? { completedAt: run.completed_at } : {}), + consecutiveFailures, +}); + +const latestCompletedByWorkflow = (runs: GiteaActionRun[]) => { + const grouped = new Map(); + for (const run of newestFirst(runs).filter((candidate) => Boolean(candidate.conclusion))) { + // Scheduled and ordinary runs are independent signals: a push cannot hide + // a broken scheduled job or count toward its repeated-failure threshold. + const group = `${run.event === "schedule" ? "scheduled" : "default"}:${workflowFor(run)}`; + grouped.set(group, [...(grouped.get(group) ?? []), run]); + } + return grouped; +}; + +const consecutiveFailuresFor = (runs: GiteaActionRun[]) => { + let count = 0; + for (const run of runs) { + if (!run.conclusion || !FAILED_ACTION_CONCLUSIONS.has(run.conclusion)) break; + count += 1; + } + return count; +}; + +export const classifyActionFindings = (input: { + defaultBranch: string; + defaultBranchRuns: GiteaActionRun[]; + recentRuns: GiteaActionRun[]; + openPullRequests: GiteaPullRequest[]; + activeWorkflows?: string[]; + activeWorkflowEvents?: ActiveWorkflowEvents; +}): DetectedFinding[] => { + const findings: DetectedFinding[] = []; + const activeWorkflows = input.activeWorkflows ? new Set(input.activeWorkflows) : undefined; + const workflowIsActive = (run: GiteaActionRun) => { + const workflow = workflowFor(run); + if (input.activeWorkflowEvents) { + if (!input.activeWorkflowEvents.has(workflow)) return false; + const events = input.activeWorkflowEvents.get(workflow); + return events === undefined || events.has(run.event); + } + return !activeWorkflows || activeWorkflows.has(workflow); + }; + const defaultBranchRuns = input.defaultBranchRuns.filter((run) => !isPullRequestRun(run) && branchFor(run) === input.defaultBranch && workflowIsActive(run)); + const defaultBranchIsAgentBranch = AGENT_BRANCH_PATTERN.test(input.defaultBranch); + if (defaultBranchIsAgentBranch) { + findings.push({ + severity: "warning", + code: "DEFAULT_BRANCH_IS_AGENT_BRANCH", + subject: input.defaultBranch, + message: `Standaardbranch ${input.defaultBranch} is een tijdelijke agentbranch en moet naar een duurzame branch worden teruggezet.`, + evidence: { kind: "repository_configuration", scope: "default_branch", branch: input.defaultBranch }, + }); + } else if (defaultBranchRuns.length === 0) { + findings.push({ + severity: "warning", + code: "DEFAULT_BRANCH_CI_ABSENT", + subject: input.defaultBranch, + message: `Er is geen Gitea Actions-run gevonden voor standaardbranch ${input.defaultBranch}.`, + evidence: { kind: "gitea_action", scope: "default_branch", branch: input.defaultBranch }, + }); + } else { + for (const runs of latestCompletedByWorkflow(defaultBranchRuns).values()) { + const latest = runs[0]; + if (!latest?.conclusion || !FAILED_ACTION_CONCLUSIONS.has(latest.conclusion)) continue; + const workflow = workflowFor(latest); + const consecutiveFailures = consecutiveFailuresFor(runs); + const scheduled = latest.event === "schedule"; + findings.push({ + severity: scheduled ? (consecutiveFailures >= 3 ? "critical" : "warning") : "critical", + code: scheduled ? "SCHEDULED_ACTION_FAILED" : "DEFAULT_BRANCH_ACTION_FAILED", + subject: `${scheduled ? "schedule" : "default"}:${workflow}`, + message: scheduled + ? `Geplande workflow ${workflow} faalde ${consecutiveFailures} keer na elkaar op ${input.defaultBranch}.` + : `Workflow ${workflow} faalde op standaardbranch ${input.defaultBranch}.`, + evidence: actionEvidence(latest, scheduled ? "scheduled" : "default_branch", input.defaultBranch, consecutiveFailures), + }); + } + } + + const openPullRequestNumbers = new Set(input.openPullRequests.map((pullRequest) => pullRequest.number)); + const openPullRequestBranches = new Set(input.openPullRequests.map((pullRequest) => pullRequest.head.ref)); + const topicGroups = new Map(); + for (const run of newestFirst(input.recentRuns)) { + if (!workflowIsActive(run)) continue; + const branch = branchFor(run, input.openPullRequests); + if (branch === input.defaultBranch && !isPullRequestRun(run)) continue; + const pullRequestNumber = pullRequestNumberFor(run); + const isOpenPullRequest = isPullRequestRun(run) + ? (pullRequestNumber ? openPullRequestNumbers.has(pullRequestNumber) : openPullRequestBranches.has(branch)) + : openPullRequestBranches.has(branch); + if (!isOpenPullRequest || (!isPullRequestRun(run) && AGENT_BRANCH_PATTERN.test(branch))) continue; + const subject = `pr:${pullRequestNumber ?? branch}:${workflowFor(run)}`; + topicGroups.set(subject, [...(topicGroups.get(subject) ?? []), run]); + } + for (const [subject, runs] of topicGroups) { + const completedRuns = newestFirst(runs).filter((run) => Boolean(run.conclusion)); + const latest = completedRuns[0]; + if (!latest?.conclusion || !FAILED_ACTION_CONCLUSIONS.has(latest.conclusion)) continue; + const branch = branchFor(latest, input.openPullRequests); + findings.push({ + severity: "info", + code: "TOPIC_BRANCH_ACTION_FAILED", + subject, + message: `Workflow ${workflowFor(latest)} faalde voor de open pull request vanaf ${branch}.`, + evidence: actionEvidence(latest, "pull_request", branch, consecutiveFailuresFor(completedRuns)), + }); + } + + const priority = (finding: DetectedFinding) => finding.code === "DEFAULT_BRANCH_ACTION_FAILED" ? 0 : finding.code === "SCHEDULED_ACTION_FAILED" ? 1 : finding.code === "DEFAULT_BRANCH_IS_AGENT_BRANCH" ? 2 : finding.code === "DEFAULT_BRANCH_CI_ABSENT" ? 3 : 4; + return findings.sort((left, right) => priority(left) - priority(right)); +}; + +export const analyzeLinkedRepository = async ( + env: AppEnv, + link: LinkedRepository, + request: PortfolioAnalysisRequest, + clientOverride?: GiteaClient, +): Promise => { + const client = clientOverride ?? getGiteaClient(env); + const strategy = request.strategy ?? chooseAnalysisStrategy({ changedPaths: request.changedPaths }).strategy; + const includeBranches = strategy === "diff" ? false : request.includeBranches; + const includePullRequests = strategy === "diff" ? request.includePullRequests : request.includePullRequests; + const includeActions = strategy === "diff" ? false : request.includeActions; + const repository = await client.getRepository(link.owner, link.repository); + const [branches, pullRequests, defaultBranchActionRuns, workflowContents, writeRepository] = await Promise.all([ + includeBranches ? client.listBranches(link.owner, link.repository) : Promise.resolve(undefined), + includePullRequests + ? client.listPullRequests(link.owner, link.repository, "open").catch((error: unknown) => { + if (error instanceof GiteaApiError && error.status === 404) return []; + throw error; + }) + : Promise.resolve(undefined), + includeActions ? client.listActionRuns(link.owner, link.repository, { branch: repository.default_branch, limit: 30 }) : Promise.resolve(undefined), + includeActions + ? client.getContents(link.owner, link.repository, ".gitea/workflows", repository.default_branch).catch((error: unknown) => { + if (error instanceof GiteaApiError && error.status === 404) return []; + throw error; + }) + : Promise.resolve(undefined), + link.accessMode === "read_write" && !clientOverride && env.PROJECTBRAIN_GITEA_WRITE_TOKEN && (!repository.permissions?.push && !repository.permissions?.admin) + ? getGiteaIssueClient(env).getRepository(link.owner, link.repository).catch((error: unknown) => { + if (error instanceof GiteaApiError && [401, 403, 404].includes(error.status)) return { ...repository, permissions: { ...repository.permissions, push: false, admin: false } }; + throw error; + }) + : Promise.resolve(repository), + ]); + const recentActionRuns = includeActions && pullRequests?.length + ? await client.listActionRuns(link.owner, link.repository, { limit: 50 }) + : defaultBranchActionRuns; + const findings: DetectedFinding[] = []; + const evaluatedRuleCodes = ["REPOSITORY_ARCHIVED"]; + const activeWorkflows = workflowContents + ? (Array.isArray(workflowContents) ? workflowContents : [workflowContents]) + .filter((entry) => entry.type === "file" && /\.ya?ml$/i.test(entry.name)) + .map((entry) => entry.name) + : undefined; + const activeWorkflowEvents = workflowContents + ? await loadActiveWorkflowEvents(client, link.owner, link.repository, repository.default_branch, workflowContents) + : undefined; + const defaultBranch = branches?.find((branch) => branch.name === repository.default_branch); + if (branches) { + evaluatedRuleCodes.push("REPOSITORY_EMPTY", "BRANCH_SPRAWL"); + if (branches.length) evaluatedRuleCodes.push("DEFAULT_BRANCH_UNPROTECTED"); + } + if (pullRequests) evaluatedRuleCodes.push("OPEN_PR_BACKLOG"); + if (repository.archived) findings.push({ severity: "info", code: "REPOSITORY_ARCHIVED", message: "Repository is gearchiveerd." }); + if (branches?.length === 0) findings.push({ severity: "info", code: "REPOSITORY_EMPTY", message: "Repository bevat nog geen commit of branch." }); + else if (branches && !defaultBranch?.protected) findings.push({ severity: "warning", code: "DEFAULT_BRANCH_UNPROTECTED", message: `Standaardbranch ${repository.default_branch} is niet beschermd.` }); + if (branches && branches.length > 25) findings.push({ severity: "warning", code: "BRANCH_SPRAWL", message: `Repository bevat ${branches.length} branches.` }); + if (pullRequests && pullRequests.length > 10) findings.push({ severity: "warning", code: "OPEN_PR_BACKLOG", message: `Repository bevat ${pullRequests.length} open pull requests.` }); + const hasRepositoryContent = branches ? branches.length > 0 : Boolean(defaultBranchActionRuns?.workflow_runs.length); + if (includeActions && !repository.archived && hasRepositoryContent) { + evaluatedRuleCodes.push("DEFAULT_BRANCH_ACTION_FAILED", "SCHEDULED_ACTION_FAILED", "DEFAULT_BRANCH_IS_AGENT_BRANCH", "DEFAULT_BRANCH_CI_ABSENT"); + if (pullRequests) evaluatedRuleCodes.push("TOPIC_BRANCH_ACTION_FAILED", "LATEST_ACTION_FAILED"); + findings.push(...classifyActionFindings({ + defaultBranch: repository.default_branch, + defaultBranchRuns: defaultBranchActionRuns?.workflow_runs ?? [], + recentRuns: recentActionRuns?.workflow_runs ?? [], + openPullRequests: pullRequests ?? [], + activeWorkflows, + activeWorkflowEvents, + })); + } + if (link.accessMode === "read_write" && writeRepository.permissions) evaluatedRuleCodes.push("WRITE_CAPABILITY_DRIFT"); + if (link.accessMode === "read_write" && writeRepository.permissions && !writeRepository.permissions.push && !writeRepository.permissions.admin) findings.push({ severity: "critical", code: "WRITE_CAPABILITY_DRIFT", message: "ProjectBrain verwacht schrijftoegang, maar Gitea rapporteert geen pushrecht." }); + + const latestRun = recentActionRuns ? newestFirst(recentActionRuns.workflow_runs)[0] : undefined; + const defaultLatestRun = defaultBranchActionRuns ? newestFirst(defaultBranchActionRuns.workflow_runs.filter((run) => !isPullRequestRun(run) && branchFor(run) === repository.default_branch))[0] : undefined; + + return { + repositoryId: link.id, + projectId: link.projectId, + projectSlug: link.projectSlug, + repository: link.fullName, + defaultBranch: repository.default_branch, + accessMode: link.accessMode, + archived: repository.archived, + private: repository.private, + ...(branches ? { branchSummary: { total: branches.length, protected: branches.filter((branch) => branch.protected).length, chatgptWorkBranches: branches.filter((branch) => AGENT_BRANCH_PATTERN.test(branch.name)).length, defaultProtected: Boolean(defaultBranch?.protected) } } : {}), + ...(pullRequests ? { pullRequestSummary: { open: pullRequests.length } } : {}), + ...(recentActionRuns && defaultBranchActionRuns ? { actionsSummary: { totalInspected: recentActionRuns.workflow_runs.length, defaultBranchInspected: defaultBranchActionRuns.workflow_runs.length, latestStatus: latestRun?.status, latestConclusion: latestRun?.conclusion, latestUrl: latestRun?.html_url, defaultLatestUrl: defaultLatestRun?.html_url } } : {}), + findings, + analysisStrategy: strategy, + evaluatedRuleCodes, + analyzedAt: new Date().toISOString(), + }; +}; + +const asRequest = (value: unknown): PortfolioAnalysisRequest => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("ANALYSIS_REQUEST_INVALID"); + const request = value as Record; + return { + projectIds: Array.isArray(request.projectIds) ? request.projectIds.filter((item): item is string => typeof item === "string") : [], + repositoryIds: Array.isArray(request.repositoryIds) ? request.repositoryIds.filter((item): item is string => typeof item === "string") : [], + includeBranches: request.includeBranches !== false, + includePullRequests: request.includePullRequests !== false, + includeActions: request.includeActions !== false, + changedPaths: Array.isArray(request.changedPaths) ? request.changedPaths.filter((item): item is string => typeof item === "string") : [], + ...(request.strategy === "diff" || request.strategy === "targeted" || request.strategy === "full" ? { strategy: request.strategy } : {}), + ...(typeof request.strategyReason === "string" ? { strategyReason: request.strategyReason } : {}), + }; +}; + +const loadJob = (jobId: string) => prisma.analysisJob.findUnique({ + where: { id: jobId }, + include: { + items: { + orderBy: { sequence: "asc" }, + }, + }, +}); + +const linkedRepositoryFromRow = (row: NonNullable>>["items"][number]): LinkedRepository => ({ + id: row.repositoryLinkId ?? `gitea:${row.owner}/${row.repository}`, + projectId: row.projectId ?? "", + projectSlug: row.projectSlug ?? "gitea-all", + projectName: row.projectName ?? "Gitea portfolio", + owner: row.owner, + repository: row.repository, + fullName: `${row.owner}/${row.repository}`, + defaultBranch: row.defaultBranch, + accessMode: row.accessMode, + role: "other", + isPrimary: false, + syncStatus: "ready", +}); + +const runWithConcurrency = async (items: T[], concurrency: number, operation: (item: T) => Promise) => { + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (cursor < items.length) { + const item = items[cursor++]; + await operation(item); + } + })); +}; + +export const executeAnalysisJob = async ( + env: AppEnv, + jobId: string, + options: { repositoryConcurrency: number; batchSize: number; timeoutSeconds: number }, +) => { + const job = await loadJob(jobId); + if (!job) throw new Error("ANALYSIS_JOB_NOT_FOUND"); + const request = asRequest(job.requestJson); + const deadline = Date.now() + options.timeoutSeconds * 1000; + let failed = 0; + let findingCount = 0; + + const pending = job.items.filter((item) => item.status === "pending" || item.status === "running"); + for (let offset = 0; offset < pending.length; offset += options.batchSize) { + const current = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: { cancelRequestedAt: true } }); + if (current?.cancelRequestedAt) break; + if (Date.now() >= deadline) throw new Error("ANALYSIS_JOB_TIMEOUT"); + const batch = pending.slice(offset, offset + options.batchSize); + await runWithConcurrency(batch, options.repositoryConcurrency, async (row) => { + const state = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: { cancelRequestedAt: true } }); + if (state?.cancelRequestedAt || Date.now() >= deadline) return; + const startedAt = new Date(); + await prisma.analysisJobItem.update({ where: { id: row.id }, data: { status: "running", startedAt } }); + try { + const detail = await analyzeLinkedRepository(env, linkedRepositoryFromRow(row), request); + await reconcileRepositoryFindings({ + jobId, + analysisJobItemId: row.id, + projectId: row.projectId ?? undefined, + repositoryLinkId: row.repositoryLinkId ?? undefined, + owner: row.owner, + repository: row.repository, + defaultBranch: detail.defaultBranch, + findings: detail.findings, + evaluatedRuleCodes: detail.evaluatedRuleCodes, + }); + findingCount += detail.findings.length; + await prisma.analysisJobItem.update({ where: { id: row.id }, data: { + status: "succeeded", + summary: `${detail.repository}: ${detail.findings.length} bevinding(en)`, + detailJson: detail as never, + findingCount: detail.findings.length, + completedAt: new Date(), + } }); + if (row.repositoryLinkId) { + await prisma.giteaRepositoryLink.update({ + where: { id: row.repositoryLinkId }, + data: { defaultBranch: detail.defaultBranch, syncStatus: "ready", lastSyncAt: new Date(), lastVerifiedAt: new Date() }, + }); + } + } catch (error) { + failed += 1; + await prisma.analysisJobItem.update({ where: { id: row.id }, data: { + status: "failed", + summary: `${row.owner}/${row.repository}: analyse mislukt`, + errorCode: errorCode(error), + completedAt: new Date(), + } }); + if (row.repositoryLinkId) { + await prisma.giteaRepositoryLink.update({ + where: { id: row.repositoryLinkId }, + data: { syncStatus: "degraded", lastSyncAt: new Date() }, + }).catch(() => undefined); + } + } finally { + await prisma.analysisJob.update({ where: { id: jobId }, data: { progressCurrent: { increment: 1 }, heartbeatAt: new Date() } }); + } + }); + } + + const final = await prisma.analysisJob.findUnique({ where: { id: jobId }, select: { cancelRequestedAt: true, progressCurrent: true, progressTotal: true } }); + const cancelled = Boolean(final?.cancelRequestedAt); + const now = new Date(); + if (cancelled) { + await prisma.$transaction([ + prisma.analysisJobItem.updateMany({ where: { jobId, status: { in: ["pending", "running"] } }, data: { status: "cancelled", completedAt: now } }), + prisma.analysisJob.update({ where: { id: jobId }, data: { status: "cancelled", completedAt: now, activeDedupeKey: null, summaryText: `Analyse geannuleerd na ${final?.progressCurrent ?? job.progressCurrent} van ${final?.progressTotal ?? job.progressTotal} repositories.` } }), + ]); + return; + } + + const counts = await prisma.analysisJobItem.groupBy({ by: ["status"], where: { jobId }, _count: { _all: true }, _sum: { findingCount: true } }); + const succeeded = counts.find((entry) => entry.status === "succeeded")?._count._all ?? 0; + failed = counts.find((entry) => entry.status === "failed")?._count._all ?? failed; + findingCount = counts.reduce((total, entry) => total + (entry._sum.findingCount ?? 0), 0); + const summary = { repositoryCount: job.progressTotal, succeeded, failed, findingCount }; + const status = failed === 0 ? "succeeded" : succeeded === 0 ? "failed" : "partial"; + await prisma.analysisJob.update({ where: { id: jobId }, data: { + status, + progressCurrent: job.progressTotal, + summaryText: `${succeeded} repositories geanalyseerd, ${failed} mislukt, ${findingCount} bevindingen.`, + summaryJson: summary as never, + errorCode: status === "failed" ? "ANALYSIS_ALL_REPOSITORIES_FAILED" : status === "partial" ? "ANALYSIS_PARTIAL_FAILURE" : undefined, + completedAt: now, + heartbeatAt: now, + activeDedupeKey: null, + } }); + await closeAutomaticallyResolvedFindingIssues(env); + await syncAutomaticFindingIssues(env, jobId); + await finalizeFindingVerificationForJob(env, jobId); +}; + +export const claimNextAnalysisJob = async () => { + const candidate = await prisma.analysisJob.findFirst({ where: { status: "queued" }, orderBy: { queuedAt: "asc" }, select: { id: true } }); + if (!candidate) return undefined; + const claimed = await prisma.analysisJob.updateMany({ where: { id: candidate.id, status: "queued" }, data: { status: "running", startedAt: new Date(), heartbeatAt: new Date() } }); + return claimed.count === 1 ? candidate.id : undefined; +}; + +export const recoverStaleAnalysisJobs = async (staleBefore: Date) => { + const stale = await prisma.analysisJob.findMany({ where: { status: "running", OR: [{ heartbeatAt: null }, { heartbeatAt: { lt: staleBefore } }] }, select: { id: true } }); + if (!stale.length) return 0; + const ids = stale.map((job) => job.id); + await prisma.$transaction([ + prisma.analysisJobItem.updateMany({ where: { jobId: { in: ids }, status: "running" }, data: { status: "pending", startedAt: null } }), + prisma.analysisJob.updateMany({ where: { id: { in: ids } }, data: { status: "queued", startedAt: null, heartbeatAt: null, errorCode: "ANALYSIS_WORKER_RECOVERED" } }), + ]); + return ids.length; +}; + +export const deleteExpiredAnalysisJobs = async () => prisma.analysisJob.deleteMany({ where: { status: { in: ["succeeded", "partial", "failed", "cancelled"] }, expiresAt: { lt: new Date() } } }); diff --git a/backend/src/services/repositoryAutonomy.ts b/backend/src/services/repositoryAutonomy.ts new file mode 100644 index 0000000..71e862b --- /dev/null +++ b/backend/src/services/repositoryAutonomy.ts @@ -0,0 +1,355 @@ +import { createHash } from "node:crypto"; +import { prisma } from "../db/prisma.js"; +import { + appendAutonomyEvent, + createAutonomyEvent, + getAutonomyProject, + getAutonomyState, + saveAutonomyState, + type AutonomyRoadmap, + type AutonomyRoadmapItem, + type AutonomyRoadmapPhase, + type AutonomyState, +} from "../repositories/autonomyRepository.js"; +import { parseRoadmapMarkdown } from "./autonomyRoadmap.js"; + +export type RepositoryPlanningSource = { + path: string; + content: string; + contentSha?: string | null; + commitSha?: string | null; +}; + +type BacklogTask = { + id: string; + title: string; + milestone: string; + dependsOn: string[]; + requirements: string[]; + plan?: string; + execution?: string; + status: string; + evidence: string[]; + verifiedCommit?: string; +}; + +const REPOSITORY_PLAN_MARKER = "`; + +export const isRepositoryManagedRoadmap = (roadmap?: AutonomyRoadmap) => + Boolean(roadmap?.sourceMarkdown.startsWith(REPOSITORY_PLAN_MARKER)); + +const parseRoadmapSections = (markdown: string) => { + const sections = new Map(); + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + let key: string | undefined; + let title = ""; + let body: string[] = []; + + const flush = () => { + if (!key) return; + const objective = body + .map((line) => line.trim()) + .filter((line) => line && !/^\*\*Packets?:\*\*/i.test(line)) + .join(" ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 1_600); + sections.set(key, { title: title || key, objective: objective || title || key }); + }; + + for (const line of lines) { + const heading = /^##\s+(M\d+)\s*(?:[—–:-]\s*)?(.*?)\s*$/.exec(line); + if (!heading) { + if (key) body.push(line); + continue; + } + flush(); + key = heading[1].toUpperCase(); + title = heading[2]?.trim() ? `${key} — ${heading[2].trim()}` : key; + body = []; + } + flush(); + return sections; +}; + +const parseBacklog = (content: string): { project: string; executionEnabled: boolean; tasks: BacklogTask[] } | undefined => { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return undefined; + } + const document = asRecord(parsed); + const rows = Array.isArray(document.tasks) ? document.tasks : []; + const tasks = rows.flatMap((value): BacklogTask[] => { + const row = asRecord(value); + const id = asString(row.id); + const title = asString(row.title); + if (!id || !title) return []; + return [{ + id, + title, + milestone: asString(row.milestone, "Execution"), + dependsOn: stringArray(row.depends_on), + requirements: stringArray(row.requirements), + ...(asString(row.plan) ? { plan: asString(row.plan) } : {}), + ...(asString(row.execution) ? { execution: asString(row.execution) } : {}), + status: asString(row.status, "planned"), + evidence: stringArray(row.evidence), + ...(asString(row.verified_commit) ? { verifiedCommit: asString(row.verified_commit) } : {}), + }]; + }); + if (!tasks.length) return undefined; + return { + project: asString(document.project, "Repository"), + executionEnabled: document.execution_enabled !== false, + tasks, + }; +}; + +const taskInstruction = (task: BacklogTask, waitingExternal: boolean) => [ + `Execute ${task.id}: ${task.title}.`, + task.plan ? `Use ${task.plan} as the authoritative packet plan.` : undefined, + task.requirements.length ? `Requirements: ${task.requirements.join(", ")}.` : undefined, + task.execution ? `Execution class: ${task.execution}.` : undefined, + waitingExternal ? "Repository state currently marks this work as externally gated; do not perform mutating execution until the gate is cleared." : undefined, +].filter((part): part is string => Boolean(part)).join(" "); + +const normalizePhases = (phases: AutonomyRoadmapPhase[]) => { + const firstOpen = phases.findIndex((phase) => phase.items.some((item) => item.status !== "completed")); + phases.forEach((phase, index) => { + if (phase.items.length > 0 && phase.items.every((item) => item.status === "completed")) phase.status = "completed"; + else phase.status = index === (firstOpen < 0 ? 0 : firstOpen) ? "active" : "pending"; + }); + return firstOpen; +}; + +export const buildRepositoryAutonomyRoadmap = (input: { + roadmap?: RepositoryPlanningSource; + backlog?: RepositoryPlanningSource; + importedAt?: string; +}): AutonomyRoadmap | undefined => { + const importedAt = input.importedAt ?? new Date().toISOString(); + const marker = markerFor(input.roadmap, input.backlog); + const backlog = input.backlog ? parseBacklog(input.backlog.content) : undefined; + + if (backlog) { + const sections = parseRoadmapSections(input.roadmap?.content ?? ""); + const grouped = new Map(); + for (const task of backlog.tasks) grouped.set(task.milestone, [...(grouped.get(task.milestone) ?? []), task]); + + const phases: AutonomyRoadmapPhase[] = Array.from(grouped.entries()).map(([milestone, tasks], index) => { + const section = sections.get(milestone.toUpperCase()); + const items: AutonomyRoadmapItem[] = tasks.map((task) => { + const waitingExternal = task.status === "blocked_external" + || (!backlog.executionEnabled && task.execution?.toLowerCase() === "lab"); + const completionEvidence = [...task.evidence, ...(task.verifiedCommit ? [task.verifiedCommit] : [])]; + return { + id: task.id, + title: `${task.id} — ${task.title}`, + instruction: taskInstruction(task, waitingExternal), + source: "roadmap", + createdAt: importedAt, + status: task.status === "verified" ? "completed" : "pending", + attempts: 0, + priority: Math.max(1, 100 - index * 8), + scope: "committed", + ...(waitingExternal ? { waitingExternal: true } : {}), + ...(task.dependsOn.length ? { dependsOn: task.dependsOn } : {}), + ...(completionEvidence.length ? { completionEvidence } : {}), + domain: milestone, + }; + }); + return { + id: `repository-phase-${slug(milestone)}`, + title: section?.title ?? milestone, + objective: section?.objective ?? `Complete the committed ${milestone} repository backlog according to its linked packet plans and dependencies.`, + status: "pending", + brainstormIterations: 0, + brainstormConverged: true, + validationFailures: 0, + items, + }; + }); + + const firstOpen = normalizePhases(phases); + const completed = firstOpen < 0; + return { + id: `repository-roadmap-${slug(input.backlog?.path ?? "backlog")}`, + title: `${backlog.project} repository plan`, + sourceMarkdown: `${marker}\n${input.roadmap?.content ?? "# Repository execution plan"}`, + status: completed ? "completed" : "active", + currentPhaseIndex: completed ? Math.max(0, phases.length - 1) : firstOpen, + phases, + importedAt, + ...(completed ? { completedAt: importedAt } : {}), + }; + } + + if (!input.roadmap?.content.trim()) return undefined; + const parsed = parseRoadmapMarkdown(input.roadmap.content); + parsed.id = `repository-roadmap-${slug(input.roadmap.path)}`; + parsed.sourceMarkdown = `${marker}\n${input.roadmap.content}`; + parsed.importedAt = importedAt; + parsed.phases.forEach((phase) => { + phase.items.forEach((item) => { + item.source = "roadmap"; + item.scope = item.scope ?? "committed"; + }); + }); + return parsed; +}; + +const preserveRuntimeItem = (incoming: AutonomyRoadmapItem, current?: AutonomyRoadmapItem): AutonomyRoadmapItem => { + if (!current) return incoming; + // Once a repository item exists in runtime state, a planning-file refresh may + // preserve completion but may not newly grant it. New completion must come + // from canonical Gitea execution reconciliation (or the legacy delivery path). + const status = current.status === "completed" + ? "completed" + : current.status === "blocked" + ? "blocked" + : incoming.status === "completed" + ? current.status + : incoming.status; + const completionEvidence = Array.from(new Set([...(incoming.completionEvidence ?? []), ...(current.completionEvidence ?? [])])); + return { + ...incoming, + status, + attempts: current.attempts, + createdAt: current.createdAt ?? incoming.createdAt, + ...(current.deliveryId ? { deliveryId: current.deliveryId } : {}), + ...(current.lastResult ? { lastResult: current.lastResult } : {}), + ...(current.lastError ? { lastError: current.lastError } : {}), + ...(completionEvidence.length ? { completionEvidence } : {}), + }; +}; + +export const mergeRepositoryAutonomyRoadmap = (current: AutonomyRoadmap, incoming: AutonomyRoadmap): AutonomyRoadmap => { + const existing = new Map(current.phases.flatMap((phase) => phase.items).map((item) => [item.id, item])); + const incomingIds = new Set(incoming.phases.flatMap((phase) => phase.items).map((item) => item.id)); + const phases = incoming.phases.map((phase) => ({ + ...phase, + items: phase.items.map((item) => preserveRuntimeItem(item, existing.get(item.id))), + })); + + // Explicit assignments and runtime-discovered repair work are durable and + // must survive a repository-plan refresh. Repository items that disappeared + // from the authoritative backlog are intentionally not resurrected. + for (const oldPhase of current.phases) { + const extras = oldPhase.items.filter((item) => !incomingIds.has(item.id) && item.source !== "roadmap"); + if (!extras.length || !phases.length) continue; + const target = phases.find((phase) => phase.id === oldPhase.id) + ?? phases.find((phase) => phase.status === "active") + ?? phases[0]; + if (!target) continue; + target.items.unshift(...extras); + } + + const firstOpen = normalizePhases(phases); + const completed = firstOpen < 0; + return { + ...incoming, + id: current.id || incoming.id, + phases, + status: completed ? "completed" : "active", + currentPhaseIndex: completed ? Math.max(0, phases.length - 1) : firstOpen, + ...(completed ? { completedAt: current.completedAt ?? new Date().toISOString() } : {}), + }; +}; + +const assignmentOnlyRoadmap = (roadmap: AutonomyRoadmap) => { + const items = roadmap.phases.flatMap((phase) => phase.items); + return items.length > 0 && items.every((item) => item.source === "user_assignment"); +}; + +export const reconcileRepositoryAutonomyRoadmap = async (projectIdOrSlug: string, actor = "system") => { + const initialProject = await getAutonomyProject(projectIdOrSlug); + let state = await getAutonomyState(initialProject.id); + if (!state.enabled) return { state, changed: false, reason: "auto_mode_disabled" as const }; + + const primary = await prisma.giteaRepositoryLink.findFirst({ + where: { projectId: initialProject.id }, + orderBy: [{ isPrimary: "desc" }, { updatedAt: "desc" }], + select: { id: true }, + }); + if (!primary) return { state, changed: false, reason: "repository_not_linked" as const }; + + const sourceRows = await prisma.projectSource.findMany({ + where: { projectId: initialProject.id, repositoryLinkId: primary.id, inputMode: "git", status: "processed" }, + orderBy: { updatedAt: "desc" }, + take: 200, + select: { path: true, content: true, contentSha: true, commitSha: true }, + }); + const sources = sourceRows.flatMap((row): RepositoryPlanningSource[] => row.path ? [{ ...row, path: row.path }] : []); + const backlog = sources.find((source) => source.path.toLowerCase() === "docs/implementation/backlog.json") + ?? sources.find((source) => /(^|\/)backlog\.json$/i.test(source.path)); + const roadmap = sources.find((source) => source.path.toLowerCase() === "roadmap.md") + ?? sources.find((source) => /(^|\/)roadmap\.md$/i.test(source.path)); + const incoming = buildRepositoryAutonomyRoadmap({ roadmap, backlog }); + if (!incoming) return { state, changed: false, reason: "repository_plan_not_found" as const }; + + const current = state.roadmap; + if (current && !isRepositoryManagedRoadmap(current) && !assignmentOnlyRoadmap(current)) { + return { state, changed: false, reason: "explicit_plan_preserved" as const }; + } + if (current?.sourceMarkdown.startsWith(markerFor(roadmap, backlog))) { + return { state, changed: false, reason: "repository_plan_current" as const }; + } + if (state.activeLease) { + return { state, changed: false, reason: "active_lease_deferred" as const }; + } + + const reconciled = current ? mergeRepositoryAutonomyRoadmap(current, incoming) : incoming; + const validPhaseIds = new Set(reconciled.phases.map((phase) => phase.id)); + const validItemIds = new Set(reconciled.phases.flatMap((phase) => phase.items).map((item) => item.id)); + if (state.blocker && ( + (state.blocker.phaseId && !validPhaseIds.has(state.blocker.phaseId)) + || (state.blocker.itemId && !validItemIds.has(state.blocker.itemId)) + )) delete state.blocker; + + state = { + ...state, + roadmap: reconciled, + status: !state.enabled + ? "off" + : reconciled.status === "completed" + ? "completed" + : state.blocker ? "blocked" : "running", + }; + state = appendAutonomyEvent( + state, + createAutonomyEvent( + current ? "autonomy.repository_plan.reconciled" : "autonomy.repository_plan.bootstrapped", + actor, + current + ? `Reconciled Auto Mode with repository plan '${reconciled.title}'.` + : `Bootstrapped Auto Mode from repository plan '${reconciled.title}'.`, + ), + ); + + // getAutonomyState may have persisted an expired lease/retry transition, so + // reload the project row before applying the CAS-protected autonomy update. + const freshProject = await getAutonomyProject(initialProject.id); + state = await saveAutonomyState(freshProject, state, actor); + return { state, changed: true, reason: current ? "repository_plan_reconciled" as const : "repository_plan_bootstrapped" as const }; +}; diff --git a/backend/src/services/workGraph.ts b/backend/src/services/workGraph.ts new file mode 100644 index 0000000..62134d2 --- /dev/null +++ b/backend/src/services/workGraph.ts @@ -0,0 +1,573 @@ +import { randomUUID } from "node:crypto"; +import { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import { normalizeAutonomyState, type AutonomyRoadmapItem } from "../repositories/autonomyRepository.js"; +import { reviewRequiresHumanAttention } from "../repositories/reviewAttentionPolicy.js"; + +export type WorkGraphScope = "required" | "committed" | "stretch" | "future"; +export type WorkGraphEligibility = "ready" | "waiting" | "needs_user" | "running" | "blocked" | "done" | "superseded"; + +export type WorkGraphNodeView = { + id: string; + projectId: string; + stableKey: string; + source: string; + sourceId: string; + type: string; + title: string; + canonicalLifecycle: string; + domainLifecycle: string; + scope: WorkGraphScope; + priority: number; + phase?: string; + risk: string; + blockingStatus: string; + executionEligibility: WorkGraphEligibility; + blockReason?: string; + owner?: string; + leaseExpiresAt?: string; + attemptCount: number; + consecutiveFailureCount: number; + lastMeaningfulProgressAt?: string; + completedAt?: string; + dependencies: Array<{ id: string; nodeId: string; dependsOnNodeId: string; dependencyType: string }>; + dependents: string[]; + completionEvidence: Array<{ kind: string; referenceType: string; referenceId: string; status: string; summary: string; verifiedAt?: string }>; + metadata?: Record; + supersededById?: string; + createdAt: string; + updatedAt: string; +}; + +export type WorkGraphSnapshot = { + generatedAt: string; + nodes: WorkGraphNodeView[]; + ready: WorkGraphNodeView[]; + blockers: Array; + parallelGroups: string[][]; + counts: { + total: number; + ready: number; + running: number; + waiting: number; + needsUser: number; + blocked: number; + done: number; + future: number; + }; +}; + +type Projection = { + stableKey: string; + source: string; + sourceId: string; + type: string; + title: string; + canonicalLifecycle: string; + domainLifecycle: string; + scope: WorkGraphScope; + priority: number; + phase?: string; + risk: string; + blockingStatus: string; + executionEligibility: WorkGraphEligibility; + blockReason?: string; + metadataJson?: Prisma.InputJsonValue; + completionEvidenceJson?: Prisma.InputJsonValue; + supersededByStableKey?: string; +}; + +type WorkGraphNodeRecord = Prisma.WorkGraphNodeGetPayload<{ include: { dependencies: true; dependents: true; evidence: true } }>; + +const asRecord = (value: unknown): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +const asString = (value: unknown, fallback = "") => typeof value === "string" ? value : fallback; +const asArray = (value: unknown) => Array.isArray(value) ? value : []; +const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +const dateString = (value: Date | null | undefined) => value?.toISOString(); +const sourceTypeForItem = (item: AutonomyRoadmapItem) => item.source === "user_assignment" ? "feature" : item.source === "brainstorm" ? "brainstorm" : item.source === "finding" || item.source === "remediation" ? "remediation" : item.source === "release" ? "release" : item.source === "bug" ? "bug" : "feature"; +const priorityForSeverity = (severity: string) => severity === "critical" ? 100 : severity === "warning" ? 75 : 45; + +const autonomyProjection = (projectId: string, autonomy: unknown): { rows: Projection[]; dependencies: Array<{ nodeKey: string; dependencyKey: string; type: string }> } => { + const state = normalizeAutonomyState(autonomy); + const itemStatus = new Map((state.roadmap?.phases ?? []).flatMap((phase) => phase.items.map((item) => [item.id, item.status] as const))); + const rows: Projection[] = []; + const dependencies: Array<{ nodeKey: string; dependencyKey: string; type: string }> = []; + for (const phase of state.roadmap?.phases ?? []) { + for (const item of phase.items) { + const scope = item.scope ?? "committed"; + const dependencyIds = item.dependsOn ?? []; + const unresolvedDependencies = dependencyIds.filter((dependencyId) => itemStatus.get(dependencyId) !== "completed"); + const completed = item.status === "completed"; + const superseded = Boolean(item.supersededBy); + const running = item.status === "in_progress"; + const blocked = item.status === "blocked"; + const waitingExternal = Boolean(item.waitingExternal); + const eligibility: WorkGraphEligibility = superseded ? "superseded" : completed ? "done" : running ? "running" : scope === "future" || scope === "stretch" ? "blocked" : blocked ? "blocked" : waitingExternal || unresolvedDependencies.length ? "waiting" : "ready"; + rows.push({ + stableKey: `autonomy:${item.id}`, + source: "autonomy", + sourceId: item.id, + type: sourceTypeForItem(item), + title: item.title, + canonicalLifecycle: superseded ? "superseded" : completed ? "done" : running ? "running" : blocked ? "recovering" : waitingExternal || unresolvedDependencies.length ? "waiting" : "ready", + domainLifecycle: item.status, + scope, + priority: item.priority ?? (item.source === "user_assignment" ? 90 : 50), + phase: phase.title, + risk: item.risk ?? "normal", + blockingStatus: superseded ? "closed" : unresolvedDependencies.length ? "dependency" : waitingExternal ? "external" : blocked ? "blocked" : "open", + executionEligibility: eligibility, + ...(item.lastError ? { blockReason: item.lastError } : waitingExternal ? { blockReason: "An external dependency is unavailable; ProjectBrain will reconsider this work when it recovers." } : {}), + metadataJson: json({ instruction: item.instruction, attempts: item.attempts, deliveryId: item.deliveryId, touchedPaths: item.touchedPaths, domain: item.domain, releaseBlocking: item.releaseBlocking, waitingExternal: item.waitingExternal, supersededBy: item.supersededBy }), + ...(item.supersededBy ? { supersededByStableKey: `delivery:${item.supersededBy}` } : {}), + ...(item.completionEvidence?.length ? { completionEvidenceJson: json(item.completionEvidence.map((referenceId) => ({ kind: "completion", referenceType: "autonomy", referenceId }))) } : {}), + }); + for (const dependencyId of dependencyIds) dependencies.push({ nodeKey: `autonomy:${item.id}`, dependencyKey: `autonomy:${dependencyId}`, type: "blocks" }); + } + } + return { rows, dependencies }; +}; + +const deliveryProjection = (project: { deliveryPlans: Array<{ id: string; title: string; status: string; priority: string; initiativeId: string | null; problem: string; desiredOutcome: string; updatedAt: Date; runs: Array<{ mergeCommitSha: string | null; verifiedAt: Date | null; completedAt: Date | null; changes?: Array<{ path: string }> }> }> }) => project.deliveryPlans.map((delivery): Projection => { + const done = ["merged", "verified", "released"].includes(delivery.status); + const superseded = delivery.status === "superseded"; + const attention = delivery.status === "awaiting_approval"; + const active = ["executing", "validating", "pull_request_open"].includes(delivery.status); + const failed = ["failed", "blocked", "cancelled"].includes(delivery.status); + const hasMergeEvidence = delivery.runs.some((run) => Boolean(run.mergeCommitSha)); + return { + stableKey: `delivery:${delivery.id}`, + source: "delivery", + sourceId: delivery.id, + type: "delivery", + title: delivery.title, + canonicalLifecycle: superseded ? "superseded" : done ? "done" : attention ? "needs_user" : active ? "running" : failed ? "recovering" : "ready", + domainLifecycle: delivery.status, + scope: "committed", + priority: delivery.priority === "critical" ? 100 : delivery.priority === "high" ? 80 : 55, + risk: delivery.priority === "critical" ? "critical" : "normal", + blockingStatus: superseded ? "closed" : attention ? "approval" : failed ? "blocked" : "open", + executionEligibility: superseded ? "superseded" : done ? "done" : attention ? "needs_user" : active ? "running" : failed ? "blocked" : "ready", + ...(failed ? { blockReason: `Delivery is ${delivery.status}.` } : {}), + metadataJson: json({ problem: delivery.problem, desiredOutcome: delivery.desiredOutcome, initiativeId: delivery.initiativeId, touchedPaths: delivery.runs.flatMap((run) => run.changes?.map((change) => change.path) ?? []), updatedAt: delivery.updatedAt.toISOString() }), + ...(hasMergeEvidence ? { completionEvidenceJson: json(delivery.runs.filter((run) => run.mergeCommitSha).map((run) => ({ kind: "merge", referenceType: "delivery_run", referenceId: run.mergeCommitSha, verifiedAt: dateString(run.verifiedAt) }))) } : {}), + }; +}); + +const findingProjection = (project: { repositoryFindings: Array<{ id: string; title: string; severity: string; status: string; confidence: number; riskScore: number; updatedAt: Date; resolvedAt: Date | null; investigations: Array<{ deliveryPlanId: string | null }> }> }) => project.repositoryFindings.map((finding): Projection => { + const done = finding.status === "resolved"; + const suppressed = finding.status === "suppressed"; + const active = !done && !suppressed; + const linkedDeliveryId = finding.investigations.find((investigation) => investigation.deliveryPlanId)?.deliveryPlanId; + return { + stableKey: `finding:${finding.id}`, + source: "finding", + sourceId: finding.id, + type: "remediation", + title: finding.title, + canonicalLifecycle: done ? "done" : active ? "ready" : "failed", + domainLifecycle: finding.status, + scope: active ? "required" : "committed", + priority: Math.max(priorityForSeverity(finding.severity), Math.min(100, finding.riskScore)), + risk: finding.severity, + blockingStatus: finding.severity === "critical" && active ? "release" : active ? "open" : "closed", + executionEligibility: done ? "done" : suppressed ? "blocked" : "ready", + ...(suppressed ? { blockReason: "Finding is suppressed by policy." } : {}), + metadataJson: json({ confidence: finding.confidence, riskScore: finding.riskScore, linkedDeliveryId, updatedAt: finding.updatedAt.toISOString() }), + ...(finding.resolvedAt ? { completionEvidenceJson: json([{ kind: "resolved", referenceType: "repository_finding", referenceId: finding.id, verifiedAt: finding.resolvedAt.toISOString() }]) } : {}), + }; +}); + +const initiativeProjection = (project: { initiatives: Array<{ id: string; title: string; status: string; priority: string; intent: string; approvalState: string }> }) => project.initiatives.map((initiative): Projection => { + const done = ["completed", "delivered", "verified"].includes(initiative.status); + const attention = initiative.approvalState === "pending" && ["proposed", "draft"].includes(initiative.status); + return { + stableKey: `initiative:${initiative.id}`, + source: "roadmap", + sourceId: initiative.id, + type: "roadmap", + title: initiative.title, + canonicalLifecycle: done ? "done" : attention ? "needs_user" : "ready", + domainLifecycle: initiative.status, + scope: attention ? "future" : "committed", + priority: initiative.priority === "critical" ? 100 : initiative.priority === "high" ? 80 : 50, + risk: "normal", + blockingStatus: attention ? "approval" : "open", + executionEligibility: done ? "done" : attention ? "needs_user" : "ready", + ...(attention ? { blockReason: "Roadmap initiative awaits an explicit decision." } : {}), + metadataJson: json({ intent: initiative.intent, approvalState: initiative.approvalState }), + }; +}); + +const featureProjection = (project: { features: Array<{ id: string; name: string; description: string; status: string; lifecycle: string; approvalState: string; confidence: number; health: number; verificationState: string | null; verifiedAt: Date | null; updatedAt: Date; featureGroupId: string }> }) => project.features.filter((feature) => feature.lifecycle !== "archived").map((feature): Projection => { + const done = feature.status === "done" || ["verified", "production_proven", "deprecated"].includes(feature.lifecycle); + const attention = !done && feature.lifecycle === "planned" && feature.approvalState === "pending"; + const future = !done && feature.lifecycle === "feature_candidate"; + return { + stableKey: `feature:${feature.id}`, + source: "feature", + sourceId: feature.id, + type: "feature", + title: feature.name, + canonicalLifecycle: done ? "done" : attention ? "needs_user" : "ready", + domainLifecycle: `${feature.status}:${feature.lifecycle}`, + scope: future ? "future" : "committed", + priority: feature.lifecycle === "active" ? 65 : feature.lifecycle === "planned" ? 55 : 40, + risk: feature.health < 40 ? "high" : "normal", + blockingStatus: attention ? "human" : done ? "closed" : "open", + executionEligibility: done ? "done" : attention ? "needs_user" : future ? "blocked" : "ready", + ...(attention ? { blockReason: "Feature is planned but awaits an explicit approval." } : future ? { blockReason: "Feature candidate remains Future until explicitly promoted or committed." } : {}), + metadataJson: json({ description: feature.description, featureGroupId: feature.featureGroupId, confidence: feature.confidence, health: feature.health, verificationState: feature.verificationState, verifiedAt: dateString(feature.verifiedAt) }), + }; +}); + +const brainstormProjection = (project: { brainstormSessions: Array<{ id: string; title: string; prompt: string; status: string; nextActionsJson: Prisma.JsonValue; updatedAt: Date }> }) => project.brainstormSessions.filter((session) => !["archived", "failed"].includes(session.status)).map((session): Projection => ({ + stableKey: `brainstorm:${session.id}`, + source: "brainstorm", + sourceId: session.id, + type: "brainstorm", + title: session.title, + canonicalLifecycle: "ready", + domainLifecycle: session.status, + scope: "future", + priority: 20, + phase: "Brainstorm", + risk: "normal", + blockingStatus: "scope", + executionEligibility: "blocked", + blockReason: "Brainstorm suggestions stay Future until a valid promotion or committed requirement exists.", + metadataJson: json({ prompt: session.prompt, nextActions: asArray(session.nextActionsJson) }), +})); + +const reviewProjection = (project: { reviewQueueItems: Array<{ id: string; title: string; description: string; status: "pending" | "accepted" | "rejected" | "merged" | "converted"; priority: string; type: string; source: string; sourceType: string; createdBy: string }> }) => project.reviewQueueItems.filter((item) => reviewRequiresHumanAttention(item)).map((item): Projection => ({ + stableKey: `attention:${item.id}`, + source: "attention", + sourceId: item.id, + type: item.type || "decision", + title: item.title, + canonicalLifecycle: "needs_user", + domainLifecycle: item.status, + scope: "required", + priority: item.priority === "critical" ? 100 : item.priority === "high" ? 80 : 65, + risk: item.priority === "critical" ? "critical" : "normal", + blockingStatus: "human", + executionEligibility: "needs_user", + blockReason: item.description, + metadataJson: json({ source: "review_queue" }), +})); + +const findProject = async (projectIdOrSlug: string) => { + const project = await prisma.project.findFirst({ + where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, + select: { + id: true, + slug: true, + workspaceState: { select: { notesJson: true } }, + deliveryPlans: { select: { id: true, title: true, status: true, priority: true, initiativeId: true, problem: true, desiredOutcome: true, updatedAt: true, runs: { select: { mergeCommitSha: true, verifiedAt: true, completedAt: true, changes: { select: { path: true } } } } } }, + repositoryFindings: { select: { id: true, title: true, severity: true, status: true, confidence: true, riskScore: true, updatedAt: true, resolvedAt: true, investigations: { select: { deliveryPlanId: true } } } }, + initiatives: { select: { id: true, title: true, status: true, priority: true, intent: true, approvalState: true } }, + reviewQueueItems: { select: { id: true, title: true, description: true, status: true, priority: true, type: true, source: true, sourceType: true, createdBy: true } }, + features: { where: { deletedAt: null }, select: { id: true, name: true, description: true, status: true, lifecycle: true, approvalState: true, confidence: true, health: true, verificationState: true, verifiedAt: true, updatedAt: true, featureGroupId: true } }, + brainstormSessions: { where: { deletedAt: null }, select: { id: true, title: true, prompt: true, status: true, nextActionsJson: true, updatedAt: true } }, + }, + }); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + return project; +}; + +const upsertProjection = async (projectId: string, projection: Projection) => { + const { supersededByStableKey: _supersededByStableKey, ...persistedProjection } = projection; + const existing = await prisma.workGraphNode.findUnique({ where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } }, select: { owner: true, leaseExpiresAt: true, executionEligibility: true, completedAt: true, domainLifecycle: true, scope: true } }); + const hasLiveLease = Boolean(existing?.owner && existing.leaseExpiresAt && existing.leaseExpiresAt.getTime() > Date.now() && existing.executionEligibility === "running"); + // A completed graph node is durable evidence, but it must not hide an + // authoritative source reopening the work. Preserve completion only while + // the source lifecycle still describes the same completed reality. + const hasDurableCompletion = existing?.executionEligibility === "done" + && Boolean(existing.completedAt) + && existing.domainLifecycle === projection.domainLifecycle + && projection.executionEligibility !== "superseded"; + // A graph claim/reclaim is operational state that may briefly lead the + // source record. Keep that state while the source lifecycle itself has not + // changed; a source lifecycle change remains authoritative and can reset it. + const sameActiveFamily = Boolean(existing) + && existing?.domainLifecycle === projection.domainLifecycle + && existing?.scope === projection.scope + && ["ready", "running"].includes(existing?.executionEligibility ?? "") + && ["ready", "running"].includes(projection.executionEligibility); + const preserveOperationalState = hasLiveLease || hasDurableCompletion || sameActiveFamily; + return prisma.workGraphNode.upsert({ + where: { projectId_stableKey: { projectId, stableKey: projection.stableKey } }, + create: { projectId, ...persistedProjection, ...(persistedProjection.metadataJson ? { metadataJson: persistedProjection.metadataJson } : {}), ...(persistedProjection.completionEvidenceJson ? { completionEvidenceJson: persistedProjection.completionEvidenceJson } : {}), ...(persistedProjection.executionEligibility === "done" ? { completedAt: new Date() } : {}) }, + update: { source: persistedProjection.source, sourceId: persistedProjection.sourceId, type: persistedProjection.type, title: persistedProjection.title, domainLifecycle: persistedProjection.domainLifecycle, priority: persistedProjection.priority, phase: persistedProjection.phase ?? null, risk: persistedProjection.risk, ...(preserveOperationalState ? { scope: persistedProjection.scope } : { canonicalLifecycle: persistedProjection.canonicalLifecycle, scope: persistedProjection.scope, blockingStatus: persistedProjection.blockingStatus, executionEligibility: persistedProjection.executionEligibility, blockReason: persistedProjection.blockReason ?? null }), ...(persistedProjection.metadataJson ? { metadataJson: persistedProjection.metadataJson } : {}), ...(persistedProjection.completionEvidenceJson ? { completionEvidenceJson: persistedProjection.completionEvidenceJson } : {}), ...(persistedProjection.executionEligibility === "done" && !existing?.completedAt ? { completedAt: new Date() } : {}) }, + }); +}; + +export const syncProjectWorkGraph = async (projectIdOrSlug: string, actor = "system", options: { audit?: boolean } = {}) => { + const project = await findProject(projectIdOrSlug); + const autonomy = asRecord(asRecord(project.workspaceState?.notesJson).autonomy); + const autonomyRows = autonomyProjection(project.id, autonomy); + const projections = [...autonomyRows.rows, ...deliveryProjection(project), ...findingProjection(project), ...initiativeProjection(project), ...reviewProjection(project), ...featureProjection(project), ...brainstormProjection(project)]; + const projectionKeys = new Set(projections.map((projection) => projection.stableKey)); + const nodes = new Map(); + for (const projection of projections) { + const node = await upsertProjection(project.id, projection); + nodes.set(projection.stableKey, node); + } + for (const projection of projections) { + if (!projection.supersededByStableKey) continue; + const node = nodes.get(projection.stableKey); + const replacement = nodes.get(projection.supersededByStableKey) ?? nodes.get(`autonomy:${projection.supersededByStableKey.replace(/^delivery:/, "")}`); + if (node && replacement && node.id !== replacement.id) await prisma.workGraphNode.update({ where: { id: node.id }, data: { supersededById: replacement.id } }); + } + const staleNodes = await prisma.workGraphNode.findMany({ where: { projectId: project.id }, select: { id: true, stableKey: true, title: true, executionEligibility: true } }); + for (const stale of staleNodes) { + if (projectionKeys.has(stale.stableKey) || ["done", "superseded"].includes(stale.executionEligibility)) continue; + await prisma.workGraphNode.update({ where: { id: stale.id }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "superseded", canonicalLifecycle: "superseded", blockingStatus: "closed", blockReason: "The authoritative source no longer exposes this work item; retained history is preserved as superseded." } }); + await prisma.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: stale.id, eventType: "work.superseded", actor, summary: `Superseded stale work '${stale.title}'.`, payloadJson: json({ stableKey: stale.stableKey }) } }); + } + const dependencyPairs = [...autonomyRows.dependencies]; + for (const delivery of project.deliveryPlans) if (delivery.initiativeId) dependencyPairs.push({ nodeKey: `delivery:${delivery.id}`, dependencyKey: `initiative:${delivery.initiativeId}`, type: "context" }); + for (const finding of project.repositoryFindings) { + const investigation = finding.investigations.find((candidate) => candidate.deliveryPlanId); + if (investigation?.deliveryPlanId) dependencyPairs.push({ nodeKey: `finding:${finding.id}`, dependencyKey: `delivery:${investigation.deliveryPlanId}`, type: "remediation" }); + } + // Rebuild only derived edges. Explicit human dependency edits are durable + // graph data and must survive every reconciliation pass. + await prisma.workGraphDependency.deleteMany({ where: { projectId: project.id, createdBy: "autonomy-sync" } }); + for (const pair of dependencyPairs) { + const node = nodes.get(pair.nodeKey); + const dependency = nodes.get(pair.dependencyKey); + if (!node || !dependency || node.id === dependency.id) continue; + await prisma.workGraphDependency.create({ data: { projectId: project.id, nodeId: node.id, dependsOnNodeId: dependency.id, dependencyType: pair.type, createdBy: "autonomy-sync" } }).catch((error: unknown) => { + if ((error as { code?: string }).code !== "P2002") throw error; + }); + } + if (options.audit) await prisma.workGraphAuditEvent.create({ data: { projectId: project.id, eventType: "graph.reconciled", actor, summary: `Reconciled ${projections.length} cross-domain work node(s).`, payloadJson: json({ nodeCount: projections.length, dependencyCount: dependencyPairs.length }) } }); + return project.id; +}; + +const nodeView = (node: WorkGraphNodeRecord): WorkGraphNodeView => ({ + id: node.id, + projectId: node.projectId, + stableKey: node.stableKey, + source: node.source, + sourceId: node.sourceId, + type: node.type, + title: node.title, + canonicalLifecycle: node.canonicalLifecycle, + domainLifecycle: node.domainLifecycle, + scope: (["required", "committed", "stretch", "future"].includes(node.scope) ? node.scope : "committed") as WorkGraphScope, + priority: node.priority, + ...(node.phase ? { phase: node.phase } : {}), + risk: node.risk, + blockingStatus: node.blockingStatus, + executionEligibility: node.executionEligibility as WorkGraphEligibility, + ...(node.blockReason ? { blockReason: node.blockReason } : {}), + ...(node.owner ? { owner: node.owner } : {}), + ...(node.leaseExpiresAt ? { leaseExpiresAt: node.leaseExpiresAt.toISOString() } : {}), + attemptCount: node.attemptCount, + consecutiveFailureCount: node.consecutiveFailureCount, + ...(node.lastMeaningfulProgressAt ? { lastMeaningfulProgressAt: node.lastMeaningfulProgressAt.toISOString() } : {}), + ...(node.completedAt ? { completedAt: node.completedAt.toISOString() } : {}), + dependencies: node.dependencies.map((edge) => ({ id: edge.id, nodeId: edge.nodeId, dependsOnNodeId: edge.dependsOnNodeId, dependencyType: edge.dependencyType })), + dependents: node.dependents.map((edge) => edge.nodeId), + completionEvidence: (() => { + const relationEvidence = node.evidence.map((evidence) => ({ kind: evidence.kind, referenceType: evidence.referenceType, referenceId: evidence.referenceId, status: evidence.status, summary: evidence.summary, ...(evidence.verifiedAt ? { verifiedAt: evidence.verifiedAt.toISOString() } : {}) })); + const projectedEvidence = asArray(node.completionEvidenceJson).flatMap((value) => { + const row = asRecord(value); + const kind = asString(row.kind); + const referenceType = asString(row.referenceType); + const referenceId = asString(row.referenceId); + if (!kind || !referenceType || !referenceId) return []; + return [{ kind, referenceType, referenceId, status: asString(row.status, "observed"), summary: asString(row.summary, "Completion evidence recorded by the authoritative source."), ...(asString(row.verifiedAt) ? { verifiedAt: asString(row.verifiedAt) } : {}) }]; + }); + return Array.from(new Map([...relationEvidence, ...projectedEvidence].map((evidence) => [`${evidence.kind}:${evidence.referenceType}:${evidence.referenceId}`, evidence])).values()); + })(), + ...(node.metadataJson && typeof node.metadataJson === "object" && !Array.isArray(node.metadataJson) ? { metadata: node.metadataJson as Record } : {}), + ...(node.supersededById ? { supersededById: node.supersededById } : {}), + createdAt: node.createdAt.toISOString(), + updatedAt: node.updatedAt.toISOString(), +}); + +const loadNodes = async (projectId: string) => prisma.workGraphNode.findMany({ where: { projectId }, include: { dependencies: true, dependents: true, evidence: true }, orderBy: [{ executionEligibility: "asc" }, { priority: "desc" }, { updatedAt: "desc" }] }); + +const deriveNodes = (rawNodes: WorkGraphNodeView[]) => rawNodes.map((node) => { + if (node.executionEligibility === "running" && (!node.leaseExpiresAt || Date.parse(node.leaseExpiresAt) <= Date.now())) { + const { owner: _owner, leaseExpiresAt: _leaseExpiresAt, ...reclaimable } = node; + return { ...reclaimable, executionEligibility: "ready" as const, canonicalLifecycle: "ready", blockReason: "Previous lease expired; work can be safely reclaimed." }; + } + if (node.executionEligibility === "done" || node.executionEligibility === "running" || node.executionEligibility === "needs_user" || node.executionEligibility === "superseded") return node; + if (node.scope === "future" || node.scope === "stretch") return { ...node, executionEligibility: "blocked" as const, blockReason: node.blockReason ?? "Outside Required + Committed scope until explicitly promoted." }; + const blockedBy = node.dependencies.filter((edge) => { + const dependency = rawNodes.find((candidate) => candidate.id === edge.dependsOnNodeId); + return dependency && dependency.executionEligibility !== "done" && dependency.canonicalLifecycle !== "done"; + }); + return blockedBy.length ? { ...node, executionEligibility: "waiting" as const, blockingStatus: "dependency" } : node; +}); + +const genericScore = (node: WorkGraphNodeView, all: WorkGraphNodeView[]) => { + const downstream = all.filter((candidate) => (candidate.scope === "required" || candidate.scope === "committed") && !["done", "superseded"].includes(candidate.executionEligibility) && candidate.dependencies.some((edge) => edge.dependsOnNodeId === node.id)).length; + const scope = node.scope === "required" ? 45 : node.scope === "committed" ? 30 : -40; + const severity = node.risk === "critical" ? 30 : node.risk === "high" ? 18 : node.risk === "medium" ? 10 : 3; + const release = node.blockingStatus === "release" ? 30 : 0; + const retryPenalty = Math.min(30, node.consecutiveFailureCount * 6); + return node.priority + scope + severity + release + downstream * 20 - retryPenalty; +}; + +export const rankReadyWork = (nodes: WorkGraphNodeView[]) => nodes.filter((node) => node.executionEligibility === "ready" && node.scope !== "future" && node.scope !== "stretch").sort((left, right) => genericScore(right, nodes) - genericScore(left, nodes) || right.priority - left.priority || left.id.localeCompare(right.id)); + +export const buildParallelGroups = (ready: WorkGraphNodeView[]) => { + const conflictKeys = (node: WorkGraphNodeView) => { + const metadata = asRecord(node.metadata); + const paths = asArray(metadata.touchedPaths).filter((path): path is string => typeof path === "string").map((path) => path.toLowerCase().replaceAll("\\", "/")); + const keys = new Set(paths.flatMap((path) => { + const parts = path.split("/"); + return parts.map((_, index) => `path:${parts.slice(0, index + 1).join("/")}`); + })); + if (node.type === "delivery") keys.add("resource:delivery"); + if (node.type === "remediation") keys.add("resource:analysis"); + const domain = typeof metadata.domain === "string" ? metadata.domain.toLowerCase() : undefined; + if (domain) keys.add(`domain:${domain}`); + return keys; + }; + const groups: string[][] = []; + for (const node of ready) { + const keys = conflictKeys(node); + const group = groups.find((candidate) => candidate.every((id) => { + const existing = ready.find((item) => item.id === id); + if (!existing) return false; + const existingKeys = conflictKeys(existing); + return !Array.from(existingKeys).some((key) => keys.has(key)) && existing.type !== "delivery" && node.type !== "delivery"; + })); + if (group) group.push(node.id); + else groups.push([node.id]); + } + return groups; +}; + +export const getProjectWorkGraph = async (projectIdOrSlug: string): Promise => { + const projectId = await syncProjectWorkGraph(projectIdOrSlug); + const rawNodes = (await loadNodes(projectId)).map(nodeView); + const nodes = deriveNodes(rawNodes); + const ready = rankReadyWork(nodes); + const blockers = nodes.filter((node) => ["waiting", "needs_user", "blocked"].includes(node.executionEligibility)).map((node) => ({ ...node, blockedBy: node.dependencies.filter((edge) => { + const dependency = nodes.find((candidate) => candidate.id === edge.dependsOnNodeId); + return dependency && dependency.executionEligibility !== "done" && dependency.canonicalLifecycle !== "done"; + }).map((edge) => edge.dependsOnNodeId) })); + return { + generatedAt: new Date().toISOString(), + nodes, + ready, + blockers, + parallelGroups: buildParallelGroups(ready), + counts: { + total: nodes.length, + ready: ready.length, + running: nodes.filter((node) => node.executionEligibility === "running").length, + waiting: nodes.filter((node) => node.executionEligibility === "waiting").length, + needsUser: nodes.filter((node) => node.executionEligibility === "needs_user").length, + blocked: nodes.filter((node) => node.executionEligibility === "blocked").length, + done: nodes.filter((node) => node.executionEligibility === "done").length, + future: nodes.filter((node) => node.scope === "future" || node.scope === "stretch").length, + }, + }; +}; + +const assertProjectNode = async (projectIdOrSlug: string, nodeId: string) => { + const project = await findProject(projectIdOrSlug); + const node = await prisma.workGraphNode.findFirst({ where: { id: nodeId, projectId: project.id }, include: { dependencies: true, dependents: true, evidence: true } }); + if (!node) throw new Error("WORK_GRAPH_NODE_NOT_FOUND"); + return { project, node }; +}; + +export const claimReadyWork = async (projectIdOrSlug: string, actor: string, leaseMinutes = 30) => { + const project = await findProject(projectIdOrSlug); + await syncProjectWorkGraph(project.id, actor); + const now = new Date(); + return prisma.$transaction(async (transaction) => { + // Load the complete project graph before ranking. Filtering out Future or + // currently-owned dependency nodes here would make a dependent item look + // runnable even though the dependency is still unresolved. + const rows = await transaction.workGraphNode.findMany({ where: { projectId: project.id }, include: { dependencies: true, dependents: true, evidence: true } }); + const candidates = rankReadyWork(deriveNodes(rows.map(nodeView))); + const chosen = candidates[0]; + if (!chosen) return undefined; + const leaseToken = randomUUID(); + const updated = await transaction.workGraphNode.updateMany({ where: { id: chosen.id, projectId: project.id, scope: { in: ["required", "committed"] }, OR: [{ executionEligibility: "ready" }, { executionEligibility: "running", leaseExpiresAt: { lt: now } }], AND: [{ OR: [{ owner: null }, { leaseExpiresAt: { lt: now } }] }] }, data: { owner: actor, leaseToken, leaseExpiresAt: new Date(now.getTime() + Math.max(5, Math.min(120, leaseMinutes)) * 60_000), executionEligibility: "running", canonicalLifecycle: "running", blockReason: null, attemptCount: { increment: 1 }, lastMeaningfulProgressAt: now } }); + if (updated.count !== 1) return undefined; + await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: chosen.id, eventType: "work.claimed", actor, summary: `Claimed '${chosen.title}'.`, payloadJson: json({ leaseToken }) } }); + const result = await transaction.workGraphNode.findUniqueOrThrow({ where: { id: chosen.id }, include: { dependencies: true, dependents: true, evidence: true } }); + return { leaseToken, node: nodeView(result) }; + }); +}; + +export const releaseWork = async (projectIdOrSlug: string, nodeId: string, actor: string, leaseToken: string, reason = "Lease released.") => { + const { project, node } = await assertProjectNode(projectIdOrSlug, nodeId); + const now = new Date(); + const updated = await prisma.workGraphNode.updateMany({ where: { id: node.id, projectId: project.id, owner: actor, leaseToken, executionEligibility: "running", leaseExpiresAt: { gt: now } }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "ready", canonicalLifecycle: "ready", lastMeaningfulProgressAt: now } }); + if (updated.count !== 1) throw new Error("WORK_GRAPH_LEASE_STALE"); + await prisma.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: node.id, eventType: "work.released", actor, summary: reason } }); + return getProjectWorkGraph(project.id); +}; + +export const reclaimExpiredWork = async (projectIdOrSlug: string, actor = "system") => { + const project = await findProject(projectIdOrSlug); + const now = new Date(); + const reclaimedCount = await prisma.$transaction(async (transaction) => { + const expired = await transaction.workGraphNode.findMany({ where: { projectId: project.id, leaseExpiresAt: { lt: now }, owner: { not: null }, executionEligibility: "running" }, select: { id: true, title: true, owner: true, leaseExpiresAt: true } }); + let count = 0; + for (const node of expired) { + const updated = await transaction.workGraphNode.updateMany({ where: { id: node.id, projectId: project.id, executionEligibility: "running", leaseExpiresAt: { lt: now } }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "ready", canonicalLifecycle: "ready", consecutiveFailureCount: { increment: 1 }, blockReason: "Previous lease expired without a completion report." } }); + if (updated.count !== 1) continue; + count += 1; + await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: node.id, eventType: "work.reclaimed", actor, summary: `Reclaimed stale work '${node.title}'.`, payloadJson: json({ previousOwner: node.owner, expiredAt: node.leaseExpiresAt?.toISOString() }) } }); + } + return count; + }); + return { reclaimedCount, graph: await getProjectWorkGraph(project.id) }; +}; + +export const recordWorkCompletion = async (projectIdOrSlug: string, nodeId: string, actor: string, leaseToken: string, input: { summary: string; evidence?: Array<{ kind: string; referenceType: string; referenceId: string; status?: string }> }) => { + const { project, node } = await assertProjectNode(projectIdOrSlug, nodeId); + const now = new Date(); + // A completion report is itself an audit event, but it is not proof of a + // validation, merge or deployment stage. Only persist evidence explicitly + // supplied by the executor so the graph never upgrades an absent stage into + // invented verified evidence. + const evidence = input.evidence ?? []; + await prisma.$transaction(async (transaction) => { + const updated = await transaction.workGraphNode.updateMany({ where: { id: node.id, projectId: project.id, owner: actor, leaseToken, executionEligibility: "running", leaseExpiresAt: { gt: now } }, data: { owner: null, leaseToken: null, leaseExpiresAt: null, executionEligibility: "done", canonicalLifecycle: "done", blockingStatus: "closed", completedAt: now, lastMeaningfulProgressAt: now, consecutiveFailureCount: 0, completionEvidenceJson: evidence.length ? json(evidence) : Prisma.JsonNull } }); + if (updated.count !== 1) throw new Error("WORK_GRAPH_LEASE_STALE"); + for (const item of evidence) await transaction.workGraphEvidence.upsert({ where: { nodeId_kind_referenceType_referenceId: { nodeId: node.id, kind: item.kind, referenceType: item.referenceType, referenceId: item.referenceId } }, create: { projectId: project.id, nodeId: node.id, kind: item.kind, referenceType: item.referenceType, referenceId: item.referenceId, status: item.status ?? "verified", summary: input.summary, verifiedAt: now }, update: { status: item.status ?? "verified", summary: input.summary, verifiedAt: now } }); + await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: node.id, eventType: "work.completed", actor, summary: input.summary, payloadJson: json({ evidence }) } }); + }); + return getProjectWorkGraph(project.id); +}; + +export const updateWorkDependencies = async (projectIdOrSlug: string, nodeId: string, dependencyNodeIds: string[], actor: string) => { + const { project, node } = await assertProjectNode(projectIdOrSlug, nodeId); + const uniqueIds = Array.from(new Set(dependencyNodeIds)); + if (uniqueIds.includes(nodeId)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE"); + const allNodes = await prisma.workGraphNode.findMany({ where: { projectId: project.id }, include: { dependencies: true } }); + const edges = new Map(allNodes.map((candidate) => [candidate.id, candidate.dependencies.map((edge) => edge.dependsOnNodeId)])); + const reaches = (from: string, target: string, seen = new Set()): boolean => { + if (from === target) return true; + if (seen.has(from)) return false; + seen.add(from); + return (edges.get(from) ?? []).some((dependency) => reaches(dependency, target, seen)); + }; + for (const dependencyId of uniqueIds) { + if (!edges.has(dependencyId) || reaches(dependencyId, node.id)) throw new Error("WORK_GRAPH_DEPENDENCY_CYCLE"); + } + await prisma.$transaction(async (transaction) => { + await transaction.workGraphDependency.deleteMany({ where: { projectId: project.id, nodeId: node.id } }); + if (uniqueIds.length) await transaction.workGraphDependency.createMany({ data: uniqueIds.map((dependsOnNodeId) => ({ projectId: project.id, nodeId: node.id, dependsOnNodeId, createdBy: actor })) }); + await transaction.workGraphAuditEvent.create({ data: { projectId: project.id, nodeId: node.id, eventType: "dependencies.updated", actor, summary: `Updated dependencies for '${node.title}'.`, payloadJson: json({ dependencyNodeIds: uniqueIds }) } }); + }); + return getProjectWorkGraph(project.id); +}; + +export const listWorkGraphHistory = async (projectIdOrSlug: string, limit = 100) => { + const project = await findProject(projectIdOrSlug); + return prisma.workGraphAuditEvent.findMany({ where: { projectId: project.id }, orderBy: { createdAt: "desc" }, take: Math.max(1, Math.min(200, limit)) }); +}; diff --git a/backend/src/types/api.ts b/backend/src/types/api.ts new file mode 100644 index 0000000..294fce7 --- /dev/null +++ b/backend/src/types/api.ts @@ -0,0 +1,1599 @@ +export type RequestId = string; +export type Timestamp = string; + +export type ValidationIssue = { + field?: string; + message: string; + code?: string; +}; + +export type ApiMeta = { + requestId: RequestId; + timestamp: Timestamp; +}; + +export type ApiResponse = { + data: T; + meta: ApiMeta; +}; + +export type ApiErrorResponse = { + error: { + code: string; + message: string; + validation?: ValidationIssue[]; + }; + meta: ApiMeta; +}; + +export type HealthResponse = { + status: "ok"; + service: "projectbrain-api"; + version: string; + environment: string; +}; + +export type HealthLevel = "healthy" | "warn" | "risk" | "blocked"; + +export type ProjectStatus = "active" | "archived" | "paused"; + +export type ConfidenceLevel = 0 | 1 | 2 | 3 | 4 | 5; + +export type FocusAreaPurpose = + | "execution" + | "validation" + | "quality" + | "architecture" + | "delivery" + | "governance"; + +export type ProjectDna = { + vision: string; + goals: string[]; + nonGoals: string[]; + targetAudience: string; + principles: string[]; + successMetrics: Array<{ + metric: string; + target: string; + targetValue?: number; + operator?: ">" | "<" | ">=" | "<=" | "=" | "between"; + }>; + maturityStage: + | "Discovery" + | "Definition" + | "Execution" + | "Validation" + | "Operational"; + revision: number; + updatedAt: string; +}; + +export type ProjectConstitutionDto = { + id: string; + projectId: string; + title: string; + intent: string; + rules: string[]; + revision: number; + updatedAt: string; +}; + +export type ProjectConstraintDto = { + id: string; + projectId: string; + title: string; + description: string; + category: + | "hosting" + | "cost" + | "privacy" + | "portability" + | "operations" + | "delivery"; + status: "active" | "proposed" | "retired"; + intent: string; +}; + +export type ProjectPrincipleDto = { + id: string; + projectId: string; + title: string; + description: string; + intent: string; + status: "active" | "proposed" | "retired"; +}; + +export type ProjectInitiativeDto = { + id: string; + projectId: string; + title: string; + intent: string; + status: "proposed" | "planned" | "active" | "complete" | "paused"; + featureIds: string[]; + priority: "critical" | "important" | "normal" | "low"; +}; + +export type EntityReferenceDto = { + entityType: + | "project" + | "feature" + | "bug" + | "task" + | "decision" + | "memoryRule" + | "prompt" + | "check" + | "snapshot" + | "relationship" + | "architecture" + | "knowledge" + | "build"; + entityId: string; + projectId: string; + label: string; + link: string; +}; + +export type FocusAreaDto = { + id: string; + title: string; + purpose: FocusAreaPurpose; + owner: string; + confidence: ConfidenceLevel; + summary: string; + state: "active" | "watch" | "defer"; + entities: EntityReferenceDto[]; +}; + +export type WorkspaceStateDto = { + projectId: string; + activeContextPackId?: string; + primaryObjective: string; + focusAreas: FocusAreaDto[]; + openTasksCount: number; + openBugCount: number; + activeSection: + | "workspace" + | "context-packs" + | "prompts" + | "insights" + | "overview" + | "relationships" + | "snapshots"; + updatedAt: string; + notes: string[]; +}; + +export type ProjectSummaryDto = { + id: string; + name: string; + description: string; + health: number; + healthStatus: HealthLevel; + lastSync: string; + domain: string; + status: ProjectStatus; + createdAt: string; + updatedAt: string; + activeContextPackId?: string; + resourceCounts?: { + repositories: number; + readyRepositories: number; + sources: number; + documentation: number; + pendingReviews: number; + attentionItems?: number; + activeFindings?: number; + criticalFindings?: number; + warningFindings?: number; + regressedFindings?: number; + contextPacks: number; + features: number; + deliveryPlans: number; + knowledge: number; + enabledSkills: number; + }; +}; + +export type ProjectDetailDto = ProjectSummaryDto & { + dna: ProjectDna; + constitution?: ProjectConstitutionDto; + constraints: ProjectConstraintDto[]; + principles: ProjectPrincipleDto[]; + initiatives: ProjectInitiativeDto[]; + featureGroups: Array<{ + id: string; + name: string; + description?: string; + focusArea?: FocusAreaPurpose; + featureIds: string[]; + }>; + memoryRules: Array<{ + id: string; + title: string; + category: string; + scope: string; + priority: string; + status: string; + }>; + promptTemplates: Array<{ + id: string; + name: string; + description: string; + icon: string; + }>; + workspaceState?: WorkspaceStateDto; + sourceLibrary?: Array<{ + id: string; projectId: string; title: string; kind: string; inputMode: string; originalName?: string; url?: string; + text: string; summary: string; status: string; suggestedDocumentationTargets: string[]; suggestedContextPackIds: string[]; + extractedAssetIds: string[]; createdAt: string; updatedAt: string; repositoryPath?: string; repositoryBranch?: string; + commitSha?: string; lastSyncedAt?: string; attribution: { createdBy: "imported"; sourceRun?: string; approvalState: "approved" }; + }>; +}; + +export type CreateProjectRequestDto = { + id?: string; + name: string; + description: string; + domain?: string; + primaryGoal?: string; + targetAudience?: string; + dna?: Omit & { updatedAt?: string }; + setupState?: Record; +}; + +export type UpdateProjectRequestDto = { + name?: string; + description?: string; + domain?: string; + status?: ProjectStatus; +}; + +export type UpdateActiveContextPackRequestDto = { + contextPackId: string; +}; + +export type CurrentStateSummaryDto = { + generatedAt: string; + exists: string[]; + works: string[]; + uncertain: string[]; + broken: string[]; + currentRisks: string[]; + pendingReviewItemIds: string[]; + recommendedNextActions: string[]; +}; + +export type HealthSummaryDto = { + health: number; + healthStatus: HealthLevel; + openTasksCount: number; + openBugCount: number; + riskCount: number; + pendingReviewCount: number; +}; + +export type WorkspaceReadDto = { + project: ProjectSummaryDto; + dna: ProjectDna; + activeFocusAreaId?: string; + activeContextPackId?: string; + focusAreas: FocusAreaDto[]; + workspaceState?: WorkspaceStateDto; + currentStateSummary?: CurrentStateSummaryDto; + healthSummary: HealthSummaryDto; +}; + +export type ContextPackSummaryDto = { + projectId: string; + contextPackId: string; + title: string; + category: string; + summary: string; + counts: { + assumptions: number; + constraints: number; + evidence: number; + features: number; + bugs: number; + tasks: number; + decisions: number; + memoryRules: number; + risks: number; + }; + qualityScore?: number; + createdAt: string; + updatedAt: string; +}; + +export type EvidenceRefDto = { + id: string; + type: "artifact" | "import" | "check" | "analysis" | "decision"; + refId: string; + label: string; + snapshotId?: string; +}; + +export type TrustMetadataDto = { + source: string; + confidence: number; + evidence: EvidenceRefDto[]; + lastChecked: string; + approvalState: ApprovalState; +}; + +export type ContextPackCategory = + | "architecture" + | "feature" + | "risk" + | "quality" + | "memory" + | "business"; + +export type ContextPackGeneratedBy = "import" | "user" | "check-run" | "manual"; + +export type ContextPackQualityDto = { + dnaIncluded: boolean; + constitutionIncluded: boolean; + constraintsIncluded: boolean; + featuresIncluded: boolean; + evidenceIncluded: boolean; + risksIncluded: boolean; + memoryIncluded: boolean; + doNotBreakIncluded: boolean; + verificationIncluded: boolean; + score: number; +}; + +export type ContextPackDto = { + id: string; + projectId: string; + title: string; + category: ContextPackCategory; + dna: ProjectDna; + summary: string; + assumptions: string[]; + constraints: string[]; + evidence: EvidenceRefDto[]; + signals: string[]; + featureIds?: string[]; + bugIds?: string[]; + taskIds?: string[]; + decisionIds?: string[]; + memoryRuleIds?: string[]; + architectureNotes?: string[]; + regressions?: string[]; + risks?: string[]; + quality?: ContextPackQualityDto; + currentFocus?: { + id: string; + title: string; + purpose: FocusAreaPurpose; + }; + trust: TrustMetadataDto; + generatedBy: ContextPackGeneratedBy; + createdAt: string; + updatedAt: string; +}; + + +export type ContextPackSelectionMode = "workspace" | "ai" | "codex" | "documentation" | "validation"; + +export type ContextPackSelectionTargetType = + | "project" + | "feature" + | "bug" + | "task" + | "decision" + | "memory-rule" + | "knowledge" + | "documentation" + | "build"; + +export type ContextPackSelectionRequestDto = { + mode?: ContextPackSelectionMode; + intent?: string; + targetType?: ContextPackSelectionTargetType; + targetId?: string; + maxPacks?: number; + includeUnapproved?: boolean; +}; + +export type ContextPackSelectionCandidateDto = { + contextPackId: string; + title: string; + category: ContextPackCategory; + score: number; + reasons: string[]; + warnings: string[]; + qualityScore?: number; + confidence: number; + approvalState: ApprovalState; + generatedBy: ContextPackGeneratedBy; +}; + +export type ContextPackSelectionResponseDto = { + projectId: string; + mode: ContextPackSelectionMode; + intent: string; + targetType?: ContextPackSelectionTargetType; + targetId?: string; + selectedContextPackIds: string[]; + primaryContextPackId?: string; + candidates: ContextPackSelectionCandidateDto[]; + decisionSummary: string; + generatedAt: string; +}; + +export type ContextPackSourceType = + | "workspace" + | "focus_area" + | "feature" + | "check" + | "manual"; + +export type CreateContextPackRequestDto = { + sourceType: ContextPackSourceType; + sourceId?: string; + title?: string; + includeDNA?: boolean; + includeFeatures?: boolean; + includeEvidence?: boolean; + includeMemoryRules?: boolean; + includeRisks?: boolean; + includeRegressions?: boolean; + includeArchitecture?: boolean; +}; + +export type PaginatedListResponse = { + items: T[]; + total: number; + page: number; + pageSize: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + nextCursor?: string; + sort?: string; + filters?: Record; +}; + +export type FeatureStatus = "Todo" | "In Progress" | "Done"; + +export type EvidenceStrength = "weak" | "medium" | "strong" | "verified"; + +export type ApprovalState = "pending" | "approved" | "rejected" | "blocked"; + +export type FeatureGroupDto = { + id: string; + projectId: string; + name: string; + description?: string; + focusArea?: FocusAreaPurpose; + featureIds: string[]; +}; + +export type CodeEvidenceDto = { + file: string; + lines: string; + type: "Logic" | "UI" | "Hook" | "Config"; + link?: string; +}; + +export type FeatureDto = { + id: string; + projectId: string; + groupId: string; + name: string; + description: string; + status: FeatureStatus; + lifecycle?: + | "feature_candidate" + | "planned" + | "active" + | "implemented" + | "verified" + | "production_proven" + | "deprecated" + | "archived"; + intent?: string; + initiativeId?: string; + evidenceStrength?: EvidenceStrength; + confidence: number; + health: number; + bugCount: number; + taskCount: number; + decisionCount: number; + codeEvidence: CodeEvidenceDto[]; + aiSuggestionCount: number; + evidenceIds: string[]; +}; + +export type EvidenceType = + | "artifact" + | "import" + | "analysis" + | "decision" + | "check" + | "architecture" + | "relationship" + | "memory-rule"; + +export type EvidenceDto = { + id: string; + projectId: string; + type: EvidenceType; + linkedEntity: { + type: + | "feature" + | "architecture" + | "bug" + | "task" + | "check" + | "memory-rule" + | "context-pack" + | "snapshot"; + id: string; + label: string; + }; + featureIds: string[]; + source: string; + confidence: number; + reason: string; + lastSeen: string; + approvalState: ApprovalState; + route?: string; +}; + +export type FeatureListFilters = { + status?: FeatureStatus[]; + confidence?: number; + hasEvidence?: boolean; + groupId?: string; + search?: string; + cursor?: string; +}; + +export type EvidenceListFilters = { + type?: EvidenceType[]; + featureId?: string; + confidence?: number; + search?: string; + cursor?: string; +}; + +export type ContextPackListFilters = { + category?: ContextPackCategory[]; + generatedBy?: ContextPackGeneratedBy[]; + source?: ContextPackGeneratedBy[]; + confidence?: number; + cursor?: string; +}; + +export type PromptTargetTool = + | "codex" + | "claude" + | "gpt" + | "cursor" + | "gemini" + | "local_llm" + | "markdown" + | "json" + | "mcp"; + +export type PromptPersona = + | "developer" + | "architect" + | "qa" + | "ux" + | "security" + | "devops" + | "technical_writer" + | "product_owner"; + +export type GeneratePromptRequestDto = { + contextPackId: string; + targetTool: PromptTargetTool; + persona: PromptPersona; + objective: string; + expectedOutput?: string; +}; + +export type GeneratedPromptDto = { + id: string; + projectId: string; + contextPackId: string; + targetTool: PromptTargetTool; + persona: PromptPersona; + objective: string; + expectedOutput?: string; + generatedText: string; + generatedFrom: "context-pack"; + createdAt: string; +}; + +export type CheckSeverity = "info" | "warning" | "high" | "critical"; + +export type CheckStatus = "open" | "in_progress" | "resolved"; + +export type CheckPipelineStage = + | "ingest" + | "architecture_discovery" + | "module_inference" + | "feature_detection" + | "risk_analysis" + | "recommendation"; + +export type CheckCategory = + | "confirmed feature" + | "new feature candidate" + | "possible regression" + | "uncertain finding" + | "missing evidence" + | "missing verification" + | "architecture conflict"; + +export type CheckLinkedEntityDto = { + type: + | "check" + | "feature" + | "bug" + | "task" + | "relationship" + | "evidence" + | "context-pack" + | "architecture" + | "snapshot" + | "memory-rule" + | "knowledge"; + id: string; + label: string; + link: string; +}; + +export type CheckItemDto = { + id: string; + projectId: string; + title: string; + severity: CheckSeverity; + status: CheckStatus; + pipelineStage: CheckPipelineStage; + category: CheckCategory; + architectureModelId?: string; + linkedFeatureIds?: string[]; + linkedEvidenceIds?: string[]; + linkedRelationshipIds?: string[]; + linkedContextPackIds?: string[]; + detail: string; + linkedEntities?: CheckLinkedEntityDto[]; + trust: TrustMetadataDto; + reviewedAt?: string; + reviewAction?: ReviewAction; + reviewReason?: string; + mergedIntoEntityId?: string; + convertedTarget?: ConvertTarget; + convertedEntityId?: string; +}; + +export type CheckListFilters = { + status?: CheckStatus[]; + severity?: CheckSeverity[]; + source?: CheckPipelineStage[]; + confidence?: number; + approvalState?: ApprovalState; + category?: CheckCategory[]; + cursor?: string; +}; + +export type ReviewBoardDto = { + confirmedFeatures: CheckItemDto[]; + newFeatureCandidates: CheckItemDto[]; + possibleRegressions: CheckItemDto[]; + uncertainFindings: CheckItemDto[]; + missingEvidence: CheckItemDto[]; + missingVerification: CheckItemDto[]; + architectureConflicts: CheckItemDto[]; +}; + +export type ReviewAction = "accept" | "reject" | "ignore" | "merge"; + +export type ConvertTarget = "bug" | "task" | "memory_rule" | "decision"; + +export type ReviewMutationResponseDto = { + success: true; + message: string; + updatedEntityId: string; + createdEntityId?: string; + affectedEntities: string[]; +}; + +export type BuildAgent = "codex" | "claude" | "gpt" | "cursor" | "manual"; + +export type BuildValidationResult = "pass" | "fail" | "warning"; + +export type BuildStatus = "success" | "failed" | "running"; + +export type BuildRegressionDto = { + title: string; + severity: "low" | "medium" | "high"; + linkedFeatureIds: string[]; + preventionRule?: string; +}; + +export type BuildHistoryDto = { + id: string; + projectId: string; + buildNumber: string; + agent: string; + goal: string; + targetTool: string; + persona: string; + expectedOutput: string; + promptContextPackId?: string; + selectedContextPackIds?: string[]; + contextSelectionSummary?: string; + promptId?: string; + filesChanged: string[]; + validationResult: BuildValidationResult; + regressionsIntroduced: BuildRegressionDto[]; + lessonsLearned: string[]; + linkedFeatureIds: string[]; + source: string; + summary: string; + status: BuildStatus; + createdAt: string; + trust: TrustMetadataDto; +}; + +export type BuildListFilters = { + agent?: string[]; + result?: BuildValidationResult[]; + status?: BuildStatus[]; + featureId?: string; + contextPackId?: string; + cursor?: string; +}; + +export type CreateBuildRequestDto = { + goal: string; + agent?: BuildAgent; + contextPackId?: string; + promptId?: string; + filesChanged?: string[]; + validationResult?: string; + lessonsLearned?: string[]; + linkedFeatureIds?: string[]; + autoSelectContext?: boolean; + selectionIntent?: string; + selectedContextPackIds?: string[]; + contextSelectionSummary?: string; +}; + +export type RegressionSeverity = "low" | "medium" | "high" | "critical"; + +export type RegressionStatus = "active" | "watch" | "resolved"; + +export type RegressionMemoryDto = { + id: string; + projectId: string; + title: string; + description?: string; + affectedFeatureIds: string[]; + firstSeen: string; + timesOccurred: number; + typicalCause: string; + preventionRule: string; + linkedBuildIds: string[]; + severity: RegressionSeverity; + status: RegressionStatus; + includeInContextPacks?: string[]; + trust: TrustMetadataDto; + includeInContextPackStatus?: "in_pack" | "not_in_pack"; + convertedRuleId?: string; +}; + +export type RegressionListFilters = { + status?: RegressionStatus[]; + featureId?: string; + severity?: RegressionSeverity[]; + timesOccurredMin?: number; + timesOccurredMax?: number; + cursor?: string; +}; + +export type CreateRegressionRequestDto = { + title: string; + description?: string; + affectedFeatureIds?: string[]; + typicalCause?: string; + preventionRule?: string; + severity?: RegressionSeverity; +}; + +export type ConvertRegressionRuleSeverity = + | "important" + | "critical" + | "do_not_break"; + +export type RegressionRuleDto = { + id: string; + projectId: string; + regressionId: string; + title: string; + severity: ConvertRegressionRuleSeverity; + source: "regression-memory"; + createdAt: string; +}; + +export type SnapshotSourceRun = + | "manual" + | "import-analysis" + | "check-run" + | "build"; + +export type SnapshotDiffDto = { + section: string; + before: string; + after: string; + reason: string; +}; + +export type SnapshotDto = { + id: string; + timestamp: string; + sourceRun: SnapshotSourceRun; + projectId: string; + itemsAdded: string[]; + itemsRemoved: string[]; + diffs: SnapshotDiffDto[]; + keyRisks: string[]; + summary: string; +}; + +export type SnapshotListFilters = { + sourceRun?: SnapshotSourceRun[]; + from?: string; + to?: string; + containsRisk?: string; + cursor?: string; +}; + +export type CreateSnapshotRequestDto = { + sourceRun?: SnapshotSourceRun; + summary?: string; + keyRisks?: string[]; +}; + +export type SnapshotCompareDto = { + baseSnapshotId: string; + targetSnapshotId: string; + added: string[]; + removed: string[]; + sectionCount: number; + conflictCount: number; + summary: string; +}; + +export type InsightSeverity = "info" | "warning" | "high" | "critical"; + +export type InsightStatus = "open" | "accepted" | "rejected" | "ignored"; + +export type ProjectInsightDto = { + id: string; + projectId: string; + title: string; + category: string; + severity: InsightSeverity; + status: InsightStatus; + summary: string; + recommendation: string; + reviewed: boolean; + confidence: number; + linkedFeatureIds: string[]; + linkedEvidenceIds: string[]; + trust: TrustMetadataDto; + createdAt: string; + reviewedAt?: string; + reviewReason?: string; +}; + +export type InsightListFilters = { + category?: string[]; + severity?: InsightSeverity[]; + status?: InsightStatus[]; + confidence?: number; + reviewed?: boolean; + cursor?: string; +}; + +export type InsightReviewAction = "accept" | "reject" | "ignore"; + +export type RelationshipType = + | "depends_on" + | "conflicts_with" + | "duplicates" + | "supersedes" + | "belongs_to" + | "implements" + | "references"; + +export type RelationshipDto = { + id: string; + projectId: string; + from: string; + to: string; + type: RelationshipType; + rationale: string; + strength: number; + confidence: number; + source: string; + trust: TrustMetadataDto; + createdAt: string; + deletedAt?: string; +}; + +export type RelationshipListFilters = { + from?: string; + to?: string; + type?: RelationshipType[]; + confidence?: number; + strength?: number; + cursor?: string; +}; + +export type CreateRelationshipRequestDto = { + from: string; + to: string; + type: RelationshipType; + rationale: string; + strength?: number; + confidence?: number; +}; + +export type KnowledgeItemDto = { + id: string; + projectId: string; + title: string; + category: string; + content: string; + linkedFeatureIds: string[]; + source: string; + confidence: number; + includeInContextPacks: string[]; + trust: TrustMetadataDto; + createdAt: string; + updatedAt: string; +}; + +export type KnowledgeListFilters = { + category?: string[]; + featureId?: string; + source?: string[]; + confidence?: number; + search?: string; + cursor?: string; +}; + +export type KnowledgeMutationRequestDto = { + title?: string; + category?: string; + content?: string; + linkedFeatureIds?: string[]; + source?: string; + confidence?: number; +}; + +export type MemoryRuleScope = "global" | "module" | "feature" | "prompt_only"; + +export type MemoryRuleCategory = + | "UX" + | "Technical" + | "Architecture" + | "Do Not Break" + | "Naming" + | "Workflow" + | "Security"; + +export type MemoryRuleSeverity = + | "info" + | "important" + | "critical" + | "do_not_break"; + +export type MemoryRuleDto = { + id: string; + projectId: string; + title: string; + rule: string; + scope: MemoryRuleScope; + category: MemoryRuleCategory; + severity: MemoryRuleSeverity; + linkedFeatureIds: string[]; + includeInContextPacks: string[]; + includeInContextPacksDefault: boolean; + trust: TrustMetadataDto; + createdAt: string; + updatedAt: string; +}; + +export type MemoryRuleListFilters = { + scope?: MemoryRuleScope[]; + category?: MemoryRuleCategory[]; + severity?: MemoryRuleSeverity[]; + includeInContextPacks?: boolean; + featureId?: string; + search?: string; + cursor?: string; +}; + +export type MemoryRuleMutationRequestDto = { + title?: string; + rule?: string; + scope?: MemoryRuleScope; + category?: MemoryRuleCategory; + severity?: MemoryRuleSeverity; + linkedFeatureIds?: string[]; + includeInContextPacks?: boolean; +}; + +export type CaptureItemType = + | "note" + | "idea" + | "bug" + | "build_summary" + | "codex_output" + | "chatgpt_output" + | "error_log" + | "docs_snippet"; + +export type CaptureItemStatus = "captured" | "classified" | "in_review"; + +export type CaptureClassificationType = + | "feature" + | "bug" + | "task" + | "decision" + | "memory_rule" + | "assumption" + | "open_question" + | "risk" + | "knowledge" + | "documentation_update"; + +export type CaptureItemDto = { + id: string; + projectId: string; + type: CaptureItemType; + title: string; + content: string; + source: string; + status: CaptureItemStatus; + classificationType?: CaptureClassificationType; + confidence?: number; + evidenceStrength?: EvidenceStrength; + suggestedAction?: string; + createdAt: string; + updatedAt: string; + linkedReviewItemId?: string; +}; + +export type CaptureItemListFilters = { + status?: CaptureItemStatus[]; + type?: CaptureItemType[]; + source?: string[]; + confidence?: number; + classificationType?: CaptureClassificationType[]; + cursor?: string; +}; + +export type CreateCaptureItemRequestDto = { + type: CaptureItemType; + content: string; + source?: string; +}; + +export type CaptureClassificationDto = { + captureItemId: string; + classificationType: CaptureClassificationType; + confidence: number; + evidenceStrength: EvidenceStrength; + suggestedAction: string; +}; + +export type ReviewQueueItemType = + | "feature" + | "bug" + | "task" + | "memory_rule" + | "decision" + | "knowledge" + | "assumption" + | "open_question" + | "risk" + | "documentation_update" + | "architecture_conflict" + | "missing_evidence"; + +export type ReviewQueuePriority = "critical" | "important" | "normal" | "low"; + +export type ReviewQueueStatus = + | "pending" + | "accepted" + | "rejected" + | "merged" + | "converted"; + +export type VerificationState = + | "unverified" + | "needs evidence" + | "verified" + | "blocked"; + +export type ReviewQueueConvertTarget = + | "feature" + | "bug" + | "task" + | "memory_rule" + | "decision" + | "knowledge" + | "assumption" + | "open_question" + | "risk" + | "documentation_update"; + +export type ReviewQueueItemDto = { + id: string; + projectId: string; + type: ReviewQueueItemType; + title: string; + description: string; + priority: ReviewQueuePriority; + status: ReviewQueueStatus; + source: string; + confidence: number; + evidenceStrength: EvidenceStrength; + verificationState: VerificationState; + suggestedAction: string; + notes?: string; + targetEntityId?: string; + convertedTarget?: ReviewQueueConvertTarget; + createdEntityId?: string; + createdAt: string; + updatedAt: string; + blueprint?: { + graphId?: string; + nodeId?: string; + outputPath?: string; + contextPackId?: string; + selectedContextPackIds?: string[]; + }; +}; + +export type ReviewQueueListFilters = { + priority?: ReviewQueuePriority[]; + type?: ReviewQueueItemType[]; + status?: ReviewQueueStatus[]; + source?: string[]; + confidence?: number; + evidenceStrength?: EvidenceStrength[]; + verificationState?: VerificationState[]; + cursor?: string; +}; + +export type UpdateReviewQueueItemRequestDto = { + title?: string; + description?: string; + priority?: ReviewQueuePriority; + status?: ReviewQueueStatus; + notes?: string; +}; + +export type AIActionId = + | "generate_features" + | "detect_bugs" + | "classify_notes" + | "extract_decisions" + | "extract_memory_rules" + | "update_documentation" + | "generate_codex_task" + | "import_build_summary" + | "suggest_roadmap" + | "generate_current_state" + | "detect_missing_docs_verification_tests_context_risks"; + +export type AIActionInputSource = + | "constitution" + | "dna" + | "feature_map" + | "architecture" + | "capture_items" + | "context_pack" + | "checks" + | "manual"; + +export type AIActionOutputTarget = + | "review_queue" + | "context_pack" + | "documentation" + | "roadmap" + | "current_state" + | "codex_task"; + +export type AIActionCategory = + | "generation" + | "analysis" + | "classification" + | "extraction" + | "documentation" + | "planning"; + +export type AIActionDto = { + id: AIActionId; + projectId: string; + label: string; + category: AIActionCategory; + sourceType: AIActionInputSource[]; + outputTarget: AIActionOutputTarget[]; + description: string; + requiresContextPack: boolean; + status: "available"; +}; + +export type AIActionListFilters = { + category?: AIActionCategory[]; + outputTarget?: AIActionOutputTarget[]; + sourceType?: AIActionInputSource[]; + cursor?: string; +}; + +export type AIActionRunStatus = "completed" | "failed"; + +export type RunAIActionRequestDto = { + inputSource?: AIActionInputSource; + inputId?: string; + outputTarget?: AIActionOutputTarget; + objective?: string; +}; + +export type AIActionRunDto = { + id: string; + projectId: string; + actionId: AIActionId; + status: AIActionRunStatus; + inputSource: AIActionInputSource; + inputId?: string; + outputTarget: AIActionOutputTarget; + objective: string; + outputText: string; + createdFrom: string; + reviewQueuePreview?: ReviewQueueItemDto; + createdAt: string; +}; + +export type AIActionRunListFilters = { + actionId?: AIActionId[]; + status?: AIActionRunStatus[]; + outputTarget?: AIActionOutputTarget[]; + createdFrom?: string[]; + cursor?: string; +}; + +export type AcceptedTruthKind = + | "feature" + | "bug" + | "task" + | "decision" + | "memory_rule" + | "documentation" + | "knowledge" + | "risk" + | "assumption" + | "open_question"; + +export type AcceptedTruthStatus = "accepted" | "converted" | "merged"; + +export type AcceptedTruthItemDto = { + id: string; + projectId: string; + sourceReviewItemId: string; + kind: AcceptedTruthKind; + title: string; + summary: string; + status: AcceptedTruthStatus; + source: string; + confidence: number; + evidenceStrength: EvidenceStrength; + createdAt: string; + updatedAt: string; +}; + +export type AcceptedTruthListFilters = { + kind?: AcceptedTruthKind[]; + status?: AcceptedTruthStatus[]; + confidence?: number; + evidenceStrength?: EvidenceStrength[]; + sourceReviewItemId?: string; + cursor?: string; +}; + +export type ProjectUpdateTargetArea = + | "feature-map" + | "bugs" + | "tasks" + | "decisions" + | "memory" + | "documentation" + | "knowledge" + | "risks" + | "questions"; + +export type ProjectUpdateStatus = "draft" | "ready" | "applied"; + +export type ProjectUpdateDto = { + id: string; + projectId: string; + sourceTruthItemId: string; + kind: AcceptedTruthKind; + title: string; + summary: string; + targetArea: ProjectUpdateTargetArea; + status: ProjectUpdateStatus; + confidence: number; + evidenceStrength: EvidenceStrength; + createdAt: string; + updatedAt: string; + appliedAt?: string; +}; + +export type ProjectUpdateListFilters = { + status?: ProjectUpdateStatus[]; + targetArea?: ProjectUpdateTargetArea[]; + kind?: AcceptedTruthKind[]; + sourceTruthItemId?: string; + cursor?: string; +}; + +export type OperatingLogEntryType = + | "scope change" + | "decision" + | "import" + | "codex run" + | "build summary" + | "accepted AI suggestion" + | "major regression" + | "release" + | "accepted truth" + | "project update"; + +export type OperatingLogEntryDto = { + id: string; + projectId: string; + timestamp: string; + type: OperatingLogEntryType; + title: string; + summary: string; + linkedEntityIds: string[]; + source: string; + importance: "critical" | "important" | "normal" | "low"; + createdAt: string; + updatedAt: string; +}; + +export type OperatingLogListFilters = { + type?: OperatingLogEntryType[]; + source?: string[]; + importance?: Array<"critical" | "important" | "normal" | "low">; + linkedEntityId?: string; + cursor?: string; +}; + +export type ReleaseEvidenceKind = + | "validation" + | "tower_smoke" + | "browser_audit" + | "backup" + | "restore_dry_run" + | "deployment" + | "release_gate" + | "operator_note"; + +export type ReleaseEvidenceStatus = "captured" | "verified" | "rejected" | "superseded"; + +export type ReleaseEvidenceRecordDto = { + id: string; + projectId: string; + capturedAt: string; + kind: ReleaseEvidenceKind; + title: string; + summary: string; + status: ReleaseEvidenceStatus; + command?: string; + outputText?: string; + route?: string; + source: string; + confidence: number; + approvalState: ApprovalState; + evidenceStrength: EvidenceStrength; + verificationState: VerificationState; + createdBy: string; + verifiedAt?: string; + verifiedBy?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +}; + +export type ReleaseEvidenceListFilters = { + kind?: ReleaseEvidenceKind[]; + status?: ReleaseEvidenceStatus[]; + evidenceStrength?: EvidenceStrength[]; + verificationState?: VerificationState[]; + source?: string[]; + createdBy?: string[]; + cursor?: string; +}; + +export type CreateReleaseEvidenceRecordRequestDto = { + kind: ReleaseEvidenceKind; + title: string; + summary: string; + status?: ReleaseEvidenceStatus; + command?: string; + outputText?: string; + route?: string; + source?: string; + confidence?: number; + evidenceStrength?: EvidenceStrength; + verificationState?: VerificationState; + createdBy?: string; + verifiedBy?: string; + metadata?: Record; +}; + + +export type CurrentStateReportDto = { + id: string; + projectId: string; + generatedAt: string; + exists: string[]; + works: string[]; + uncertain: string[]; + broken: string[]; + currentRisks: string[]; + pendingReviewItemIds: string[]; + activeContextPackId?: string; + recommendedNextActions: string[]; + source: string; + approvalState: ApprovalState; + createdAt: string; + updatedAt: string; +}; + +export type CurrentStateReportListFilters = { + approvalState?: ApprovalState[]; + source?: string[]; + cursor?: string; +}; + +export type GenerateCurrentStateReportRequestDto = { + source?: string; + includeOperatingLog?: boolean; + includeProjectUpdates?: boolean; + includeReviewQueue?: boolean; +}; + +export type HandoffTarget = "context-pack" | "codex-task" | "current-state"; +export type HandoffBundleStatus = "draft" | "ready" | "used" | "archived"; + +export type HandoffBundleDto = { + id: string; + projectId: string; + target: HandoffTarget; + title: string; + markdown: string; + qualityScore: number; + primaryContextPackId?: string; + selectedContextPackIds?: string[]; + sourceTruthItemIds: string[]; + sourceReviewItemIds: string[]; + sourceCaptureItemIds: string[]; + sourceProjectUpdateIds: string[]; + sourceOperatingLogEntryIds: string[]; + status: HandoffBundleStatus; + approvalState: ApprovalState; + createdAt: string; + updatedAt: string; +}; + +export type CreateHandoffBundleRequestDto = { + target: HandoffTarget; + title?: string; + includeAcceptedTruth?: boolean; + includeProjectUpdates?: boolean; + includeOperatingLog?: boolean; + autoSelectContext?: boolean; + selectionIntent?: string; + contextPackId?: string; +}; + +export type HandoffBundleListFilters = { + target?: HandoffTarget[]; + status?: HandoffBundleStatus[]; + approvalState?: ApprovalState[]; + cursor?: string; +}; + +export type CodexTaskStatus = "draft" | "ready" | "exported" | "imported" | "reviewed"; + +export type CodexTaskDto = { + id: string; + projectId: string; + handoffBundleId?: string; + goal: string; + contextPackId?: string; + selectedContextPackIds?: string[]; + contextSelectionSummary?: string; + constraints: string[]; + relevantFiles: string[]; + doNotBreakRules: string[]; + validationCommands: string[]; + expectedReportFormat: string; + outputImportInstructions: string; + approvalState: ApprovalState; + status: CodexTaskStatus; + createdAt: string; + updatedAt: string; +}; + +export type CodexTaskListFilters = { + status?: CodexTaskStatus[]; + approvalState?: ApprovalState[]; + handoffBundleId?: string; + cursor?: string; +}; + +export type CreateCodexTaskFromHandoffRequestDto = { + goal?: string; + contextPackId?: string; + validationCommands?: string[]; + autoSelectContext?: boolean; + selectionIntent?: string; +}; + +export type ImportCodexOutputRequestDto = { + summary: string; + outputText?: string; + sourceRun?: string; +}; + +export type CodexRunImportDto = { + id: string; + projectId: string; + codexTaskId?: string; + sourceRun: string; + summary: string; + contextPackId?: string; + selectedContextPackIds?: string[]; + contextSelectionSummary?: string; + extractedReviewItemIds: string[]; + status: "imported" | "reviewed" | "partially accepted" | "rejected"; + createdAt: string; + updatedAt: string; +}; + +export type P7MutationResponseDto = { + success: boolean; + message: string; + createdEntityId?: string; + updatedEntityId?: string; + affectedEntities?: string[]; +}; + +export type CreateOperatingLogEntryRequestDto = { + type: OperatingLogEntryType; + title: string; + summary: string; + linkedEntityIds?: string[]; + source?: string; + importance?: "critical" | "important" | "normal" | "low"; +}; diff --git a/backend/src/utils/envelope.ts b/backend/src/utils/envelope.ts new file mode 100644 index 0000000..5a233e3 --- /dev/null +++ b/backend/src/utils/envelope.ts @@ -0,0 +1,32 @@ +import type { ApiErrorResponse, ApiResponse, ValidationIssue } from "@projectbrain/contracts"; +import type { RequestId } from "../types/api.js"; + +const currentTimestamp = () => new Date().toISOString(); + +export const successEnvelope = ( + data: T, + requestId: RequestId, +): ApiResponse => ({ + data, + meta: { + requestId, + timestamp: currentTimestamp(), + }, +}); + +export const errorEnvelope = ( + code: string, + message: string, + requestId: RequestId, + validation?: ValidationIssue[], +): ApiErrorResponse => ({ + error: { + code, + message, + ...(validation?.length ? { validation } : {}), + }, + meta: { + requestId, + timestamp: currentTimestamp(), + }, +}); diff --git a/backend/src/utils/idempotency.ts b/backend/src/utils/idempotency.ts new file mode 100644 index 0000000..0d9c317 --- /dev/null +++ b/backend/src/utils/idempotency.ts @@ -0,0 +1,72 @@ +type IdempotencyRecord = { + statusCode: number; + body: T; +}; + +const idempotencyStore = new Map>(); + +export const getIdempotencyKey = ( + method: string, + path: string, + idempotencyKey: string, +): string => `${method.toUpperCase()} ${path.split("?")[0]} ${idempotencyKey}`; + +export const getIdempotentResponse = ( + key: string, +): IdempotencyRecord | undefined => + idempotencyStore.get(key) as IdempotencyRecord | undefined; + +export const setIdempotentResponse = ( + key: string, + response: IdempotencyRecord, +): IdempotencyRecord => { + idempotencyStore.set(key, response); + return response; +}; + +const durableCoordinates = (cacheKey: string) => ({ + method: "MUTATION", + path: cacheKey, + key: cacheKey, +}); + +export const getDurableIdempotentResponse = async ( + cacheKey: string, + useDatabase: boolean, +): Promise | undefined> => { + if (!useDatabase) return getIdempotentResponse(cacheKey); + const coordinates = durableCoordinates(cacheKey); + const record = await prisma.idempotencyRecord.findUnique({ + where: { method_path_key: coordinates }, + }); + if (!record || record.expiresAt <= new Date()) return undefined; + return { statusCode: record.statusCode, body: record.responseJson as T }; +}; + +export const setDurableIdempotentResponse = async ( + cacheKey: string, + response: IdempotencyRecord, + useDatabase: boolean, +): Promise> => { + if (!useDatabase) return setIdempotentResponse(cacheKey, response); + const coordinates = durableCoordinates(cacheKey); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + await prisma.idempotencyRecord.upsert({ + where: { method_path_key: coordinates }, + create: { + ...coordinates, + requestHash: createHash("sha256").update(cacheKey).digest("hex"), + statusCode: response.statusCode, + responseJson: JSON.parse(JSON.stringify(response.body)), + expiresAt, + }, + update: { + statusCode: response.statusCode, + responseJson: JSON.parse(JSON.stringify(response.body)), + expiresAt, + }, + }); + return response; +}; +import { createHash } from "node:crypto"; +import { prisma } from "../db/prisma.js"; diff --git a/backend/src/workers/analysisWorker.ts b/backend/src/workers/analysisWorker.ts new file mode 100644 index 0000000..73d936b --- /dev/null +++ b/backend/src/workers/analysisWorker.ts @@ -0,0 +1,167 @@ +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import { loadEnv } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { claimNextAnalysisJob, deleteExpiredAnalysisJobs, executeAnalysisJob, recoverStaleAnalysisJobs } from "../services/repositoryAnalysis.js"; +import { createPortfolioAnalysisJob } from "../repositories/analysisJobRepository.js"; +import { reconcilePortfolioRepositories } from "../services/portfolioControlPlane.js"; +import { retryPendingProjectRepositoryProvisioning } from "../services/projectRepositoryProvisioning.js"; + +const env = loadEnv(); +const pollMs = env.PROJECTBRAIN_ANALYSIS_POLL_MS; +let stopping = false; +let activeJobId: string | undefined; +let lastError: string | undefined; +const instanceId = randomUUID(); +const startedAt = new Date(); +let nextScheduleCheckAt = 0; +let nextOnboardingCheckAt = 0; + +const heartbeat = async () => prisma.analysisWorkerHeartbeat.upsert({ + where: { id: "primary" }, + create: { id: "primary", instanceId, status: lastError ? "degraded" : "ready", activeJobId, detail: lastError, startedAt, lastSeenAt: new Date() }, + update: { instanceId, status: lastError ? "degraded" : "ready", activeJobId, detail: lastError, startedAt, lastSeenAt: new Date() }, +}); + +const healthServer = createServer((request, response) => { + if (request.url !== "/health" && request.url !== "/healthz" && request.url !== "/ready") { + response.writeHead(404).end(); + return; + } + response.writeHead(lastError ? 503 : 200, { "content-type": "application/json" }); + response.end(JSON.stringify({ status: lastError ? "degraded" : "ready", component: "analysis-worker", activeJobId, lastError })); +}); + +const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const schedulePortfolioAnalysisWhenDue = async () => { + const repositorySyncConfigured = Boolean(env.PROJECTBRAIN_GITEA_URL && env.PROJECTBRAIN_GITEA_TOKEN); + if (env.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES && repositorySyncConfigured && Date.now() >= nextOnboardingCheckAt) { + nextOnboardingCheckAt = Date.now() + env.PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS * 1_000; + let reconciliationError: unknown; + try { + const reconciliation = await reconcilePortfolioRepositories(env, "system:portfolio-onboarding", { dryRun: false }); + if (reconciliation.onboardedCount || reconciliation.failedCount) { + console.info(JSON.stringify({ event: "portfolio_reconciliation_completed", ...reconciliation })); + } + } catch (error) { + reconciliationError = error; + } + const provisioning = await retryPendingProjectRepositoryProvisioning(env); + if (provisioning.length) console.info(JSON.stringify({ event: "project_repository_provisioning_retried", results: provisioning })); + if (reconciliationError) throw reconciliationError; + } + if (!env.PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES || Date.now() < nextScheduleCheckAt) return; + nextScheduleCheckAt = Date.now() + 60_000; + const active = await prisma.analysisJob.findFirst({ where: { status: { in: ["queued", "running"] } }, select: { id: true } }); + if (active) return; + + const policies = await prisma.findingAutomationPolicy.findMany({ + where: { enabled: true, scheduledAnalysis: true, project: { deletedAt: null, archivedAt: null } }, + select: { projectId: true, scheduleMinutes: true }, + }); + const projectIds = policies.map((policy) => policy.projectId); + const scheduledJobs = projectIds.length ? await prisma.analysisJob.findMany({ + where: { actor: { startsWith: "system:scheduler:" }, items: { some: { projectId: { in: projectIds } } } }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, items: { where: { projectId: { in: projectIds } }, select: { projectId: true } } }, + }) : []; + const latestByProject = new Map(); + for (const job of scheduledJobs) { + for (const item of job.items) { + if (item.projectId && !latestByProject.has(item.projectId)) latestByProject.set(item.projectId, job.createdAt); + } + } + const now = Date.now(); + const dueProjectIds = policies + .filter((policy) => { + const latest = latestByProject.get(policy.projectId); + return !latest || now - latest.getTime() >= policy.scheduleMinutes * 60_000; + }) + .map((policy) => policy.projectId); + if (dueProjectIds.length) { + await createPortfolioAnalysisJob(env, "system:scheduler:portfolio", { projectIds: dueProjectIds, includeBranches: true, includePullRequests: true, includeActions: true }); + return; + } + + if (env.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES) return; + const latestDiscovery = await prisma.analysisJob.findFirst({ where: { actor: "system:scheduler:discovery" }, orderBy: { createdAt: "desc" }, select: { createdAt: true } }); + const discoveryIntervalMs = env.PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES * 60_000; + if (!latestDiscovery || Date.now() - latestDiscovery.createdAt.getTime() >= discoveryIntervalMs) { + await createPortfolioAnalysisJob(env, "system:scheduler:discovery", { includeBranches: true, includePullRequests: true, includeActions: true }); + } +}; + +const loop = async () => { + await recoverStaleAnalysisJobs(new Date(Date.now() - env.PROJECTBRAIN_ANALYSIS_STALE_SECONDS * 1000)); + let cleanupCounter = 0; + while (!stopping) { + try { + lastError = undefined; + await heartbeat(); + await schedulePortfolioAnalysisWhenDue(); + if (++cleanupCounter >= 300) { + await deleteExpiredAnalysisJobs(); + cleanupCounter = 0; + } + const jobId = await claimNextAnalysisJob(); + if (!jobId) { + await sleep(pollMs); + continue; + } + activeJobId = jobId; + lastError = undefined; + try { + await executeAnalysisJob(env, jobId, { + repositoryConcurrency: env.PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY, + batchSize: env.PROJECTBRAIN_ANALYSIS_BATCH_SIZE, + timeoutSeconds: env.PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS, + }); + } catch (error) { + const code = error instanceof Error ? error.message : "ANALYSIS_JOB_FAILED"; + const completedAt = new Date(); + await prisma.$transaction(async (transaction) => { + await transaction.analysisJobItem.updateMany({ + where: { jobId, status: { in: ["pending", "running"] } }, + data: { status: "failed", errorCode: code, completedAt }, + }); + const progressCurrent = await transaction.analysisJobItem.count({ + where: { jobId, status: { in: ["succeeded", "failed", "cancelled"] } }, + }); + await transaction.analysisJob.update({ + where: { id: jobId }, + data: { + status: "failed", + progressCurrent, + errorCode: code, + summaryText: `Analysejob gestopt: ${code}.`, + completedAt, + heartbeatAt: completedAt, + activeDedupeKey: null, + }, + }); + }).catch(() => undefined); + } finally { + activeJobId = undefined; + await heartbeat().catch(() => undefined); + } + } catch (error) { + lastError = error instanceof Error ? error.message : "ANALYSIS_WORKER_FAILED"; + await sleep(Math.max(pollMs, 5_000)); + } + } +}; + +const shutdown = async () => { + stopping = true; + healthServer.close(); + await prisma.$disconnect(); + process.exit(0); +}; +process.on("SIGINT", () => void shutdown()); +process.on("SIGTERM", () => void shutdown()); + +healthServer.listen(env.PROJECTBRAIN_ANALYSIS_WORKER_PORT, env.HOST, () => { + console.info(JSON.stringify({ event: "analysis_worker_started", port: env.PROJECTBRAIN_ANALYSIS_WORKER_PORT })); +}); +await loop(); diff --git a/backend/test/activity-runtime.test.ts b/backend/test/activity-runtime.test.ts new file mode 100644 index 0000000..5f01199 --- /dev/null +++ b/backend/test/activity-runtime.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import type { GiteaActionJob } from "../src/integrations/gitea/types.js"; +import { compactGiteaRuntime, createGiteaActivityRuntimeLoader, repositoryFromActionJob } from "../src/services/activityRuntime.js"; + +const env = loadEnv({ + NODE_ENV: "test", + DATABASE_URL: "postgresql://example.invalid/projectbrain", + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", + PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters", +}); + +const job = (input: Partial = {}): GiteaActionJob => ({ + id: 42, + html_url: "https://gitea.example.com/Jens/projectbrain/actions/runs/21/jobs/42", + run_id: 21, + name: "quality", + labels: ["ubuntu-latest"], + run_attempt: 1, + head_sha: "abc123", + head_branch: "main", + status: "in_progress", + runner_id: 9, + runner_name: "itworx-security-runner", + steps: [ + { name: "Checkout", number: 1, status: "completed", conclusion: "success" }, + { name: "Test", number: 2, status: "in_progress" }, + ], + created_at: "2026-08-28T19:00:00Z", + started_at: "2026-08-28T19:01:00Z", + completed_at: "1970-01-01T00:00:00Z", + ...input, +}); + +describe("compact activity runtime", () => { + it("keeps older running jobs visible ahead of a full page of newer queued jobs", () => { + const jobs = [job({ id: 1 }), job({ id: 2 }), ...Array.from({ length: 30 }, (_, i) => job({ id: i + 100, status: "queued" }))]; + const snapshot = compactGiteaRuntime([], jobs, "2026-09-10T00:00:00Z"); + expect(snapshot.jobs.slice(0, 12).slice(0, 2).map((item) => item.id)).toEqual([2, 1]); + expect(snapshot.counts).toEqual({ running: 2, runnable: 30, dependencyBlocked: 0 }); + expect(snapshot.jobs).toHaveLength(32); + }); + it("extracts a repository safely from a Gitea job URL", () => { + expect(repositoryFromActionJob(job({ html_url: "https://gitea.example.com/Space%20Owner/Repo/actions/runs/1/jobs/2" }))).toBe("Space Owner/Repo"); + expect(repositoryFromActionJob(job({ html_url: "not-a-url" }))).toBeUndefined(); + }); + + it("returns only compact runner and current-step data", () => { + const snapshot = compactGiteaRuntime([ + { id: 9, name: "runner", status: "online", busy: true, disabled: false, ephemeral: false, labels: [{ id: 1, name: "ubuntu-latest", type: "custom" }] }, + ], [job()], "2026-08-28T19:02:00Z"); + + expect(snapshot.runners[0]).toEqual({ id: 9, name: "runner", status: "online", busy: true, disabled: false, labels: ["ubuntu-latest"] }); + expect(snapshot.jobs[0]).toMatchObject({ repository: "Jens/projectbrain", currentStep: "Test", queueState: "running", completedSteps: 1, totalSteps: 2 }); + expect(snapshot.jobs[0].completedAt).toBeUndefined(); + expect(JSON.stringify(snapshot)).not.toContain("head_sha"); + expect(compactGiteaRuntime([], [job()], "2026-08-28T19:03:00Z").stateVersion) + .toBe(compactGiteaRuntime([], [job()], "2026-08-28T19:04:00Z").stateVersion); + }); + + it("derives a busy runner from an active job when user-level runner discovery omits it", () => { + const snapshot = compactGiteaRuntime([], [job()], "2026-08-28T19:02:00Z"); + expect(snapshot.runners).toEqual([{ id: 9, name: "itworx-security-runner", status: "observed", busy: true, disabled: false, labels: ["ubuntu-latest"] }]); + }); + + it("does not present dependency-blocked jobs as runnable work", () => { + const snapshot = compactGiteaRuntime([], [job({ status: "waiting", runner_id: 0, runner_name: undefined })], "2026-08-28T19:02:00Z"); + expect(snapshot.jobs[0].queueState).toBe("dependency-blocked"); + expect(snapshot.counts).toEqual({ running: 0, runnable: 0, dependencyBlocked: 1 }); + }); + + it("keeps every active job visible and bounds only completed history", () => { + const completed = Array.from({ length: 35 }, (_, index) => job({ id: 100 + index, status: "completed", conclusion: "success" })); + const active = Array.from({ length: 38 }, (_, index) => job({ id: 10 + index, status: index < 3 ? "in_progress" : "queued", runner_id: index < 3 ? 9 : 0, runner_name: index < 3 ? "runner" : undefined })); + const snapshot = compactGiteaRuntime([], [...completed, ...active], "2026-08-28T19:02:00Z"); + expect(snapshot.jobs).toHaveLength(48); + expect(snapshot.jobs.filter((item) => item.queueState !== "completed")).toHaveLength(38); + expect(snapshot.jobs.filter((item) => item.queueState === "completed")).toHaveLength(10); + }); + + it("coalesces simultaneous refreshes and serves a stale snapshot after a transient failure", async () => { + let clock = new Date("2026-08-28T19:00:00Z"); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 1, runners: [ + { id: 9, name: "runner", status: "online", busy: true, disabled: false, ephemeral: false, labels: [] }, + ] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => input.status === "in_progress" + ? { total_count: 1, jobs: [job()] } + : input.status === undefined ? { total_count: 1, jobs: [job()] } : { total_count: 0, jobs: [] }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs }, now: () => clock }); + + const [first, simultaneous] = await Promise.all([load(), load()]); + expect(first.stateVersion).toBe(simultaneous.stateVersion); + expect(listUserActionRunners).toHaveBeenCalledTimes(1); + expect(listUserActionJobs).toHaveBeenCalledTimes(4); + expect(first.counts).toEqual({ running: 1, runnable: 0, dependencyBlocked: 0 }); + + clock = new Date("2026-08-28T19:00:16Z"); + listUserActionRunners.mockRejectedValue(new Error("temporary upstream failure")); + listUserActionJobs.mockRejectedValue(new Error("temporary upstream failure")); + const stale = await load(); + expect(stale).toMatchObject({ availability: "stale", warning: "temporary upstream failure" }); + expect(stale.jobs).toHaveLength(1); + }); + + it("keeps a live snapshot and derives counts when one filtered query fails", async () => { + const waitingJob = job({ id: 43, status: "waiting", runner_id: 0, runner_name: undefined }); + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] }); + const listUserActionJobs = vi.fn().mockImplementation(async (input: { status?: string } = {}) => { + if (input.status === "pending") throw new Error("unsupported status filter"); + if (input.status === undefined) return { total_count: 1, jobs: [waitingJob] }; + return { total_count: 0, jobs: [] }; + }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } }); + + const snapshot = await load(); + expect(snapshot.availability).toBe("live"); + expect(snapshot.warning).toContain("Partial Gitea monitoring failure"); + expect(snapshot.counts).toEqual({ running: 0, runnable: 0, dependencyBlocked: 1 }); + expect(snapshot.jobs[0].queueState).toBe("dependency-blocked"); + }); + + it("uses Gitea's documented pending filter for dependency-blocked jobs", async () => { + const listUserActionRunners = vi.fn().mockResolvedValue({ total_count: 0, runners: [] }); + const listUserActionJobs = vi.fn().mockResolvedValue({ total_count: 0, jobs: [] }); + const load = createGiteaActivityRuntimeLoader(env, { client: { listUserActionRunners, listUserActionJobs } }); + + await load(); + + expect(listUserActionJobs).toHaveBeenCalledWith({ status: "pending", limit: 50 }); + expect(listUserActionJobs).not.toHaveBeenCalledWith({ status: "waiting", limit: 50 }); + }); +}); diff --git a/backend/test/analysis-job-capacity.test.ts b/backend/test/analysis-job-capacity.test.ts new file mode 100644 index 0000000..d30ce88 --- /dev/null +++ b/backend/test/analysis-job-capacity.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import { createPortfolioAnalysisJob } from "../src/repositories/analysisJobRepository.js"; +import { refreshPortfolioCatalog } from "../src/services/portfolioControlPlane.js"; +import { registerAnalysisJobRoutes } from "../src/routes/analysisJobs.js"; +import Fastify from "fastify"; + +const mocks = vi.hoisted(() => ({ + inventory: vi.fn(), links: vi.fn(), findJob: vi.fn(), findFirstJob: vi.fn(), createJob: vi.fn(), + catalog: vi.fn(), upsert: vi.fn(), unavailable: vi.fn(), +})); +vi.mock("../src/integrations/gitea/service.js", () => ({ + getGiteaClient: () => ({ listRepositories: mocks.inventory }), + listLinkedRepositories: mocks.links, +})); +vi.mock("../src/db/prisma.js", () => ({ prisma: { + analysisJob: { findUnique: mocks.findJob, findFirst: mocks.findFirstJob, create: mocks.createJob }, + portfolioRepository: { findMany: mocks.catalog, upsert: mocks.upsert, updateMany: mocks.unavailable }, +} })); + +const env = loadEnv({ NODE_ENV: "test", PROJECTBRAIN_DATA_SOURCE: "database", + DATABASE_URL: "postgresql://fixture:fixture@localhost:5432/fixture", + PROJECTBRAIN_OWNER_USERNAME: "fixture-owner", PROJECTBRAIN_OWNER_PASSWORD: "fixture-only-password", + PROJECTBRAIN_AUTH_SECRET: "fixture-only-auth-secret-at-least-32-characters" }); +const repositories = (count: number) => Array.from({ length: count }, (_, index) => ({ + id: index + 1, name: `repo-${index}`, full_name: `Owner/repo-${index}`, + owner: { login: "Owner" }, default_branch: "main", permissions: { pull: true }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.links.mockResolvedValue([]); + mocks.catalog.mockResolvedValue([]); + mocks.findJob.mockResolvedValue(null); + mocks.findFirstJob.mockResolvedValue(null); + mocks.createJob.mockImplementation(async ({ data }) => ({ + id: "fixture-job", status: "queued", progressTotal: data.progressTotal, progressCurrent: 0, + })); +}); + +describe("bounded complete portfolio analysis", () => { + it.each([58, 200, 201])("enforces the same %i-selection boundary at the project HTTP route", async (count) => { + mocks.links.mockResolvedValue(repositories(count).map((repository) => ({ + id: String(repository.id), projectId: "fixture-project", projectSlug: "fixture-project", + projectName: "Fixture", owner: "Owner", repository: repository.name, + fullName: repository.full_name, defaultBranch: "main", accessMode: "read_only", + }))); + const app = Fastify(); + app.decorateRequest("authUser", null); + app.addHook("onRequest", async (request) => { + request.authUser = { id: "fixture-owner", username: "fixture-owner", role: "owner", credentialVersion: 1 }; + }); + await registerAnalysisJobRoutes(app, { env }); + try { + const response = await app.inject({ method: "POST", url: "/api/projects/fixture-project/analysis-jobs", + payload: { repositoryIds: Array.from({ length: count }, (_, index) => String(index + 1)) } }); + if (count <= 200) { + expect(response.statusCode).toBe(202); + expect(response.json().data.job.progressTotal).toBe(count); + expect(mocks.createJob.mock.calls[0][0].data.items.create).toHaveLength(count); + } else { + expect(response.statusCode).toBeGreaterThanOrEqual(400); + expect(mocks.createJob).not.toHaveBeenCalled(); + } + } finally { + await app.close(); + } + }); + it.each([51, 58, 200])("persists all %i targets without truncation", async (count) => { + mocks.inventory.mockResolvedValue(repositories(count)); + const result = await createPortfolioAnalysisJob(env, "fixture-actor", {}); + expect(result.job.progressTotal).toBe(count); + const data = mocks.createJob.mock.calls[0][0].data; + expect(data.items.create).toHaveLength(count); + expect(data.requestJson.repositoryIds).toHaveLength(count); + expect(new Set(data.items.create.map((item: { sequence: number }) => item.sequence)).size).toBe(count); + }); + + it("rejects 201 targets before job persistence", async () => { + mocks.inventory.mockResolvedValue(repositories(201)); + await expect(createPortfolioAnalysisJob(env, "fixture-actor", {})).rejects.toThrow("ANALYSIS_REPOSITORY_LIMIT_EXCEEDED"); + expect(mocks.createJob).not.toHaveBeenCalled(); + }); + + it("does not persist a job or mark cached repositories unavailable after incomplete inventory", async () => { + mocks.inventory.mockRejectedValue(new Error("GITEA_INVENTORY_LIMIT_EXCEEDED")); + await expect(createPortfolioAnalysisJob(env, "fixture-actor", {})).rejects.toThrow("GITEA_INVENTORY_LIMIT_EXCEEDED"); + await expect(refreshPortfolioCatalog(env)).rejects.toThrow("GITEA_INVENTORY_LIMIT_EXCEEDED"); + expect(mocks.createJob).not.toHaveBeenCalled(); + expect(mocks.upsert).not.toHaveBeenCalled(); + expect(mocks.unavailable).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/test/analysisStrategy.test.ts b/backend/test/analysisStrategy.test.ts new file mode 100644 index 0000000..e6aab35 --- /dev/null +++ b/backend/test/analysisStrategy.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { chooseAnalysisStrategy, findingFingerprint, groupFindingRootCauses } from "../src/services/analysisStrategy.js"; + +describe("analysis strategy", () => { + it("prioritizes changed-area analysis inside the budget", () => { + expect(chooseAnalysisStrategy({ changedPaths: ["src/a.ts", "src/b.ts"], now: 100, lastFullScanAt: new Date(0).toISOString() })).toMatchObject({ strategy: "diff", estimatedScope: "changed-area" }); + }); + + it("uses targeted analysis while a full scan is fresh", () => { + expect(chooseAnalysisStrategy({ now: 100_000, lastFullScanAt: new Date(99_000).toISOString(), activeFindingCount: 2, staleAfterMs: 10_000 })).toMatchObject({ strategy: "targeted" }); + expect(chooseAnalysisStrategy({ now: 100_000, lastFullScanAt: new Date(0).toISOString(), staleAfterMs: 10_000 }).strategy).toBe("full"); + }); + + it("normalizes fingerprints and groups repeated root causes", () => { + expect(findingFingerprint({ ruleCode: "CI_FAILED", path: "src\\a123.ts", subject: "run 42" })).toBe("ci_failed|src/a#.ts|run #"); + expect(groupFindingRootCauses([{ fingerprint: "a", ruleCode: "CI", subject: "workflow", rootCause: "runner" }, { fingerprint: "b", ruleCode: "CI", subject: "workflow", rootCause: "runner" }])).toEqual([{ rootCause: "runner", fingerprints: ["a", "b"] }]); + }); +}); diff --git a/backend/test/authentication.test.ts b/backend/test/authentication.test.ts new file mode 100644 index 0000000..0ca0e38 --- /dev/null +++ b/backend/test/authentication.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadEnv } from "../src/config/env.js"; +import { canAccessProject } from "../src/security/authStore.js"; +import { parseAutonomyClaimInput } from "../src/routes/autonomy.js"; +import { isOwnerRequest, parseWorkGraphLeaseInput } from "../src/routes/operatingSystem.js"; +import { hashPassword, verifyPassword } from "../src/security/password.js"; +import { apiPaths, authSessionDataSchema, successEnvelopeSchema } from "@projectbrain/contracts"; +import { PassThrough } from "node:stream"; + +const ownerPassword = "correct-horse-battery-staple"; +const env = () => loadEnv({ + NODE_ENV: "test", + LOG_LEVEL: "silent", + PROJECTBRAIN_DATA_SOURCE: "fixture", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_SESSION_TTL_HOURS: "1", + PROJECTBRAIN_TRUST_PROXY: "false", +}); + +const apps: Awaited>[] = []; +const makeApp = async () => { + const app = await buildApp({ env: env() }); + apps.push(app); + return app; +}; + +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); +}); + +const login = async (app: Awaited>) => { + const response = await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "owner", password: ownerPassword } }); + const cookie = response.cookies.find((item) => item.name === "projectbrain_session"); + return { response, cookie: cookie ? `${cookie.name}=${cookie.value}` : "", csrfToken: response.json().data?.csrfToken as string }; +}; + +describe("authentication boundary", () => { + it("rejects known insecure production bootstrap credentials", () => { + expect(() => loadEnv({ NODE_ENV: "production", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "fixture", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "adminadminadmin", PROJECTBRAIN_AUTH_SECRET: "production-secret-with-at-least-thirty-two-characters" })).toThrow(/insecure default/); + }); + + it("requires a complete, database-backed Entra configuration", () => { + const base = { NODE_ENV: "test", PROJECTBRAIN_DATA_SOURCE: "fixture", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters" }; + expect(() => loadEnv({ ...base, PROJECTBRAIN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111" })).toThrow(/must be configured together/); + expect(() => loadEnv({ ...base, PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", PROJECTBRAIN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111", PROJECTBRAIN_ENTRA_CLIENT_ID: "22222222-2222-4222-8222-222222222222", PROJECTBRAIN_ENTRA_CLIENT_SECRET: "a-test-secret-value" })).toThrow(/database/); + const blank = loadEnv({ ...base, PROJECTBRAIN_ENTRA_TENANT_ID: "", PROJECTBRAIN_ENTRA_CLIENT_ID: "", PROJECTBRAIN_ENTRA_CLIENT_SECRET: "" }); + expect(blank.PROJECTBRAIN_ENTRA_TENANT_ID).toBeUndefined(); + }); + + it("marks the production session cookie Secure", async () => { + const productionEnv = loadEnv({ NODE_ENV: "production", DATABASE_URL: "postgresql://example.invalid/projectbrain", LOG_LEVEL: "silent", PROJECTBRAIN_DATA_SOURCE: "fixture", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, PROJECTBRAIN_AUTH_SECRET: "production-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_SESSION_TTL_HOURS: "1", PROJECTBRAIN_TRUST_PROXY: "false" }); + const app = await buildApp({ env: productionEnv }); + apps.push(app); + const response = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: ownerPassword } }); + expect(response.headers["set-cookie"]).toContain("Secure"); + }, 10_000); + + it("hashes passwords with a random salt and rejects malformed hashes", async () => { + const first = await hashPassword(ownerPassword); + const second = await hashPassword(ownerPassword); + expect(first).not.toBe(second); + expect(await verifyPassword(ownerPassword, first)).toBe(true); + expect(await verifyPassword("incorrect-password-value", first)).toBe(false); + expect(await verifyPassword(ownerPassword, "not-a-supported-hash")).toBe(false); + }); + + it("denies fixture project access to a non-owner identity", async () => { + expect(await canAccessProject(env(), { id: "member-1", username: "member", role: "member", credentialVersion: 1 }, "widefrog", false)).toBe(false); + }); + it("keeps portfolio-wide operating-system access owner-only", () => { + expect(isOwnerRequest({ authUser: { id: "owner-1", username: "owner", role: "owner", credentialVersion: 1 } } as never)).toBe(true); + expect(isOwnerRequest({ authUser: { id: "member-1", username: "member", role: "member", credentialVersion: 1 } } as never)).toBe(false); + }); + it("keeps only deliberate health, readiness and login bootstrap routes public", async () => { + const app = await makeApp(); + expect((await app.inject({ method: "GET", url: "/api/health" })).statusCode).toBe(200); + expect((await app.inject({ method: "GET", url: "/api/system/readiness" })).statusCode).toBe(200); + const metrics = await app.inject({ method: "GET", url: "/api/metrics" }); + expect(metrics.statusCode).toBe(200); + expect(metrics.body).toContain("projectbrain_http_requests_total"); + expect(metrics.body).toContain("projectbrain_database_ready 1"); + const entraConfig = await app.inject({ method: "GET", url: apiPaths.auth.entraConfig }); + expect(entraConfig.statusCode).toBe(200); + expect(entraConfig.json().data).toEqual({ enabled: false, provider: "microsoft-entra" }); + expect((await app.inject({ method: "GET", url: apiPaths.auth.entraStart })).statusCode).toBe(503); + expect((await app.inject({ method: "POST", url: apiPaths.auth.entraLinkStart })).statusCode).toBe(401); + const protectedResponse = await app.inject({ method: "GET", url: "/api/projects" }); + expect(protectedResponse.statusCode).toBe(401); + expect(protectedResponse.json().error.code).toBe("AUTHENTICATION_REQUIRED"); + }); + + it("rejects unsafe incoming correlation IDs and emits a safe replacement", async () => { + const app = await makeApp(); + const response = await app.inject({ method: "GET", url: "/api/health", headers: { "x-request-id": "unsafe\nrequest" } }); + expect(response.headers["x-request-id"]).toMatch(/^req-[0-9a-f-]+$/); + }); + + it("redacts credentials and project content from standard logs", async () => { + const chunks: string[] = []; + const logStream = new PassThrough(); + logStream.on("data", (chunk) => chunks.push(chunk.toString())); + const loggedEnv = { ...env(), LOG_LEVEL: "info" as const }; + const app = await buildApp({ env: loggedEnv, logStream }); + apps.push(app); + await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "owner", password: "secret-project-content" } }); + await new Promise((resolve) => setImmediate(resolve)); + const output = chunks.join(""); + expect(output).not.toContain("secret-project-content"); + expect(output).not.toContain(ownerPassword); + }); + + it("creates a secure server-side session and exposes no password", async () => { + const app = await makeApp(); + const { response, cookie, csrfToken } = await login(app); + expect(response.statusCode).toBe(200); + expect(cookie).toContain("projectbrain_session="); + expect(response.headers["set-cookie"]).toContain("HttpOnly"); + expect(response.headers["set-cookie"]).toContain("SameSite=Lax"); + expect(csrfToken.length).toBeGreaterThan(20); + expect(JSON.stringify(response.json())).not.toContain(ownerPassword); + expect(successEnvelopeSchema(authSessionDataSchema).safeParse(response.json()).success).toBe(true); + const session = await app.inject({ method: "GET", url: apiPaths.auth.session, headers: { cookie } }); + expect(session.statusCode).toBe(200); + expect(session.headers["set-cookie"]).toContain("Max-Age=3600"); + expect(session.json().data.user.username).toBe("owner"); + expect(successEnvelopeSchema(authSessionDataSchema).safeParse(session.json()).success).toBe(true); + }); + + it("rejects invalid credentials and enforces login rate limiting", async () => { + const app = await makeApp(); + const attempts = []; + for (let index = 0; index < 6; index += 1) attempts.push(await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: "incorrect-password-value" } })); + expect(attempts[0].statusCode).toBe(401); + expect(attempts[5].statusCode).toBe(429); + }); + + it("rejects malformed login and an incorrect current password", async () => { + const app = await makeApp(); + expect((await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "x", password: "short" } })).statusCode).toBe(400); + const { cookie, csrfToken } = await login(app); + const response = await app.inject({ method: "POST", url: "/api/auth/rotate-password", headers: { cookie, "x-csrf-token": csrfToken }, payload: { currentPassword: "incorrect-password-value", newPassword: "a-different-secure-owner-password" } }); + expect(response.statusCode).toBe(403); + }); + + it("requires CSRF for mutations and accepts the session token", async () => { + const app = await makeApp(); + const { cookie, csrfToken } = await login(app); + const missing = await app.inject({ method: "POST", url: "/api/projects", headers: { cookie }, payload: { name: "Secure Project", description: "A securely created project." } }); + expect(missing.statusCode).toBe(403); + expect(missing.json().error.code).toBe("CSRF_TOKEN_INVALID"); + const accepted = await app.inject({ method: "POST", url: "/api/projects", headers: { cookie, "x-csrf-token": csrfToken }, payload: { name: "Secure Project", description: "A securely created project." } }); + expect(accepted.statusCode).toBe(201); + }); + + it("does not accept a caller-supplied actor for durable work-graph commands", async () => { + expect(parseWorkGraphLeaseInput({ actor: "system:owner", leaseMinutes: 10 })).toEqual({ leaseMinutes: 10 }); + expect(parseAutonomyClaimInput({ actor: "system:owner", runId: "scheduled-run" })).toEqual({ runId: "scheduled-run" }); + }); + + it("revokes a session on logout", async () => { + const app = await makeApp(); + const { cookie, csrfToken } = await login(app); + expect((await app.inject({ method: "POST", url: "/api/auth/logout", headers: { cookie, "x-csrf-token": csrfToken } })).statusCode).toBe(200); + expect((await app.inject({ method: "GET", url: "/api/projects", headers: { cookie } })).statusCode).toBe(401); + }); + + it("rotates credentials and revokes every existing session", async () => { + const app = await makeApp(); + const { cookie, csrfToken } = await login(app); + const rotated = await app.inject({ method: "POST", url: "/api/auth/rotate-password", headers: { cookie, "x-csrf-token": csrfToken }, payload: { currentPassword: ownerPassword, newPassword: "a-different-secure-owner-password" } }); + expect(rotated.statusCode).toBe(200); + expect((await app.inject({ method: "GET", url: "/api/projects", headers: { cookie } })).statusCode).toBe(401); + expect((await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: ownerPassword } })).statusCode).toBe(401); + expect((await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: "a-different-secure-owner-password" } })).statusCode).toBe(200); + }); + + it("emits baseline security headers", async () => { + const app = await makeApp(); + const response = await app.inject({ method: "GET", url: "/api/health" }); + expect(response.headers["content-security-policy"]).toContain("default-src 'self'"); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + expect(response.headers["x-frame-options"]).toBe("SAMEORIGIN"); + }); +}); diff --git a/backend/test/autonomy.test.ts b/backend/test/autonomy.test.ts new file mode 100644 index 0000000..5472d97 --- /dev/null +++ b/backend/test/autonomy.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { loadEnv } from "../src/config/env.js"; +import { compareAutoModeProjectRows, createProjectBrainMcpServer } from "../src/mcp/autonomyServer.js"; +import { autonomyRetryDelayMinutes, canResumeAutonomyLease, classifyAutonomyBlocker, enqueueAutonomyTaskInState, evaluateAutonomyRunBudget, isAutonomyLeaseOwner, normalizeAutonomyState, parseRoadmapMarkdown, releaseActiveAutonomyLease, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js"; +import { autonomyHeartbeatSlotId, compactAutonomyState, resolveAutonomyHeartbeat, summarizeAutonomyProject } from "../src/services/autonomyRuntime.js"; + +const env = loadEnv({ + NODE_ENV: "test", + DATABASE_URL: "postgresql://example.invalid/projectbrain", + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_MCP_ENABLED: "true", + PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", + PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", + PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters", +}); + +describe("Auto Mode roadmap parser", () => { + it("turns markdown phases and list items into ordered durable work", () => { + const roadmap = parseRoadmapMarkdown(`# Product roadmap + +## Fundament +Maak de deliverylaag hervatbaar. +- [x] Bestaande status importeren +- [ ] Leasecontract toevoegen + +## Product +Voltooi de gebruikersflow. +1. Auto toggle bouwen +2. Voortgang tonen`); + + expect(roadmap.title).toBe("Product roadmap"); + expect(roadmap.phases).toHaveLength(2); + expect(roadmap.phases[0]).toMatchObject({ title: "Fundament", objective: "Maak de deliverylaag hervatbaar.", status: "active" }); + expect(roadmap.phases[0].items.map((item) => [item.title, item.status])).toEqual([ + ["Bestaande status importeren", "completed"], + ["Leasecontract toevoegen", "pending"], + ]); + expect(roadmap.phases[1].status).toBe("pending"); + expect(roadmap.phases[1].items.map((item) => item.title)).toEqual(["Auto toggle bouwen", "Voortgang tonen"]); + }); + + it("accepts a flat task list by creating one execution phase", () => { + const roadmap = parseRoadmapMarkdown("- Analyseer repo\n- Bouw oplossing\n- Valideer resultaat", "MCP automation"); + + expect(roadmap.title).toBe("MCP automation"); + expect(roadmap.phases).toHaveLength(1); + expect(roadmap.phases[0].title).toBe("Execution"); + expect(roadmap.phases[0].items).toHaveLength(3); + }); + + it("allows an empty phase so the brainstorm loop can discover its work", () => { + const roadmap = parseRoadmapMarkdown("# Roadmap\n\n## Quality convergence\nZoek autonoom naar resterende gaten."); + + expect(roadmap.phases[0].items).toEqual([]); + expect(roadmap.phases[0].brainstormConverged).toBe(false); + expect(roadmap.phases[0].objective).toBe("Zoek autonoom naar resterende gaten."); + }); +}); + +describe("Auto Mode state safety", () => { + it("keeps an hourly ChatGPT Chat executor fresh between scheduled runs", () => { + expect(env.PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS).toBe(5_400); + }); + + it("stores a user assignment durably even while Auto Mode is off", () => { + const current = normalizeAutonomyState({}, "owner"); + const queued = enqueueAutonomyTaskInState(current, { instruction: "Verbeter de mobiele navigatie\nmet regressietests." }, "owner"); + + expect(queued.state.status).toBe("off"); + expect(queued.task).toMatchObject({ + title: "Verbeter de mobiele navigatie", + instruction: "Verbeter de mobiele navigatie\nmet regressietests.", + source: "user_assignment", + status: "pending", + }); + expect(queued.state.roadmap?.phases[0].items).toEqual([queued.task]); + expect(queued.state.events.at(-1)?.type).toBe("autonomy.task.enqueued"); + }); + + it("prioritizes a new user assignment in the current claimable phase instead of a phase already passed", () => { + const roadmap = parseRoadmapMarkdown("## Eerder\n- [ ] Eerste opdracht\n\n## Huidig\n- [ ] Lopend werk"); + roadmap.phases[0].id = "projectbrain-opdrachten-old"; + roadmap.phases[0].status = "completed"; + roadmap.phases[1].status = "active"; + roadmap.currentPhaseIndex = 1; + const current = normalizeAutonomyState({ enabled: true, status: "running", roadmap }, "owner"); + + const queued = enqueueAutonomyTaskInState(current, { instruction: "Nieuwe opdracht" }, "owner"); + + expect(queued.state.status).toBe("running"); + expect(queued.state.roadmap?.phases).toHaveLength(2); + expect(queued.state.roadmap?.phases[0].status).toBe("completed"); + expect(queued.state.roadmap?.phases[1].items[0]).toEqual(queued.task); + expect(queued.state.roadmap?.phases[1].items[1].title).toBe("Lopend werk"); + }); + + it("derives the public status from durable facts instead of stale stored copy", () => { + expect(normalizeAutonomyState({ enabled: true, status: "running" }).status).toBe("waiting_roadmap"); + expect(normalizeAutonomyState({}).profile).toBe("normal"); + expect(normalizeAutonomyState({ profile: "unsupported" }).profile).toBe("normal"); + const disabled = normalizeAutonomyState({ enabled: false, status: "running", activeLease: { token: "old", kind: "implement", phaseId: "phase" } }); + expect(disabled.status).toBe("off"); + expect(disabled.activeLease).toBeUndefined(); + }); + + it("releases in-progress work safely when Auto Mode is disabled", () => { + const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk"); + roadmap.phases[0].status = "executing"; + roadmap.phases[0].items[0].status = "in_progress"; + const released = releaseActiveAutonomyLease({ + version: 1, + profile: "normal", + enabled: true, + status: "running", + autoBrainstorm: true, + maxBrainstormIterations: 5, + maxConsecutiveFailures: 3, + leaseMinutes: 30, + maxWorkItemsPerRun: 8, + maxRunMinutes: 45, + runs: [], + roadmap, + activeLease: { token: "lease", kind: "implement", actor: "codex", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString() }, + completedLeases: [], + events: [], + updatedAt: new Date().toISOString(), + updatedBy: "owner", + }, "owner"); + + expect(released.activeLease).toBeUndefined(); + expect(released.roadmap?.phases[0]).toMatchObject({ status: "active", items: [expect.objectContaining({ status: "pending" })] }); + expect(released.events.at(-1)?.type).toBe("autonomy.lease.released"); + }); + + it("separates claimable work from a fresh or expired executor lease", () => { + const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk"); + const base = normalizeAutonomyState({ enabled: true, roadmap }); + const fresh = summarizeAutonomyProject({ + projectId: "project-id", projectSlug: "project", projectName: "Project", + autonomy: { ...base, activeLease: { token: "lease", kind: "implement", actor: "executor", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: "2026-09-02T10:00:00.000Z", expiresAt: "2026-09-02T10:30:00.000Z" } }, + }, Date.parse("2026-09-02T10:15:00.000Z")); + const expired = summarizeAutonomyProject({ + projectId: "project-id", projectSlug: "project", projectName: "Project", + autonomy: { ...base, activeLease: { token: "lease", kind: "implement", actor: "executor", roadmapId: roadmap.id, phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, createdAt: "2026-09-02T10:00:00.000Z", expiresAt: "2026-09-02T10:30:00.000Z" } }, + }, Date.parse("2026-09-02T10:31:00.000Z")); + + expect(fresh).toMatchObject({ activeWorkKind: "implement", pendingWorkCount: 1, lifecycle: "running" }); + expect(expired.activeWorkKind).toBeUndefined(); + expect(expired.lifecycle).toBe("ready"); + }); + + it("defaults and bounds the continuous executor run budget", () => { + expect(normalizeAutonomyState({})).toMatchObject({ maxWorkItemsPerRun: 8, maxRunMinutes: 45 }); + expect(normalizeAutonomyState({ maxWorkItemsPerRun: 100, maxRunMinutes: 2 })).toMatchObject({ maxWorkItemsPerRun: 20, maxRunMinutes: 5 }); + }); + + it("only lets the owning scheduled run resume a live lease", () => { + const lease = { + token: "lease", + kind: "implement" as const, + actor: "chatgpt-web", + runId: "run-1", + roadmapId: "roadmap", + phaseId: "phase", + createdAt: "2026-09-02T10:00:00.000Z", + expiresAt: "2026-09-02T10:30:00.000Z", + }; + + expect(canResumeAutonomyLease(lease, "chatgpt-web", "run-1")).toBe(true); + expect(canResumeAutonomyLease(lease, "chatgpt-web", "run-2")).toBe(false); + expect(canResumeAutonomyLease(lease, "another-executor", "run-1")).toBe(false); + expect(canResumeAutonomyLease(lease, "chatgpt-web")).toBe(false); + expect(isAutonomyLeaseOwner(lease, "chatgpt-web")).toBe(true); + expect(isAutonomyLeaseOwner(lease, "another-executor")).toBe(false); + }); + + it("enforces item and elapsed-time budgets from durable run state", () => { + const base = normalizeAutonomyState({ + maxWorkItemsPerRun: 2, + maxRunMinutes: 10, + runs: [{ id: "item-run", actor: "chatgpt-web", startedAt: "2026-09-03T10:00:00.000Z", reportedWorkItems: 2 }], + }); + expect(evaluateAutonomyRunBudget(base, "chatgpt-web", "item-run", Date.parse("2026-09-03T10:01:00.000Z"))).toMatchObject({ allowed: false, reason: "item_limit" }); + + const timed = normalizeAutonomyState({ + maxWorkItemsPerRun: 8, + maxRunMinutes: 10, + runs: [{ id: "time-run", actor: "chatgpt-web", startedAt: "2026-09-03T10:00:00.000Z", reportedWorkItems: 1 }], + }); + expect(evaluateAutonomyRunBudget(timed, "chatgpt-web", "time-run", Date.parse("2026-09-03T10:10:00.000Z"))).toMatchObject({ allowed: false, reason: "time_limit" }); + expect(evaluateAutonomyRunBudget(timed, "other-actor", "time-run", Date.parse("2026-09-03T10:01:00.000Z"))).toMatchObject({ allowed: false, reason: "owner_mismatch" }); + }); + + it("classifies technical failures as retryable and reserves human blockers for real owner input", () => { + expect(classifyAutonomyBlocker("Untrusted non-HTTPS Gitea server URL")).toBe("retryable"); + expect(classifyAutonomyBlocker("Runner unavailable during validation")).toBe("retryable"); + expect(classifyAutonomyBlocker("Missing credential required for production deployment")).toBe("human"); + expect(classifyAutonomyBlocker("Owner decision required between two product directions")).toBe("human"); + expect(classifyAutonomyBlocker("Temporary provider failure", "human")).toBe("retryable"); + }); + + it("backs off repeated technical failures without requiring a human decision", () => { + expect(autonomyRetryDelayMinutes(1)).toBe(15); + expect(autonomyRetryDelayMinutes(2)).toBe(30); + expect(autonomyRetryDelayMinutes(3)).toBe(60); + expect(autonomyRetryDelayMinutes(20)).toBeLessThanOrEqual(1_440); + }); + + it("makes a due retryable blocker claimable without clearing failure counters", () => { + const roadmap = parseRoadmapMarkdown("## Fase\n- [ ] Werk"); + roadmap.status = "blocked"; + roadmap.phases[0].status = "blocked"; + roadmap.phases[0].items[0].status = "blocked"; + roadmap.phases[0].items[0].attempts = 2; + const blocked = normalizeAutonomyState({ + enabled: true, + roadmap, + blocker: { at: "2026-09-03T10:00:00.000Z", retryAt: "2026-09-03T11:00:00.000Z", kind: "retryable", phaseId: roadmap.phases[0].id, itemId: roadmap.phases[0].items[0].id, reason: "Temporary provider failure" }, + }); + + const retried = retryAutonomyBlockerIfDue(blocked, Date.parse("2026-09-03T11:00:00.000Z")); + + expect(retried).toMatchObject({ status: "running", roadmap: { status: "active", phases: [{ status: "active", items: [{ status: "pending", attempts: 2 }] }] } }); + expect(retried.blocker).toBeUndefined(); + expect(retried.events.at(-1)?.type).toBe("autonomy.blocker.retry_ready"); + }); + + it("fences heartbeat ownership between overlapping scheduled runs", () => { + const current = { runId: "new-run", status: "working", startedAt: new Date("2026-09-03T10:00:00.000Z") }; + expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "old-run", status: "idle" })).toMatchObject({ accepted: false }); + expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", status: "degraded" })).toMatchObject({ accepted: false, reason: "run_owner_mismatch" }); + expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "new-run", status: "idle" })).toMatchObject({ accepted: true, startedAt: current.startedAt }); + expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:10:00.000Z"))).toMatchObject({ accepted: false, reason: "active_run" }); + expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T11:00:00.000Z"))).toMatchObject({ accepted: true, startedAt: new Date("2026-09-03T11:00:00.000Z") }); + expect(resolveAutonomyHeartbeat({ ...current, status: "idle" }, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:10:00.000Z"))).toMatchObject({ accepted: true, startedAt: new Date("2026-09-03T10:10:00.000Z") }); + expect(resolveAutonomyHeartbeat({ ...current, activeProjectId: "project-a" }, { instanceId: "ordinary-chat", runId: "new-run", status: "working", activeProjectId: "project-b" })).toMatchObject({ accepted: false, reason: "active_project" }); + }); + + it("allows takeover after a short project's configured run window plus grace", () => { + const current = { runId: "old-run", status: "working", startedAt: new Date("2026-09-03T10:00:00.000Z") }; + const takeoverAfterMs = 25 * 60_000; + expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:24:00.000Z"), takeoverAfterMs)).toMatchObject({ accepted: false, reason: "active_run" }); + expect(resolveAutonomyHeartbeat(current, { instanceId: "ordinary-chat", runId: "next-run", status: "working" }, new Date("2026-09-03T10:26:00.000Z"), takeoverAfterMs)).toMatchObject({ accepted: true }); + }); + + it("isolates concurrent ordinary ChatGPT executors by project", () => { + expect(autonomyHeartbeatSlotId("mediarr")).toBe("project:mediarr"); + expect(autonomyHeartbeatSlotId("rombuilder")).toBe("project:rombuilder"); + expect(autonomyHeartbeatSlotId("mediarr")).not.toBe(autonomyHeartbeatSlotId("rombuilder")); + expect(autonomyHeartbeatSlotId()).toBe("primary"); + }); + + it("projects large durable roadmaps into bounded MCP context", () => { + const roadmap = parseRoadmapMarkdown(`## Current\n${Array.from({ length: 80 }, (_, index) => `- [ ] Item ${index + 1} ${"detail ".repeat(30)}`).join("\n")}\n## Later\n${Array.from({ length: 80 }, (_, index) => `- [ ] Later ${index + 1}`).join("\n")}`); + const state = normalizeAutonomyState({ + enabled: true, + roadmap, + events: Array.from({ length: 30 }, (_, index) => ({ id: `event-${index}`, at: "2026-09-04T10:00:00.000Z", type: "test", actor: "chatgpt", summary: "evidence ".repeat(200) })), + }); + for (const item of state.roadmap!.phases[0].items.slice(0, 40)) item.status = "completed"; + + const compact = compactAutonomyState(state, { itemLimit: 10, eventLimit: 3 }); + const serialized = JSON.stringify(compact); + + expect(compact.roadmap?.currentPhase?.items).toHaveLength(10); + expect(compact.roadmap?.currentPhase).toMatchObject({ totalItems: 80, itemWindowStart: 37, itemsReturned: 10, hasMoreItems: true }); + expect(compact.roadmap?.currentPhase?.items[3]).toMatchObject({ title: expect.stringContaining("Item 41"), status: "pending" }); + expect(compact.events).toHaveLength(3); + expect(serialized).not.toContain("sourceMarkdown"); + expect(serialized.length).toBeLessThan(12_000); + expect(serialized.length).toBeLessThan(JSON.stringify(state).length / 5); + }); +}); + +describe("Auto Mode MCP surface", () => { + it("orders claimable projects fairly before considering backlog size", () => { + const projects = [ + { name: "huge-recent", status: "running", pending_work_items: 201, last_run_at: "2026-09-05T09:00:00.000Z" }, + { name: "small-old", status: "running", pending_work_items: 4, last_run_at: "2026-09-04T09:00:00.000Z" }, + { name: "never-run", status: "running", pending_work_items: 2 }, + { name: "busy", status: "running", pending_work_items: 50, active_work_kind: "implement", last_run_at: "2026-09-01T09:00:00.000Z" }, + { name: "blocked", status: "blocked", pending_work_items: 500, last_run_at: "2026-08-01T09:00:00.000Z" }, + ].sort(compareAutoModeProjectRows); + + expect(projects.map((project) => project.name)).toEqual(["never-run", "small-old", "huge-recent", "busy", "blocked"]); + }); + + it("advertises the complete durable roadmap controller to MCP clients", async () => { + const server = createProjectBrainMcpServer(env, "auto-contract-test"); + const client = new Client({ name: "auto-contract-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertisedTools = (await client.listTools()).tools; + const tools = advertisedTools.map((tool) => tool.name); + + expect(tools).toEqual(expect.arrayContaining([ + "list_auto_mode_projects", + "heartbeat_auto_executor", + "get_auto_mode_state", + "configure_auto_mode", + "import_auto_roadmap", + "resume_auto_mode", + "claim_auto_work", + "report_auto_work", + ])); + expect(advertisedTools.find((tool) => tool.name === "claim_auto_work")?.inputSchema).toMatchObject({ + required: expect.arrayContaining(["project_id", "run_id"]), + }); + expect(advertisedTools.find((tool) => tool.name === "configure_auto_mode")?.inputSchema).toMatchObject({ + properties: expect.objectContaining({ max_work_items_per_run: expect.any(Object), max_run_minutes: expect.any(Object), max_recovery_minutes: expect.any(Object), pause_after_current: expect.any(Object), scheduling_policy: expect.any(Object) }), + }); + expect(advertisedTools.find((tool) => tool.name === "get_auto_mode_state")?.inputSchema).toMatchObject({ + properties: expect.objectContaining({ view: expect.any(Object), item_limit: expect.any(Object), event_limit: expect.any(Object) }), + }); + expect(advertisedTools.find((tool) => tool.name === "report_auto_work")?.inputSchema).toMatchObject({ + properties: expect.objectContaining({ blocker_type: expect.any(Object), retry_after_minutes: expect.any(Object) }), + }); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/backend/test/autonomyRecovery.test.ts b/backend/test/autonomyRecovery.test.ts new file mode 100644 index 0000000..5512ebb --- /dev/null +++ b/backend/test/autonomyRecovery.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { autonomyRetryDelayMinutes, classifyAutonomyBlocker } from "../src/services/autonomyRecovery.js"; + +describe("Auto Mode recovery classification", () => { + it("keeps ordinary infrastructure failures retryable even when a client requests human escalation", () => { + expect(classifyAutonomyBlocker("Gitea runner timed out while starting validation.", "human")).toBe("retryable"); + expect(classifyAutonomyBlocker("Untrusted non-HTTPS Gitea server URL blocked security bootstrap.", "human")).toBe("retryable"); + expect(classifyAutonomyBlocker("Provider returned HTTP 502 Bad Gateway.", "human")).toBe("retryable"); + }); + + it("requires human attention only when durable evidence says a real owner action is needed", () => { + expect(classifyAutonomyBlocker("Missing credential required for the production deployment.", "retryable")).toBe("human"); + expect(classifyAutonomyBlocker("Owner decision is required before choosing between destructive reset and preserving production data.")).toBe("human"); + expect(classifyAutonomyBlocker("Approval is required for this irreversible operation.")).toBe("human"); + }); + + it("defaults unknown operational failures to retryable instead of creating approval chores", () => { + expect(classifyAutonomyBlocker("Unexpected validation orchestration failure.", "human")).toBe("retryable"); + }); + + it("uses bounded exponential retry delays", () => { + expect(autonomyRetryDelayMinutes(1)).toBe(15); + expect(autonomyRetryDelayMinutes(2)).toBe(30); + expect(autonomyRetryDelayMinutes(3)).toBe(60); + expect(autonomyRetryDelayMinutes(7)).toBe(960); + expect(autonomyRetryDelayMinutes(100)).toBe(960); + }); + + it.each([ + "CI failed. No approval is required; repair the same branch.", + "No destructive operation was performed. Runner is unavailable.", + "This is not a credentials or approval requirement. Validation failed.", + ])("does not mistake negated human decisions for blockers: %s", (reason) => { + expect(classifyAutonomyBlocker(reason, "human")).toBe("retryable"); + }); + + it("retains a real decision even beside negated or technical context", () => { + expect(classifyAutonomyBlocker("No CI output is available, but missing credentials prevent deployment.")).toBe("human"); + expect(classifyAutonomyBlocker("Runner failed. Owner authorization is required to change branch protection.")).toBe("human"); + expect(classifyAutonomyBlocker("No changes were made, owner authorization is required.")).toBe("human"); + expect(classifyAutonomyBlocker("This is not a CI issue and credentials are required.")).toBe("human"); + }); +}); diff --git a/backend/test/autonomyReplanner.test.ts b/backend/test/autonomyReplanner.test.ts new file mode 100644 index 0000000..634d80b --- /dev/null +++ b/backend/test/autonomyReplanner.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { reconcileAutonomyPlan } from "../src/services/autonomyReplanner.js"; +import { normalizeAutonomyState } from "../src/repositories/autonomyRepository.js"; + +const state = () => normalizeAutonomyState({ enabled: true, roadmap: { id: "roadmap-1", title: "Release", status: "active", currentPhaseIndex: 0, importedAt: "2026-09-12T10:00:00.000Z", phases: [{ id: "phase-1", title: "Build", objective: "Ship", status: "executing", items: [{ id: "item-1", title: "Feature", source: "delivery", deliveryId: "delivery-1", status: "in_progress", attempts: 1 }] }] } }); + +describe("autonomy reality reconciliation", () => { + it("preserves work completed by an external merge", () => { + const result = reconcileAutonomyPlan(state(), [{ itemId: "item-1", deliveryId: "delivery-1", status: "pull_request_open", mergeCommitSha: "abc123", validationEvidence: "head123" }]); + const item = result.state.roadmap?.phases[0]?.items[0]; + expect(item?.status).toBe("completed"); + expect(item?.completionEvidence).toEqual(expect.arrayContaining(["delivery:delivery-1", "merge:abc123", "head123"])); + }); + + it("adds bounded recovery work for superseded delivery state", () => { + const result = reconcileAutonomyPlan(state(), [{ itemId: "item-1", deliveryId: "delivery-1", status: "superseded", supersededBy: "delivery-2" }]); + expect(result.repairItems).toHaveLength(1); + expect(result.state.roadmap?.phases[0]?.items).toHaveLength(2); + expect(result.state.roadmap?.phases[0]?.items[1]?.source).toBe("recovery"); + }); + + it("replans a previously completed item when its delivery is superseded", () => { + const completed = state(); + completed.roadmap!.phases[0].items[0].status = "completed"; + const result = reconcileAutonomyPlan(completed, [{ itemId: "item-1", deliveryId: "delivery-1", status: "merged", supersededBy: "delivery-2" }]); + expect(result.repairItems).toHaveLength(1); + expect(result.state.roadmap?.phases[0]?.items[0]).toMatchObject({ status: "completed", supersededBy: "delivery-2" }); + }); + + it("clears an external wait when the observed delivery becomes available again", () => { + const waiting = state(); + const item = waiting.roadmap?.phases[0]?.items[0]; + if (!item) throw new Error("test item missing"); + item.waitingExternal = true; + item.lastError = "Provider unavailable"; + const result = reconcileAutonomyPlan(waiting, [{ itemId: item.id, deliveryId: "delivery-1", status: "executing" }]); + expect(result.changed).toBe(true); + expect(result.state.roadmap?.phases[0]?.items[0]).toMatchObject({ waitingExternal: false, status: "in_progress" }); + expect(result.state.roadmap?.phases[0]?.items[0]?.lastError).toBeUndefined(); + }); +}); diff --git a/backend/test/autonomyScheduler.test.ts b/backend/test/autonomyScheduler.test.ts new file mode 100644 index 0000000..54465ab --- /dev/null +++ b/backend/test/autonomyScheduler.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { chooseNextAutonomyWork, planParallelAutonomyWork } from "../src/services/autonomyScheduler.js"; +import { parseRoadmapMarkdown } from "../src/repositories/autonomyRepository.js"; + +describe("smart autonomy scheduler", () => { + it("chooses the independent item that unlocks the most downstream work", () => { + const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B\n- [ ] C\n- [ ] D"); + const [a, b, c, d] = roadmap.phases[0].items; + c.dependsOn = [a.id]; + d.dependsOn = [a.id]; + b.priority = 80; + const selected = chooseNextAutonomyWork(roadmap.phases, 0); + expect(selected?.item.id).toBe(a.id); + expect(selected?.downstreamCount).toBe(2); + }); + + it("does not group likely path-conflicting work in parallel", () => { + const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B\n- [ ] C"); + const [a, b, c] = roadmap.phases[0].items; + a.touchedPaths = ["src/shared/project.ts"]; + b.touchedPaths = ["src/shared/project.ts"]; + c.touchedPaths = ["src/other.ts"]; + const candidates = chooseNextAutonomyWork(roadmap.phases, 0); + expect(candidates?.item).toBeTruthy(); + const all = roadmap.phases[0].items.map((item) => ({ item, phase: roadmap.phases[0], phaseIndex: 0, score: 1, downstreamCount: 0, conflictKeys: (item.touchedPaths ?? []).map((path) => `path:${path}`) })); + const groups = planParallelAutonomyWork(all); + expect(groups.some((group) => group.includes(a.id) && group.includes(b.id))).toBe(false); + }); + + it("can deliberately favor downstream impact when a command changes scheduling policy", () => { + const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B\n- [ ] C\n- [ ] D"); + const [a, b, c, d] = roadmap.phases[0].items; + c.dependsOn = [a.id]; + d.dependsOn = [a.id]; + b.priority = 100; + expect(chooseNextAutonomyWork(roadmap.phases, 0, "downstream-impact")?.item.id).toBe(a.id); + }); + + it("does not claim work while its external dependency is unavailable", () => { + const roadmap = parseRoadmapMarkdown("## Delivery\n- [ ] A\n- [ ] B"); + roadmap.phases[0].items[0].waitingExternal = true; + expect(chooseNextAutonomyWork(roadmap.phases, 0)?.item.id).toBe(roadmap.phases[0].items[1].id); + }); +}); diff --git a/backend/test/autonomyWork.test.ts b/backend/test/autonomyWork.test.ts new file mode 100644 index 0000000..deb7cf7 --- /dev/null +++ b/backend/test/autonomyWork.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAutonomyState, parseRoadmapMarkdown, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js"; +import { addDiscoveredTasks, isAutonomyItemReady, orderAutonomyRepairs, workPrompt } from "../src/services/autonomyWork.js"; + +describe("autonomous repair sequencing", () => { + it("runs an existing prerequisite before retrying a priority user assignment", () => { + const phase = parseRoadmapMarkdown("## CI\n- Gate Ruff\n- Fix Ruff errors").phases[0]; + const [gate, repair] = phase.items; + gate.source = "user_assignment"; + expect(addDiscoveredTasks(phase, ["Fix Ruff errors"])).toEqual([]); + expect(orderAutonomyRepairs(phase, gate, ["Fix Ruff errors"])).toBe(true); + expect(isAutonomyItemReady(phase, gate)).toBe(false); + expect(isAutonomyItemReady(phase, repair)).toBe(true); + repair.status = "completed"; + expect(isAutonomyItemReady(phase, gate)).toBe(true); + }); + + it("rejects circular repair dependencies and does not duplicate discovered work", () => { + const phase = parseRoadmapMarkdown("## CI\n- Gate\n- Repair").phases[0]; + orderAutonomyRepairs(phase, phase.items[0], ["Repair"]); + expect(() => orderAutonomyRepairs(phase, phase.items[1], ["Gate"])).toThrow("AUTO_MODE_DEPENDENCY_CYCLE"); + expect(addDiscoveredTasks(phase, ["Repair", " repair ", "New test"])).toEqual(["New test"]); + }); + + it("recovers historical technical human blockers with a bounded retry time", () => { + const roadmap = parseRoadmapMarkdown("## CI\n- Repair"); + roadmap.status = "blocked"; + roadmap.phases[0].status = "blocked"; + const state = normalizeAutonomyState({ enabled: true, roadmap, maxRecoveryMinutes: 120, blocker: { kind: "human", at: "2026-09-01T23:00:00Z", recoveryStartedAt: "2026-09-01T23:00:00Z", reason: "CI validation failed. No approval is required." } }); + expect(state.blocker?.kind).toBe("retryable"); + expect(retryAutonomyBlockerIfDue(state, Date.parse("2026-09-02T00:00:00Z")).status).toBe("running"); + }); + + it("resumes the recorded delivery through merge rather than suggesting a new branch", () => { + const phase = parseRoadmapMarkdown("## Build\n- Feature").phases[0]; + phase.items[0].deliveryId = "existing-delivery"; + const prompt = workPrompt("implement", phase, phase.items[0]); + expect(prompt).toContain("Resume delivery 'existing-delivery'"); + expect(prompt).toContain("merge_feature_delivery"); + expect(prompt).toContain("Always report delivery_id"); + }); +}); diff --git a/backend/test/canonicalLifecycle.test.ts b/backend/test/canonicalLifecycle.test.ts new file mode 100644 index 0000000..0eb5f43 --- /dev/null +++ b/backend/test/canonicalLifecycle.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAutonomyState } from "../src/repositories/autonomyRepository.js"; +import { canonicalLifecycleFromAutonomy } from "../src/services/canonicalLifecycle.js"; + +describe("backend canonical lifecycle projection", () => { + it("uses the same seven-state semantics as the product contract", () => { + expect(canonicalLifecycleFromAutonomy(normalizeAutonomyState({ enabled: false, status: "off" }))).toBe("ready"); + expect(canonicalLifecycleFromAutonomy(normalizeAutonomyState({ + enabled: true, + status: "blocked", + blocker: { kind: "human", reason: "Owner decision required", at: "2026-09-01T00:00:00Z" }, + }))).toBe("needs_user"); + expect(canonicalLifecycleFromAutonomy(normalizeAutonomyState({ + enabled: true, + status: "blocked", + blocker: { kind: "retryable", reason: "Runner timeout", at: "2026-09-01T00:00:00Z" }, + }))).toBe("recovering"); + const completed = normalizeAutonomyState({ enabled: true, status: "completed" }); + expect(canonicalLifecycleFromAutonomy({ ...completed, status: "completed" })).toBe("done"); + }); +}); diff --git a/backend/test/database-authentication.test.ts b/backend/test/database-authentication.test.ts new file mode 100644 index 0000000..e9c22c6 --- /dev/null +++ b/backend/test/database-authentication.test.ts @@ -0,0 +1,300 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadEnv } from "../src/config/env.js"; +import { prisma } from "../src/db/prisma.js"; +import { createSession } from "../src/security/authStore.js"; +import { hashPassword } from "../src/security/password.js"; +import { createHash } from "node:crypto"; +import { apiPaths } from "@projectbrain/contracts"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; + +const databaseUrl = process.env.TEST_DATABASE_URL; +if (databaseUrl) process.env.DATABASE_URL = databaseUrl; +const databaseDescribe = databaseUrl ? describe : describe.skip; +const ownerPassword = "database-owner-password-value"; +const coreLoopIds = { + capture: [] as string[], review: [] as string[], truth: [] as string[], update: [] as string[], + handoff: [] as string[], task: [] as string[], runImport: [] as string[], +}; +const testEnv = () => loadEnv({ + NODE_ENV: "test", + LOG_LEVEL: "silent", + DATABASE_URL: databaseUrl, + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "database-owner", + PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, + PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_SESSION_TTL_HOURS: "1", + PROJECTBRAIN_TRUST_PROXY: "false", +}); +const entraEnv = () => loadEnv({ + NODE_ENV: "test", + LOG_LEVEL: "silent", + DATABASE_URL: databaseUrl, + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "database-owner", + PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, + PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_SESSION_TTL_HOURS: "1", + PROJECTBRAIN_TRUST_PROXY: "false", + PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", + PROJECTBRAIN_ENTRA_TENANT_ID: "11111111-1111-4111-8111-111111111111", + PROJECTBRAIN_ENTRA_CLIENT_ID: "22222222-2222-4222-8222-222222222222", + PROJECTBRAIN_ENTRA_CLIENT_SECRET: "database-entra-test-secret", +}); + +databaseDescribe("database authentication persistence", () => { + beforeAll(async () => { + await prisma.oidcLoginAttempt.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } }); + await prisma.externalIdentity.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } }); + await prisma.authSession.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } }); + await prisma.projectMembership.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } }); + await prisma.user.deleteMany({ where: { username: { in: ["database-owner", "database-member"] } } }); + }); + + afterAll(async () => { + await prisma.oidcLoginAttempt.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } }); + await prisma.externalIdentity.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } }); + const markedTasks = await prisma.codexTask.findMany({ where: { goal: "Execute the database core-loop proof" }, select: { id: true } }); + await prisma.codexRunImport.deleteMany({ where: { OR: [{ sourceRun: "core-loop-database-proof" }, { codexTaskId: { in: markedTasks.map((item) => item.id) } }] } }); + await prisma.reviewQueueItem.deleteMany({ where: { OR: [{ source: "core-loop-database-proof" }, { title: { contains: "Core-loop database proof" } }] } }); + await prisma.codexTask.deleteMany({ where: { goal: "Execute the database core-loop proof" } }); + await prisma.handoffBundle.deleteMany({ where: { title: "Core-loop database proof handoff" } }); + const markedCaptures = await prisma.captureItem.findMany({ where: { content: "Core-loop database proof source material" }, select: { linkedReviewItemId: true } }); + const markedReviewIds = markedCaptures.flatMap((item) => item.linkedReviewItemId ? [item.linkedReviewItemId] : []); + const markedTruth = await prisma.acceptedTruthItem.findMany({ where: { sourceReviewItemId: { in: markedReviewIds } }, select: { id: true } }); + await prisma.projectUpdate.deleteMany({ where: { sourceTruthItemId: { in: markedTruth.map((item) => item.id) } } }); + await prisma.acceptedTruthItem.deleteMany({ where: { id: { in: markedTruth.map((item) => item.id) } } }); + await prisma.reviewQueueItem.deleteMany({ where: { id: { in: markedReviewIds } } }); + await prisma.captureItem.deleteMany({ where: { content: "Core-loop database proof source material" } }); + await prisma.codexRunImport.deleteMany({ where: { id: { in: coreLoopIds.runImport } } }); + await prisma.codexTask.deleteMany({ where: { id: { in: coreLoopIds.task } } }); + await prisma.handoffBundle.deleteMany({ where: { id: { in: coreLoopIds.handoff } } }); + await prisma.projectUpdate.deleteMany({ where: { id: { in: coreLoopIds.update } } }); + await prisma.acceptedTruthItem.deleteMany({ where: { id: { in: coreLoopIds.truth } } }); + await prisma.reviewQueueItem.deleteMany({ where: { id: { in: coreLoopIds.review } } }); + await prisma.captureItem.deleteMany({ where: { id: { in: coreLoopIds.capture } } }); + await prisma.idempotencyRecord.deleteMany({ where: { key: { contains: "core-loop-database-proof" } } }); + await prisma.operatingLogEntry.deleteMany({ where: { title: "Durable idempotency restart proof" } }); + await prisma.idempotencyRecord.deleteMany({ where: { key: { contains: "database-restart-replay" } } }); + await prisma.authSession.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } }); + await prisma.projectMembership.deleteMany({ where: { user: { username: { in: ["database-owner", "database-member"] } } } }); + await prisma.user.deleteMany({ where: { username: { in: ["database-owner", "database-member"] } } }); + await prisma.$disconnect(); + }); + + it("keeps a server-side owner session valid across API restart", async () => { + const firstApp = await buildApp({ env: testEnv() }); + const login = await firstApp.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } }); + expect(login.statusCode).toBe(200); + const cookie = login.cookies.find((item) => item.name === "projectbrain_session"); + expect(cookie).toBeDefined(); + const cookieHeader = `${cookie!.name}=${cookie!.value}`; + await firstApp.close(); + + const restartedApp = await buildApp({ env: testEnv() }); + const response = await restartedApp.inject({ method: "GET", url: "/api/projects", headers: { cookie: cookieHeader } }); + expect(response.statusCode).toBe(200); + expect(response.json().data.length).toBeGreaterThan(0); + await restartedApp.close(); + }, 15_000); + + it("creates a one-time PKCE Entra link attempt without persisting raw state", async () => { + const app = await buildApp({ env: entraEnv() }); + const login = await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "database-owner", password: ownerPassword } }); + const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session"); + const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`; + const csrfToken = login.json().data.csrfToken as string; + const response = await app.inject({ method: "POST", url: apiPaths.auth.entraLinkStart, headers: { cookie, "x-csrf-token": csrfToken } }); + expect(response.statusCode).toBe(200); + const authorizationUrl = new URL(response.json().data.authorizationUrl); + expect(authorizationUrl.hostname).toBe("login.microsoftonline.com"); + expect(authorizationUrl.pathname).toContain("11111111-1111-4111-8111-111111111111"); + expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authorizationUrl.searchParams.get("nonce")?.length).toBeGreaterThan(30); + const state = authorizationUrl.searchParams.get("state")!; + const attempt = await prisma.oidcLoginAttempt.findUnique({ where: { stateHash: createHash("sha256").update(state).digest("hex") } }); + expect(attempt?.purpose).toBe("link"); + expect(attempt?.returnTo).toBe("/settings"); + expect(attempt?.stateHash).not.toBe(state); + await app.close(); + }); + + it("validates the Entra ID token and turns a linked identity into a ProjectBrain session", async () => { + const app = await buildApp({ env: entraEnv() }); + const login = await app.inject({ method: "POST", url: apiPaths.auth.login, payload: { username: "database-owner", password: ownerPassword } }); + const localCookie = login.cookies.find((item) => item.name === "projectbrain_session")!; + const linkStart = await app.inject({ method: "POST", url: apiPaths.auth.entraLinkStart, headers: { cookie: `${localCookie.name}=${localCookie.value}`, "x-csrf-token": login.json().data.csrfToken } }); + const authorizationUrl = new URL(linkStart.json().data.authorizationUrl); + const state = authorizationUrl.searchParams.get("state")!; + const nonce = authorizationUrl.searchParams.get("nonce")!; + const tenantId = "11111111-1111-4111-8111-111111111111"; + const clientId = "22222222-2222-4222-8222-222222222222"; + const objectId = "33333333-3333-4333-8333-333333333333"; + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const publicJwk = { ...await exportJWK(publicKey), kid: "entra-test-key", alg: "RS256", use: "sig" }; + const idToken = await new SignJWT({ tid: tenantId, oid: objectId, nonce, name: "Entra Owner", preferred_username: "owner@example.com" }) + .setProtectedHeader({ alg: "RS256", kid: "entra-test-key" }) + .setIssuer(`https://login.microsoftonline.com/${tenantId}/v2.0`) + .setAudience(clientId) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + if (url.endsWith("/oauth2/v2.0/token")) return new Response(JSON.stringify({ id_token: idToken }), { status: 200, headers: { "content-type": "application/json" } }); + if (url.endsWith("/discovery/v2.0/keys")) return new Response(JSON.stringify({ keys: [publicJwk] }), { status: 200, headers: { "content-type": "application/json" } }); + return new Response("not found", { status: 404 }); + })); + try { + const callback = await app.inject({ method: "GET", url: `${apiPaths.auth.entraCallback}?code=one-time-code&state=${encodeURIComponent(state)}` }); + expect(callback.statusCode).toBe(302); + expect(callback.headers.location).toBe("https://projectbrain.example.com/settings"); + const entraCookie = callback.cookies.find((item) => item.name === "projectbrain_session"); + expect(entraCookie).toBeDefined(); + const identity = await prisma.externalIdentity.findUnique({ where: { provider_tenantId_subject: { provider: "microsoft-entra", tenantId, subject: objectId } } }); + expect(identity?.preferredUsername).toBe("owner@example.com"); + expect((await app.inject({ method: "GET", url: apiPaths.auth.session, headers: { cookie: `${entraCookie!.name}=${entraCookie!.value}` } })).statusCode).toBe(200); + expect((await app.inject({ method: "GET", url: `${apiPaths.auth.entraCallback}?code=replay&state=${encodeURIComponent(state)}` })).headers.location).toContain("expired_attempt"); + } finally { + vi.unstubAllGlobals(); + await app.close(); + } + }, 15_000); + + it("returns 403 until a member receives project access", async () => { + const app = await buildApp({ env: testEnv() }); + const member = await prisma.user.create({ data: { username: "database-member", passwordHash: await hashPassword("database-member-password"), role: "member" } }); + const session = await createSession(testEnv(), { id: member.id, username: member.username, role: "member", credentialVersion: member.credentialVersion }); + const cookie = `projectbrain_session=${app.signCookie(session.token)}`; + + const projectCreateDenied = await app.inject({ + method: "POST", + url: "/api/projects", + headers: { cookie, "x-csrf-token": session.csrfToken }, + payload: { name: "Unauthorized project", description: "A member must not provision a Gitea repository through project creation." }, + }); + expect(projectCreateDenied.statusCode).toBe(403); + expect(projectCreateDenied.json().error.code).toBe("PROJECT_CREATION_FORBIDDEN"); + + const denied = await app.inject({ method: "GET", url: "/api/projects/widefrog", headers: { cookie } }); + expect(denied.statusCode).toBe(403); + expect(denied.json().error.code).toBe("PROJECT_ACCESS_DENIED"); + + const project = await prisma.project.findUniqueOrThrow({ where: { slug: "widefrog" } }); + await prisma.projectMembership.create({ data: { projectId: project.id, userId: member.id, role: "reader" } }); + const allowed = await app.inject({ method: "GET", url: "/api/projects/widefrog", headers: { cookie } }); + expect(allowed.statusCode).toBe(200); + + const writeDenied = await app.inject({ method: "PATCH", url: "/api/projects/widefrog", headers: { cookie, "x-csrf-token": session.csrfToken }, payload: { name: "Forbidden update" } }); + expect(writeDenied.statusCode).toBe(403); + await app.close(); + }); + + it("replays a core-loop mutation durably after API restart", async () => { + const firstApp = await buildApp({ env: testEnv() }); + const login = await firstApp.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } }); + const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session"); + const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`; + const csrfToken = login.json().data.csrfToken as string; + const headers = { cookie, "x-csrf-token": csrfToken, "idempotency-key": "database-restart-replay" }; + const payload = { type: "project update", title: "Durable idempotency restart proof", summary: "This entry must be created once across API restarts.", source: "database-test" }; + + const created = await firstApp.inject({ method: "POST", url: "/api/projects/widefrog/operating-log", headers, payload }); + expect(created.statusCode).toBe(201); + await firstApp.close(); + + const restartedApp = await buildApp({ env: testEnv() }); + const replayed = await restartedApp.inject({ method: "POST", url: "/api/projects/widefrog/operating-log", headers, payload }); + expect(replayed.statusCode).toBe(201); + expect(replayed.json().data.id).toBe(created.json().data.id); + expect(await prisma.operatingLogEntry.count({ where: { title: payload.title } })).toBe(1); + expect(await prisma.idempotencyRecord.count({ where: { key: { contains: "database-restart-replay" } } })).toBe(1); + await restartedApp.close(); + }, 15_000); + + it("executes the complete governed core loop in PostgreSQL mode", async () => { + const app = await buildApp({ env: testEnv() }); + const login = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "database-owner", password: ownerPassword } }); + const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session"); + const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`; + const csrfToken = login.json().data.csrfToken as string; + let operation = 0; + const mutate = (method: "POST" | "PATCH", url: string, payload: Record = {}) => app.inject({ + method, url, payload, + headers: { cookie, "x-csrf-token": csrfToken, "idempotency-key": `core-loop-database-proof-${++operation}` }, + }); + + const capture = await mutate("POST", "/api/projects/widefrog/capture-items", { type: "note", content: "Core-loop database proof source material", source: "database-integration-test" }); + expect(capture.statusCode).toBe(201); + coreLoopIds.capture.push(capture.json().data.id); + + expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${capture.json().data.id}/classify`)).statusCode).toBe(200); + const queued = await mutate("POST", `/api/projects/widefrog/capture-items/${capture.json().data.id}/send-to-review`); + expect(queued.statusCode).toBe(201); + const reviewId = queued.json().data.createdEntityId as string; + coreLoopIds.review.push(reviewId); + + const concurrentReviewDecisions = await Promise.all([ + mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`, { reason: "Accepted by database core-loop proof A" }), + mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`, { reason: "Accepted by database core-loop proof B" }), + ]); + expect(concurrentReviewDecisions.map((response) => response.statusCode).sort()).toEqual([200, 409]); + expect(concurrentReviewDecisions.find((response) => response.statusCode === 409)!.json().error.code).toBe("REVIEW_ALREADY_DECIDED"); + const materialized = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/materialize-truth`); + expect(materialized.statusCode).toBe(200); + const truthId = materialized.json().data.createdEntityId as string; + coreLoopIds.truth.push(truthId); + + const conversionCapture = await mutate("POST", "/api/projects/widefrog/capture-items", { type: "note", content: "Convert this reviewed decision into a durable truth artifact", source: "database-conversion-test" }); + expect(conversionCapture.statusCode).toBe(201); + coreLoopIds.capture.push(conversionCapture.json().data.id); + expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${conversionCapture.json().data.id}/classify`)).statusCode).toBe(200); + const conversionQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${conversionCapture.json().data.id}/send-to-review`); + expect(conversionQueue.statusCode).toBe(201); + const conversionReviewId = conversionQueue.json().data.createdEntityId as string; + coreLoopIds.review.push(conversionReviewId); + const converted = await mutate("POST", `/api/projects/widefrog/review-queue/${conversionReviewId}/convert`, { target: "decision", reason: "Database artifact proof" }); + expect(converted.statusCode).toBe(200); + const convertedTruthId = converted.json().data.createdEntityId as string; + const convertedTruth = await prisma.acceptedTruthItem.findFirst({ where: { slug: convertedTruthId } }); + expect(convertedTruth).toMatchObject({ kind: "decision", status: "converted" }); + coreLoopIds.truth.push(convertedTruth!.id); + const duplicateConversion = await mutate("POST", `/api/projects/widefrog/review-queue/${conversionReviewId}/convert`, { target: "decision" }); + expect(duplicateConversion.statusCode).toBe(409); + expect(duplicateConversion.json().error.code).toBe("REVIEW_ALREADY_DECIDED"); + + const prepared = await mutate("POST", `/api/projects/widefrog/accepted-truth/${truthId}/project-updates`); + expect(prepared.statusCode).toBe(200); + const updateId = prepared.json().data.createdEntityId as string; + coreLoopIds.update.push(updateId); + const concurrentUpdateApplications = await Promise.all([ + mutate("PATCH", `/api/projects/widefrog/project-updates/${updateId}/apply`), + mutate("PATCH", `/api/projects/widefrog/project-updates/${updateId}/apply`), + ]); + expect(concurrentUpdateApplications.map((response) => response.statusCode).sort()).toEqual([200, 409]); + expect(concurrentUpdateApplications.find((response) => response.statusCode === 409)!.json().error.code).toBe("PROJECT_UPDATE_ALREADY_APPLIED"); + + const handoff = await mutate("POST", "/api/projects/widefrog/handoff-bundles", { target: "codex-task", title: "Core-loop database proof handoff", includeAcceptedTruth: true, includeProjectUpdates: true, includeOperatingLog: true, autoSelectContext: true }); + expect(handoff.statusCode).toBe(201); + coreLoopIds.handoff.push(handoff.json().data.id); + expect(handoff.json().data.sourceTruthItemIds).toContain(truthId); + + const task = await mutate("POST", `/api/projects/widefrog/handoff-bundles/${handoff.json().data.id}/codex-task`, { goal: "Execute the database core-loop proof", validationCommands: ["pnpm validate:all"] }); + expect(task.statusCode).toBe(201); + coreLoopIds.task.push(task.json().data.id); + + const imported = await mutate("POST", `/api/projects/widefrog/codex-tasks/${task.json().data.id}/import-output`, { sourceRun: "core-loop-database-proof", summary: "Execution feedback returned to governance.", outputText: "Changed files: none. Validation passed. Review this evidence." }); + expect(imported.statusCode).toBe(201); + coreLoopIds.runImport.push(imported.json().data.importDto.id); + coreLoopIds.review.push(imported.json().data.mutation.createdEntityId); + + const publicId = (value: string) => ({ OR: [{ id: value }, { slug: value }, { readableId: value }] }); + expect(await prisma.captureItem.count({ where: publicId(capture.json().data.id) })).toBe(1); + expect(await prisma.acceptedTruthItem.count({ where: { AND: [publicId(truthId), { status: "accepted" }] } })).toBe(1); + expect(await prisma.projectUpdate.count({ where: { AND: [publicId(updateId), { status: "applied" }] } })).toBe(1); + expect(await prisma.codexTask.count({ where: { AND: [publicId(task.json().data.id), { status: "imported" }] } })).toBe(1); + expect(await prisma.reviewQueueItem.count({ where: publicId(imported.json().data.mutation.createdEntityId) })).toBe(1); + await app.close(); + }, 15_000); +}); diff --git a/backend/test/database-autonomy.test.ts b/backend/test/database-autonomy.test.ts new file mode 100644 index 0000000..515a987 --- /dev/null +++ b/backend/test/database-autonomy.test.ts @@ -0,0 +1,165 @@ +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "../src/db/prisma.js"; +import { configureAutonomy, getAutonomyState, importAutonomyRoadmap, reportAutonomyWork } from "../src/repositories/autonomyRepository.js"; +import { claimAutonomyWork } from "../src/services/autonomyRuntimeControl.js"; +import { createOrResumeAutonomyDelivery } from "../src/services/autonomyDelivery.js"; +import { createFeatureDelivery, getFeatureDelivery, openFeatureDeliveryPullRequest, recordFeatureDeliveryActionValidation, recordFeatureDeliveryProgress, startFeatureDelivery } from "../src/repositories/featureDeliveryRepository.js"; +import { refreshDeliveryFromGitea } from "../src/services/deliveryReconciliation.js"; +import { createProject } from "../src/repositories/projectsRepository.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createProjectBrainMcpServer } from "../src/mcp/server.js"; +import { loadEnv } from "../src/config/env.js"; +import { GiteaApiError, GiteaClient } from "../src/integrations/gitea/client.js"; + +const databaseUrl = process.env.TEST_DATABASE_URL; +if (databaseUrl) process.env.DATABASE_URL = databaseUrl; +const databaseDescribe = databaseUrl ? describe : describe.skip; +const slugs: string[] = []; + +databaseDescribe("autonomy database write isolation", () => { + it("preserves progress and the active lease when the same roadmap is imported again", async () => { + const slug = `autonomy-import-${Date.now()}`; + slugs.push(slug); + await createProject({ id: slug, name: "Roadmap import proof", description: "Isolated database regression" }); + await configureAutonomy(slug, { enabled: true, autoBrainstorm: false }, "database-test"); + const markdown = "## Build\n- Implement feature"; + await importAutonomyRoadmap(slug, { markdown }, "database-test"); + const claim = await claimAutonomyWork(slug, "database-test"); + const repeated = await importAutonomyRoadmap(slug, { markdown: `\n${markdown}\n` }, "database-test"); + expect(repeated.roadmap).toEqual(claim.state.roadmap); + expect(repeated.activeLease).toEqual(claim.state.activeLease); + await expect(importAutonomyRoadmap(slug, { markdown: "## Replacement\n- New task" }, "database-test")).rejects.toThrow("AUTO_MODE_WORK_ACTIVE"); + expect((await getAutonomyState(slug)).activeLease?.token).toBe(claim.work!.token); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(async () => { + if (slugs.length) { + await prisma.deliveryPlan.deleteMany({ where: { project: { slug: { in: slugs } } } }); + await prisma.project.deleteMany({ where: { slug: { in: slugs } } }); + } + await prisma.$disconnect(); + }); + + it("upgrades legacy autonomy state and preserves unrelated workspace notes", async () => { + const slug = `autonomy-legacy-${Date.now()}`; + slugs.push(slug); + await createProject({ id: slug, name: "Autonomy legacy proof", description: "Isolated database regression" }); + const project = await prisma.project.findUniqueOrThrow({ where: { slug } }); + await prisma.workspaceState.update({ where: { projectId: project.id }, data: { notesJson: { autonomy: {}, unrelated: { value: "preserve me" } } } }); + const state = await configureAutonomy(slug, { enabled: true }, "database-test"); + expect(state.enabled).toBe(true); + const workspace = await prisma.workspaceState.findUniqueOrThrow({ where: { projectId: project.id } }); + expect(workspace.notesJson).toMatchObject({ unrelated: { value: "preserve me" }, autonomy: { enabled: true } }); + }); + + it("advances the CAS version even when the clock does not advance", async () => { + const slug = `autonomy-clock-${Date.now()}`; + slugs.push(slug); + await createProject({ id: slug, name: "Autonomy clock proof", description: "Isolated database regression" }); + const first = await configureAutonomy(slug, { enabled: true }, "database-test"); + vi.spyOn(Date, "now").mockReturnValue(Date.parse(first.updatedAt)); + const second = await configureAutonomy(slug, { enabled: false }, "database-test"); + expect(Date.parse(second.updatedAt)).toBeGreaterThan(Date.parse(first.updatedAt)); + expect(second.enabled).toBe(false); + }); + + it("resumes a failed implementation and executes its existing repair prerequisite first", async () => { + const slug = `autonomy-repair-${Date.now()}`; + slugs.push(slug); + await createProject({ id: slug, name: "Autonomy repair proof", description: "Isolated database regression" }); + await configureAutonomy(slug, { enabled: true, autoBrainstorm: false }, "database-test"); + await importAutonomyRoadmap(slug, { markdown: "## CI\n- Add gate\n- Fix source lint" }, "database-test"); + const first = await claimAutonomyWork(slug, "database-test", "repair-run"); + const report = await reportAutonomyWork(slug, { token: first.work!.token, outcome: "blocked", summary: "CI failed; no approval is required.", discoveredTasks: ["Fix source lint"] }, "database-test"); + expect(report.state.status).toBe("running"); + expect(report.addedTasks).toEqual([]); + const repair = await claimAutonomyWork(slug, "database-test", "repair-run"); + expect(repair.work!.itemTitle).toBe("Fix source lint"); + await expect(reportAutonomyWork(slug, { token: repair.work!.token, outcome: "success", summary: "Unproven success" }, "database-test")).rejects.toThrow("AUTO_MODE_DELIVERY_REQUIRED"); + expect((await getAutonomyState(slug)).activeLease?.token).toBe(repair.work!.token); + }); + + it("retains one delivery across retries, repairs an open PR and invalidates stale evidence", async () => { + const slug = `autonomy-delivery-${Date.now()}`; + slugs.push(slug); + await createProject({ id: slug, name: "Delivery repair proof", description: "Isolated database regression" }); + const project = await prisma.project.findUniqueOrThrow({ where: { slug } }); + await prisma.giteaRepositoryLink.create({ data: { projectId: project.id, owner: "AutonomyProof", repository: slug, defaultBranch: "main", linkedBy: "database-test" } }); + await configureAutonomy(slug, { enabled: true, autoBrainstorm: false }, "database-test"); + await importAutonomyRoadmap(slug, { markdown: "## Build\n- Implement feature" }, "database-test"); + const claim = await claimAutonomyWork(slug, "database-test", "delivery-run"); + const plan = { title: "Implement feature", intent: "Complete feature", problem: "Broken flow", desiredOutcome: "Working flow", goals: ["repair"], acceptanceCriteria: ["tests pass"], implementationPlan: ["implement", "validate", "merge"], validationCommands: ["pnpm test"], riskLevel: "low" as const }; + const delivery = await createOrResumeAutonomyDelivery(slug, plan, "database-test"); + expect((await createOrResumeAutonomyDelivery(slug, { ...plan, title: "Unnecessary replacement" }, "database-test")).id).toBe(delivery.id); + await startFeatureDelivery(slug, delivery.id, "chatgpt/existing", "database-test"); + await reportAutonomyWork(slug, { token: claim.work!.token, outcome: "failed", summary: "CI failed on existing branch" }, "database-test"); + const resumed = await claimAutonomyWork(slug, "database-test", "delivery-run"); + expect(resumed.work!.deliveryId).toBe(delivery.id); + const evidence = { provider: "gitea_actions" as const, runId: 1, runNumber: 1, url: "https://gitea.example.com/runs/1", workflowPath: ".gitea/workflows/ci.yml", headSha: "a".repeat(40), conclusion: "success" as const, verifiedAt: new Date().toISOString() }; + await recordFeatureDeliveryActionValidation(slug, delivery.id, evidence, "database-test"); + await openFeatureDeliveryPullRequest(slug, delivery.id, { number: 1, url: "https://gitea.example.com/pulls/1", title: "Feature" }, "database-test"); + await expect(reportAutonomyWork(slug, { token: resumed.work!.token, outcome: "success", summary: "PR opened" }, "database-test")).rejects.toThrow("AUTO_MODE_DELIVERY_NOT_MERGED"); + expect((await getAutonomyState(slug)).activeLease?.token).toBe(resumed.work!.token); + const head = "b".repeat(40); + let remoteMerged = false; + vi.spyOn(GiteaClient.prototype, "getPullRequest").mockImplementation(async () => ({ number: 1, title: "Feature", html_url: "https://gitea.example.com/pulls/1", state: remoteMerged ? "closed" : "open", merged: remoteMerged, merge_commit_sha: remoteMerged ? "c".repeat(40) : undefined, head: { ref: "chatgpt/existing", sha: head }, base: { ref: "main", sha: "d".repeat(40) }, mergeable: true })); + vi.spyOn(GiteaClient.prototype, "getBranch").mockResolvedValue({ name: "chatgpt/existing", commit: { id: head, message: "Repair" }, protected: false }); + vi.spyOn(GiteaClient.prototype, "getContents").mockResolvedValue({ type: "file", path: "src/feature.ts", name: "feature.ts", sha: "file-sha", size: 4, encoding: "base64", content: "dGVzdA==" }); + const write = vi.spyOn(GiteaClient.prototype, "updateFile").mockResolvedValue({}); + vi.spyOn(GiteaClient.prototype, "listActionRuns").mockResolvedValue({ total_count: 1, workflow_runs: [{ id: 2, run_number: 2, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "pull_request", head_sha: head, head_branch: "chatgpt/existing", status: "completed", conclusion: "success", html_url: "https://gitea.example.com/runs/2" }] }); + vi.spyOn(GiteaClient.prototype, "getBranchProtection").mockResolvedValue({ enable_status_check: true, status_check_contexts: ["CI / test"] }); + vi.spyOn(GiteaClient.prototype, "getCombinedCommitStatus").mockResolvedValue({ state: "success", statuses: [{ context: "CI / test", status: "success" }] }); + const merge = vi.spyOn(GiteaClient.prototype, "mergePullRequest").mockImplementation(async () => { remoteMerged = true; }); + const server = createProjectBrainMcpServer(loadEnv({ NODE_ENV: "test", DATABASE_URL: databaseUrl, PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "database-proof-password", PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", PROJECTBRAIN_GITEA_TOKEN: "test-gitea-token-with-at-least-twenty-characters" }), "database-test"); + const client = new Client({ name: "delivery-recovery-proof", version: "1" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const written = await client.callTool({ name: "write_feature_file", arguments: { project_id: slug, delivery_id: delivery.id, branch: "chatgpt/existing", path: "src/feature.ts", content: "fixed", message: "Repair failing validation", expected_sha: "file-sha" } }); + expect(written.isError, JSON.stringify(written.content)).not.toBe(true); + expect(write).toHaveBeenCalledTimes(1); + const repaired = await getFeatureDelivery(slug, delivery.id); + expect(repaired.spec.status).toBe("pull_request_open"); + expect(repaired.spec.pullRequest?.number).toBe(1); + expect(repaired.spec.validationEvidence).toBeUndefined(); + expect(repaired.spec.validationResults).toEqual([]); + const synced = await client.callTool({ name: "sync_feature_validation", arguments: { project_id: slug, delivery_id: delivery.id } }); + expect(synced.isError, JSON.stringify(synced.content)).not.toBe(true); + const merged = await client.callTool({ name: "merge_feature_delivery", arguments: { project_id: slug, delivery_id: delivery.id, expected_head_sha: head, delete_branch: false } }); + expect(merged.isError, JSON.stringify(merged.content)).not.toBe(true); + expect(merge).toHaveBeenCalledTimes(1); + const dbPlan = await prisma.deliveryPlan.findFirstOrThrow({ where: { projectId: project.id, slug: delivery.id } }); + // Simulate a previous response/webhook interruption after Gitea merged. + await prisma.deliveryPlan.update({ where: { id: dbPlan.id }, data: { status: "pull_request_open" } }); + const replay = await client.callTool({ name: "merge_feature_delivery", arguments: { project_id: slug, delivery_id: delivery.id, expected_head_sha: head, delete_branch: false } }); + expect(replay.isError, JSON.stringify(replay.content)).not.toBe(true); + expect(merge).toHaveBeenCalledTimes(1); + await client.close(); + await server.close(); + const completed = await reportAutonomyWork(slug, { token: resumed.work!.token, outcome: "success", summary: "Validated branch merged" }, "database-test"); + expect(completed.state.roadmap!.phases[0].items[0].status).toBe("completed"); + }); + + it("retires a removed branch only after proving its recorded work is included in main", async () => { + const slug = `autonomy-integrated-${Date.now()}`; + slugs.push(slug); + await createProject({ id: slug, name: "Integration proof", description: "Isolated database regression" }); + const project = await prisma.project.findUniqueOrThrow({ where: { slug } }); + await prisma.giteaRepositoryLink.create({ data: { projectId: project.id, owner: "AutonomyProof", repository: slug, defaultBranch: "main", linkedBy: "database-test" } }); + const delivery = await createFeatureDelivery(slug, { title: "Integrated work", intent: "Repair", problem: "Bug", desiredOutcome: "Fixed", goals: ["fix"], acceptanceCriteria: ["verified"], implementationPlan: ["fix"], validationCommands: ["test"], riskLevel: "low" }, "database-test"); + await startFeatureDelivery(slug, delivery.id, "chatgpt/integrated", "database-test"); + await recordFeatureDeliveryProgress(slug, delivery.id, { headSha: "a".repeat(40) }, "database-test"); + vi.spyOn(GiteaClient.prototype, "getBranch").mockImplementation(async (_owner, _repository, branch) => { + if (branch !== "main") throw new GiteaApiError(404, "GITEA_HTTP_404", "Branch removed"); + return { name: "main", commit: { id: "b".repeat(40) } }; + }); + const compare = vi.spyOn(GiteaClient.prototype, "compareCommits").mockResolvedValueOnce({ total_commits: 1 }).mockResolvedValueOnce({ total_commits: 0 }); + const client = new GiteaClient("https://gitea.example.com", "test-token"); + expect((await refreshDeliveryFromGitea(client, slug, delivery.id)).spec.status).toBe("executing"); + const integrated = await refreshDeliveryFromGitea(client, slug, delivery.id); + expect(integrated.spec.status).toBe("superseded"); + expect(integrated.spec.validationEvidence).toBeUndefined(); + expect(compare).toHaveBeenLastCalledWith("AutonomyProof", slug, "b".repeat(40), "a".repeat(40)); + }); +}); diff --git a/backend/test/database-repository-sync.test.ts b/backend/test/database-repository-sync.test.ts new file mode 100644 index 0000000..7357494 --- /dev/null +++ b/backend/test/database-repository-sync.test.ts @@ -0,0 +1,152 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import { prisma } from "../src/db/prisma.js"; +import { createProject } from "../src/repositories/projectsRepository.js"; +import { reconcilePortfolioRepositories } from "../src/services/portfolioControlPlane.js"; +import { PROJECT_REPOSITORY_PROVISION_ACTION, provisionProjectRepository } from "../src/services/projectRepositoryProvisioning.js"; + +const databaseUrl = process.env.TEST_DATABASE_URL; +if (databaseUrl) process.env.DATABASE_URL = databaseUrl; +const databaseDescribe = databaseUrl ? describe : describe.skip; +const owner = "ProjectBrainSyncProof"; +const createdProjectSlugs: string[] = []; + +const env = () => loadEnv({ + NODE_ENV: "test", + LOG_LEVEL: "silent", + DATABASE_URL: databaseUrl, + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "database-owner", + PROJECTBRAIN_OWNER_PASSWORD: "database-owner-password-value", + PROJECTBRAIN_AUTH_SECRET: "database-test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", + PROJECTBRAIN_GITEA_TOKEN: "database-gitea-token-with-twenty-characters", + PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: "true", + PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: "60", +}); + +const repositoryPayload = (name: string) => ({ + id: name.length * 100, + name, + full_name: `${owner}/${name}`, + description: `Repository sync proof for ${name}`, + private: true, + archived: false, + default_branch: "main", + html_url: `https://gitea.example.com/${owner}/${name}`, + updated_at: "2026-09-02T00:00:00Z", + owner: { login: owner }, + permissions: { admin: true, push: true, pull: true }, +}); + +const removeProofData = async () => { + await prisma.portfolioRepository.deleteMany({ where: { owner } }); + if (createdProjectSlugs.length) await prisma.project.deleteMany({ where: { slug: { in: createdProjectSlugs } } }); +}; + +databaseDescribe("bidirectional ProjectBrain and Gitea repository sync", () => { + beforeAll(removeProofData); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + afterAll(async () => { + await removeProofData(); + await prisma.$disconnect(); + }); + + it("provisions and links one initialized Gitea repository for a new ProjectBrain project", async () => { + const slug = `pb-to-gitea-${Date.now().toString(36)}`; + createdProjectSlugs.push(slug); + await createProject({ + id: slug, + name: "ProjectBrain to Gitea proof", + description: "Verifies durable repository provisioning from a ProjectBrain project.", + primaryGoal: "Create exactly one linked repository", + }); + const remote = repositoryPayload(slug); + let repositoryExists = false; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url.endsWith("/api/v1/user")) return new Response(JSON.stringify({ id: 1, login: owner }), { status: 200, headers: { "content-type": "application/json" } }); + if (url.endsWith("/api/v1/user/repos") && init?.method === "POST") { + repositoryExists = true; + return new Response(JSON.stringify(remote), { status: 201, headers: { "content-type": "application/json" } }); + } + if (url.endsWith(`/api/v1/repos/${owner}/${slug}`) && repositoryExists) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetcher); + + const first = await provisionProjectRepository(env(), slug, "database:provisioning-proof"); + const second = await provisionProjectRepository(env(), slug, "database:provisioning-proof"); + + expect(first).toMatchObject({ status: "ready", created: true, repository: { fullName: `${owner}/${slug}`, isPrimary: true } }); + expect(second).toMatchObject({ status: "ready", created: false, repository: { fullName: `${owner}/${slug}` } }); + expect(fetcher.mock.calls.filter(([input, init]) => String(input).endsWith("/api/v1/user/repos") && init?.method === "POST")).toHaveLength(1); + const project = await prisma.project.findUniqueOrThrow({ where: { slug } }); + expect(await prisma.giteaRepositoryLink.count({ where: { projectId: project.id } })).toBe(1); + expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "succeeded" } })).toBe(1); + expect(await prisma.portfolioRepository.findUnique({ where: { owner_repository: { owner, repository: slug } } })).toMatchObject({ projectId: project.id, status: "onboarded" }); + }, 15_000); + + it("discovers a new Gitea repository and creates its linked ProjectBrain project", async () => { + const name = `gitea-to-pb-${Date.now().toString(36)}`; + createdProjectSlugs.push(name); + const remote = repositoryPayload(name); + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + if (url.includes("/api/v1/user/repos")) { + const page = new URL(url).searchParams.get("page"); + return new Response(JSON.stringify(page === "1" ? [remote] : []), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url.endsWith(`/api/v1/repos/${owner}/${name}`)) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } }); + })); + + const reconciliation = await reconcilePortfolioRepositories(env(), "database:discovery-proof", { + dryRun: false, + repositoryFullNames: [`${owner}/${name}`], + }); + + expect(reconciliation).toMatchObject({ candidateCount: 1, onboardedCount: 1, failedCount: 0 }); + const project = await prisma.project.findUniqueOrThrow({ where: { slug: name } }); + expect(await prisma.giteaRepositoryLink.findUnique({ where: { owner_repository: { owner, repository: name } } })).toMatchObject({ projectId: project.id, syncStatus: "ready" }); + expect(await prisma.portfolioRepository.findUnique({ where: { owner_repository: { owner, repository: name } } })).toMatchObject({ projectId: project.id, status: "onboarded" }); + }, 15_000); + + it("resumes an ambiguous create without issuing a duplicate repository mutation", async () => { + const slug = `pb-retry-${Date.now().toString(36)}`; + createdProjectSlugs.push(slug); + await createProject({ + id: slug, + name: "ProjectBrain repository retry proof", + description: "Verifies that an uncertain Gitea create is resumed without a duplicate repository.", + primaryGoal: "Resume one durable provisioning operation", + }); + const remote = repositoryPayload(slug); + let repositoryExists = false; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (url.endsWith("/api/v1/user")) return new Response(JSON.stringify({ id: 1, login: owner }), { status: 200, headers: { "content-type": "application/json" } }); + if (url.endsWith("/api/v1/user/repos") && init?.method === "POST") { + repositoryExists = true; + return new Response(JSON.stringify({ message: "upstream result unknown" }), { status: 503, headers: { "content-type": "application/json" } }); + } + if (url.endsWith(`/api/v1/repos/${owner}/${slug}`) && repositoryExists) return new Response(JSON.stringify(remote), { status: 200, headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify({ message: "not found" }), { status: 404, headers: { "content-type": "application/json" } }); + }); + vi.stubGlobal("fetch", fetcher); + + await expect(provisionProjectRepository(env(), slug, "database:retry-proof")).rejects.toMatchObject({ status: 503, code: "GITEA_HTTP_503" }); + const resumed = await provisionProjectRepository(env(), slug, "database:retry-proof"); + + expect(resumed).toMatchObject({ status: "ready", created: false, repository: { fullName: `${owner}/${slug}` } }); + expect(fetcher.mock.calls.filter(([input, init]) => String(input).endsWith("/api/v1/user/repos") && init?.method === "POST")).toHaveLength(1); + const project = await prisma.project.findUniqueOrThrow({ where: { slug } }); + expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "failed" } })).toBe(1); + expect(await prisma.externalOperation.count({ where: { projectId: project.id, action: PROJECT_REPOSITORY_PROVISION_ACTION, status: "succeeded" } })).toBe(1); + expect(await prisma.giteaRepositoryLink.count({ where: { projectId: project.id } })).toBe(1); + }, 15_000); +}); diff --git a/backend/test/delivery-pullrequest-reconciliation.test.ts b/backend/test/delivery-pullrequest-reconciliation.test.ts new file mode 100644 index 0000000..1e170d8 --- /dev/null +++ b/backend/test/delivery-pullrequest-reconciliation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { findReusableDeliveryPullRequest } from "../src/integrations/gitea/policy.js"; +import type { GiteaPullRequest } from "../src/integrations/gitea/types.js"; + +const head = "a".repeat(40); +const merged: GiteaPullRequest = { + id: 31, number: 31, title: "M18 evidence", state: "closed", merged: true, + html_url: "https://example.test/pulls/31", created_at: "2026-09-07T00:00:00Z", updated_at: "2026-09-08T00:00:00Z", + merge_commit_sha: "b".repeat(40), head: { ref: "chatgpt/evidence", sha: head }, base: { ref: "main", sha: "b".repeat(40) }, +}; +const select = (prs: GiteaPullRequest[]) => findReusableDeliveryPullRequest(prs, "chatgpt/evidence", "main", head); + +describe("existing delivery PR reconciliation", () => { + it("adopts the exact already-merged PR instead of opening an empty replacement", () => { + expect(select([merged])).toBe(merged); + }); + it("reuses an exact open review", () => { + const open = { ...merged, merged: false, state: "open", merge_commit_sha: undefined }; + expect(select([open])).toBe(open); + }); + it.each([ + { ...merged, head: { ...merged.head, sha: "c".repeat(40) } }, + { ...merged, head: { ...merged.head, ref: "chatgpt/other" } }, + { ...merged, base: { ...merged.base, ref: "other" } }, + { ...merged, merged: false }, + ])("does not adopt stale, foreign or abandoned reviews", (pr) => { + expect(select([pr])).toBeUndefined(); + }); + it("rejects ambiguous matching reviews", () => { + expect(() => select([merged, { ...merged, id: 32, number: 32 }])).toThrow("AMBIGUOUS"); + }); + it("requires actual merge commit evidence", () => { + expect(() => select([{ ...merged, merge_commit_sha: undefined }])).toThrow("MERGE_EVIDENCE_REQUIRED"); + }); +}); diff --git a/backend/test/deliveryLifecycle.test.ts b/backend/test/deliveryLifecycle.test.ts new file mode 100644 index 0000000..750c42b --- /dev/null +++ b/backend/test/deliveryLifecycle.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { assertFeatureDeliveryTransition, canTransitionFeatureDeliveryStatus } from "../src/repositories/featureDeliveryRepository.js"; + +describe("feature delivery lifecycle invariants", () => { + it("allows only forward operational progress and explicit repair paths", () => { + expect(canTransitionFeatureDeliveryStatus("executing", "validating")).toBe(true); + expect(canTransitionFeatureDeliveryStatus("pull_request_open", "merged")).toBe(true); + expect(canTransitionFeatureDeliveryStatus("merged", "verified")).toBe(true); + expect(canTransitionFeatureDeliveryStatus("verified", "released")).toBe(true); + expect(canTransitionFeatureDeliveryStatus("released", "executing")).toBe(false); + expect(canTransitionFeatureDeliveryStatus("verified", "awaiting_approval")).toBe(false); + expect(canTransitionFeatureDeliveryStatus("superseded", "approved")).toBe(false); + }); + + it("rejects a transition that would reactivate superseded or released work", () => { + expect(() => assertFeatureDeliveryTransition("superseded", "executing")).toThrow("FEATURE_DELIVERY_INVALID_TRANSITION"); + expect(() => assertFeatureDeliveryTransition("released", "approved")).toThrow("FEATURE_DELIVERY_INVALID_TRANSITION"); + }); +}); diff --git a/backend/test/entra-oidc.test.ts b/backend/test/entra-oidc.test.ts new file mode 100644 index 0000000..af979db --- /dev/null +++ b/backend/test/entra-oidc.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; + +const mocks = vi.hoisted(() => ({ + oidcLoginAttempt: { deleteMany: vi.fn(), create: vi.fn(), findUnique: vi.fn(), delete: vi.fn() }, + externalIdentity: { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn(), deleteMany: vi.fn() }, + user: { update: vi.fn(), findUniqueOrThrow: vi.fn() }, + transaction: vi.fn(async (input: unknown) => Array.isArray(input) ? Promise.all(input) : undefined), +})); + +vi.mock("../src/db/prisma.js", () => ({ prisma: { oidcLoginAttempt: mocks.oidcLoginAttempt, externalIdentity: mocks.externalIdentity, user: mocks.user, $transaction: mocks.transaction } })); +const { oidcLoginAttempt, externalIdentity, user } = mocks; + +import { loadEnv } from "../src/config/env.js"; +import { consumeEntraAttempt, createEntraAuthorization, exchangeEntraCode, getEntraLinkStatus, isEntraConfigured, linkEntraIdentity, resolveEntraUser, unlinkEntraIdentity } from "../src/security/entraOidc.js"; + +const tenantId = "11111111-1111-4111-8111-111111111111"; +const clientId = "22222222-2222-4222-8222-222222222222"; +const baseInput = { + NODE_ENV: "test", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", + PROJECTBRAIN_ENTRA_TENANT_ID: tenantId, PROJECTBRAIN_ENTRA_CLIENT_ID: clientId, PROJECTBRAIN_ENTRA_CLIENT_SECRET: "unit-test-client-secret", +}; +const env = () => loadEnv(baseInput); +const profile = { tenantId, subject: "33333333-3333-4333-8333-333333333333", displayName: "Entra Owner", preferredUsername: "owner@example.com" }; +const storedUser = { id: "owner-id", username: "owner", role: "owner" as const, credentialVersion: 1, disabledAt: null }; + +beforeEach(() => { + vi.clearAllMocks(); + oidcLoginAttempt.deleteMany.mockResolvedValue({ count: 0 }); + oidcLoginAttempt.create.mockResolvedValue({ id: "attempt-id" }); + oidcLoginAttempt.delete.mockResolvedValue({ id: "attempt-id" }); + externalIdentity.update.mockResolvedValue({ id: "identity-id" }); + externalIdentity.upsert.mockResolvedValue({ id: "identity-id" }); + externalIdentity.deleteMany.mockResolvedValue({ count: 1 }); + user.update.mockResolvedValue(storedUser); + user.findUniqueOrThrow.mockResolvedValue(storedUser); +}); + +describe("Microsoft Entra OIDC service", () => { + it("creates a tenant-bound authorization URL with PKCE and stores only hashed state", async () => { + expect(isEntraConfigured(env())).toBe(true); + expect(isEntraConfigured(loadEnv({ ...baseInput, PROJECTBRAIN_ENTRA_TENANT_ID: undefined, PROJECTBRAIN_ENTRA_CLIENT_ID: undefined, PROJECTBRAIN_ENTRA_CLIENT_SECRET: undefined }))).toBe(false); + const authorization = new URL(await createEntraAuthorization(env(), { purpose: "login", returnTo: "//evil.example/path" })); + expect(authorization.hostname).toBe("login.microsoftonline.com"); + expect(authorization.pathname).toContain(tenantId); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + const rawState = authorization.searchParams.get("state")!; + const stored = oidcLoginAttempt.create.mock.calls[0][0].data; + expect(stored.stateHash).not.toBe(rawState); + expect(stored.stateHash).toMatch(/^[a-f0-9]{64}$/); + expect(stored.returnTo).toBe("/"); + await expect(createEntraAuthorization(env(), { purpose: "link" })).rejects.toThrow("ENTRA_LINK_USER_REQUIRED"); + }); + + it("consumes valid state exactly once and rejects missing or expired attempts", async () => { + oidcLoginAttempt.findUnique.mockResolvedValueOnce(null); + expect(await consumeEntraAttempt(env(), "missing-state")).toBeUndefined(); + oidcLoginAttempt.findUnique.mockResolvedValueOnce({ id: "expired", provider: "microsoft-entra", purpose: "login", userId: null, codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings", expiresAt: new Date(Date.now() - 1_000) }); + expect(await consumeEntraAttempt(env(), "expired-state")).toBeUndefined(); + oidcLoginAttempt.findUnique.mockResolvedValueOnce({ id: "valid", provider: "microsoft-entra", purpose: "link", userId: "owner-id", codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings", expiresAt: new Date(Date.now() + 60_000) }); + await expect(consumeEntraAttempt(env(), "valid-state")).resolves.toEqual({ purpose: "link", userId: "owner-id", codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings" }); + expect(oidcLoginAttempt.delete).toHaveBeenCalledTimes(2); + }); + + it("validates Microsoft signature, issuer, audience, tenant, nonce and oid", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const publicJwk = { ...await exportJWK(publicKey), kid: "unit-key", alg: "RS256", use: "sig" }; + const sign = (nonce: string, oid = profile.subject) => new SignJWT({ tid: tenantId, oid, nonce, name: profile.displayName, preferred_username: profile.preferredUsername }) + .setProtectedHeader({ alg: "RS256", kid: "unit-key" }).setIssuer(`https://login.microsoftonline.com/${tenantId}/v2.0`).setAudience(clientId).setIssuedAt().setExpirationTime("5m").sign(privateKey); + const idToken = await sign("expected-nonce"); + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + return url.endsWith("/token") + ? new Response(JSON.stringify({ id_token: idToken }), { status: 200, headers: { "content-type": "application/json" } }) + : new Response(JSON.stringify({ keys: [publicJwk] }), { status: 200, headers: { "content-type": "application/json" } }); + })); + try { + await expect(exchangeEntraCode(env(), "code", { purpose: "login", codeVerifier: "verifier", nonce: "expected-nonce", returnTo: "/" })).resolves.toEqual(profile); + await expect(exchangeEntraCode(env(), "code", { purpose: "login", codeVerifier: "verifier", nonce: "wrong-nonce", returnTo: "/" })).rejects.toThrow("ENTRA_NONCE_INVALID"); + } finally { + vi.unstubAllGlobals(); + } + }, 15_000); + + it("resolves, links, reports and unlinks only durable mapped identities", async () => { + externalIdentity.findUnique.mockResolvedValueOnce(null); + expect(await resolveEntraUser(profile)).toBeUndefined(); + externalIdentity.findUnique.mockResolvedValueOnce({ id: "identity-id", userId: storedUser.id, user: storedUser }); + await expect(resolveEntraUser(profile)).resolves.toEqual({ id: storedUser.id, username: "owner", role: "owner", credentialVersion: 1 }); + externalIdentity.findUnique.mockResolvedValueOnce({ id: "foreign", userId: "other-user" }); + await expect(linkEntraIdentity(storedUser.id, profile)).rejects.toThrow("ENTRA_IDENTITY_ALREADY_LINKED"); + externalIdentity.findUnique.mockResolvedValueOnce(null); + await expect(linkEntraIdentity(storedUser.id, profile)).resolves.toMatchObject({ id: storedUser.id }); + externalIdentity.findUnique.mockResolvedValueOnce({ displayName: profile.displayName, preferredUsername: profile.preferredUsername }); + await expect(getEntraLinkStatus(storedUser.id)).resolves.toEqual({ linked: true, displayName: profile.displayName, preferredUsername: profile.preferredUsername }); + await unlinkEntraIdentity(storedUser.id); + expect(externalIdentity.deleteMany).toHaveBeenCalledWith({ where: { provider: "microsoft-entra", userId: storedUser.id } }); + }); +}); diff --git a/backend/test/feature-delivery-policy.test.ts b/backend/test/feature-delivery-policy.test.ts new file mode 100644 index 0000000..71ca54a --- /dev/null +++ b/backend/test/feature-delivery-policy.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ApprovalState, DeliveryStatus, DeliveryValidationStatus } from "@prisma/client"; + +const mocks = vi.hoisted(() => { + const tx = { + deliveryPlan: { create: vi.fn(), update: vi.fn() }, + initiative: { update: vi.fn() }, + outboxEvent: { create: vi.fn() }, + deliveryRun: { create: vi.fn(), update: vi.fn() }, + deliveryValidation: { create: vi.fn(), deleteMany: vi.fn() }, + findingInvestigation: { findUnique: vi.fn(), update: vi.fn() }, + repositoryFinding: { update: vi.fn() }, + }; + const prisma = { + project: { findFirst: vi.fn() }, + initiative: { findFirstOrThrow: vi.fn() }, + deliveryPlan: { findFirst: vi.fn(), findMany: vi.fn() }, + giteaRepositoryLink: { findFirst: vi.fn() }, + deliveryRun: { findFirst: vi.fn() }, + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + }; + return { + prisma, + tx, + createInitiativeForProject: vi.fn(), + }; +}); + +vi.mock("../src/db/prisma.js", () => ({ prisma: mocks.prisma })); +vi.mock("../src/repositories/initiativesRepository.js", () => ({ createInitiativeForProject: mocks.createInitiativeForProject })); + +import { createFeatureDelivery, startFeatureDelivery, recordFeatureDeliveryActionValidation, reportedValidationResult, type FeatureDeliveryInput } from "../src/repositories/featureDeliveryRepository.js"; + +const baseInput = (riskLevel: FeatureDeliveryInput["riskLevel"]): FeatureDeliveryInput => ({ + title: "Intent-first onboarding", + intent: "Make onboarding understandable for nontechnical users.", + priority: "normal", + problem: "The current onboarding exposes too many technical concepts.", + desiredOutcome: "A short intent-first onboarding flow.", + users: ["new users"], + goals: ["reduce cognitive load"], + nonGoals: ["remove technical capabilities"], + acceptanceCriteria: ["normal users can create a project without understanding MCP or Git internals"], + implementationPlan: ["simplify the primary flow", "preserve technical drill-downs"], + risks: ["navigation changes can affect deep links"], + validationCommands: ["pnpm test"], + riskLevel, +}); + +const plan = (approvalState: ApprovalState, status: DeliveryStatus) => ({ + id: "delivery-db-1", + slug: "delivery-1", + projectId: "project-db-1", + initiativeId: "initiative-db-1", + title: "Intent-first onboarding", + intent: "Make onboarding understandable for nontechnical users.", + priority: "normal", + problem: "The current onboarding exposes too many technical concepts.", + desiredOutcome: "A short intent-first onboarding flow.", + usersJson: ["new users"], + goalsJson: ["reduce cognitive load"], + nonGoalsJson: [], + acceptanceCriteriaJson: ["normal users understand the flow"], + implementationPlanJson: ["simplify the primary flow"], + risksJson: ["navigation changes"], + validationCommandsJson: ["pnpm test"], + status, + approvalState, + approvedAt: approvalState === ApprovalState.approved ? new Date("2026-08-31T20:00:00.000Z") : null, + approvedBy: approvalState === ApprovalState.approved ? "chatgpt" : null, + reviewFeedback: null, + version: 1, + createdBy: "chatgpt", + updatedBy: "chatgpt", + createdAt: new Date("2026-08-31T20:00:00.000Z"), + updatedAt: new Date("2026-08-31T20:00:00.000Z"), + initiative: null, + runs: [], +}); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.prisma.project.findFirst.mockResolvedValue({ id: "project-db-1", slug: "projectbrain" }); + mocks.createInitiativeForProject.mockResolvedValue({ id: "initiative-1" }); + mocks.prisma.initiative.findFirstOrThrow.mockResolvedValue({ id: "initiative-db-1", slug: "initiative-1" }); + mocks.tx.initiative.update.mockResolvedValue({}); + mocks.tx.outboxEvent.create.mockResolvedValue({}); + mocks.tx.findingInvestigation.findUnique.mockResolvedValue(null); +}); + +describe("feature delivery risk authorization", () => { + it.each([ + ["low", "AUTO_ALLOWED"], + ["medium", "AUTO_ALLOWED_WITH_AUDIT"], + ] as const)("auto-authorizes %s reversible work", async (riskLevel, policyDecision) => { + const created = plan(ApprovalState.approved, DeliveryStatus.approved); + mocks.tx.deliveryPlan.create.mockResolvedValue(created); + + const result = await createFeatureDelivery("projectbrain", baseInput(riskLevel), "chatgpt"); + + expect(result.approvalState).toBe(ApprovalState.approved); + expect(result.spec.status).toBe(DeliveryStatus.approved); + expect(mocks.tx.deliveryPlan.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + approvalState: ApprovalState.approved, + status: DeliveryStatus.approved, + }), + })); + expect(mocks.tx.outboxEvent.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + eventType: "delivery.plan.auto_authorized", + payloadJson: expect.objectContaining({ riskLevel, policyDecision }), + }), + })); + }); + + it.each(["high", "destructive"] as const)("requires a human decision for %s work", async (riskLevel) => { + const created = plan(ApprovalState.pending, DeliveryStatus.awaiting_approval); + mocks.tx.deliveryPlan.create.mockResolvedValue(created); + + const result = await createFeatureDelivery("projectbrain", baseInput(riskLevel), "chatgpt"); + + expect(result.approvalState).toBe(ApprovalState.pending); + expect(result.spec.status).toBe(DeliveryStatus.awaiting_approval); + expect(mocks.tx.deliveryPlan.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + approvalState: ApprovalState.pending, + status: DeliveryStatus.awaiting_approval, + approvedAt: null, + approvedBy: null, + }), + })); + expect(mocks.tx.outboxEvent.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + eventType: "delivery.plan.user_decision_required", + payloadJson: expect.objectContaining({ riskLevel, policyDecision: "USER_DECISION_REQUIRED" }), + }), + })); + }); + + it("fails closed when a pending high-risk delivery tries to start repository execution", async () => { + mocks.prisma.deliveryPlan.findFirst.mockResolvedValue(plan(ApprovalState.pending, DeliveryStatus.awaiting_approval)); + + await expect(startFeatureDelivery("projectbrain", "delivery-1", "chatgpt/high-risk-change")) + .rejects.toThrow("FEATURE_DELIVERY_APPROVAL_REQUIRED"); + + expect(mocks.prisma.giteaRepositoryLink.findFirst).not.toHaveBeenCalled(); + expect(mocks.tx.deliveryRun.create).not.toHaveBeenCalled(); + }); +}); + +describe("Actions evidence scope", () => { + it("records only the observed workflow, preserving separately reported checks", async () => { + const current = { ...plan(ApprovalState.approved, DeliveryStatus.validating), + validationCommandsJson: ["dotnet test", "CI / build-test (pull_request)", "Live disposable provider smoke"], + runs: [{ id: "run-1", validations: [], changes: [] }] }; + mocks.prisma.deliveryPlan.findFirst.mockResolvedValue(current); + mocks.tx.deliveryPlan.update.mockResolvedValue(current); + await recordFeatureDeliveryActionValidation("projectbrain", "delivery-1", { + provider: "gitea_actions", runId: 9205, runNumber: 500, url: "https://example.test/actions/runs/9205", + workflowPath: "managed-validation.yml@refs/heads/chatgpt/work", headSha: "a".repeat(40), + conclusion: "success", verifiedAt: "2026-09-08T00:00:00Z", + }); + expect(mocks.tx.deliveryValidation.create).toHaveBeenCalledTimes(1); + expect(mocks.tx.deliveryValidation.create).toHaveBeenCalledWith({ data: expect.objectContaining({ + command: "Gitea Actions: managed-validation.yml@refs/heads/chatgpt/work", status: "passed", headSha: "a".repeat(40), + }) }); + expect(mocks.tx.deliveryValidation.deleteMany).toHaveBeenCalledWith({ where: { deliveryRunId: "run-1", provider: "gitea_actions" } }); + }); + + it.each(["CI / build-test (pull_request)", "Live provider smoke", "dotnet test"])("does not present legacy inferred %s as execution", (command) => { + expect(reportedValidationResult({ command, status: DeliveryValidationStatus.passed, provider: "gitea_actions", summary: "managed-validation.yml" })) + .toEqual({ command, status: "not_run", summary: expect.stringContaining("Legacy inferred result") }); + }); + + it("preserves explicit reports and directly observed workflow results", () => { + expect(reportedValidationResult({ command: "Live provider smoke", status: DeliveryValidationStatus.skipped, provider: null, summary: "Credentials unavailable" }).status).toBe("not_run"); + expect(reportedValidationResult({ command: "Local contract suite", status: DeliveryValidationStatus.failed, provider: null, summary: "Failed" }).status).toBe("failed"); + expect(reportedValidationResult({ command: "CI / build-test (pull_request)", status: DeliveryValidationStatus.failed, provider: "gitea_actions", summary: "ci.yml" }).status).toBe("failed"); + expect(reportedValidationResult({ command: "Gitea Actions: ci.yml", status: DeliveryValidationStatus.passed, provider: "gitea_actions", summary: "ci.yml" }).status).toBe("passed"); + }); +}); diff --git a/backend/test/featureDeliveryFlow.test.ts b/backend/test/featureDeliveryFlow.test.ts new file mode 100644 index 0000000..d8178a0 --- /dev/null +++ b/backend/test/featureDeliveryFlow.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertDeliveryWriteAllowed, canRepairFeatureDelivery } from "../src/services/featureDeliveryFlow.js"; +import type { FeatureDeliveryDto } from "../src/repositories/featureDeliveryRepository.js"; +import type { GiteaClient } from "../src/integrations/gitea/client.js"; + +describe("repairing a delivery after PR validation fails", () => { + const repository = { owner: "owner", repository: "repo", defaultBranch: "main" }; + const delivery = { approvalState: "approved", spec: { status: "pull_request_open", branch: "chatgpt/existing", pullRequest: { number: 12 } } } as FeatureDeliveryDto; + it("allows repair on the existing open PR, while terminal deliveries stay immutable", async () => { + const getPullRequest = vi.fn().mockResolvedValue({ state: "open", merged: false, head: { ref: "chatgpt/existing" }, base: { ref: "main" } }); + await expect(assertDeliveryWriteAllowed(delivery, "chatgpt/existing", { getPullRequest } as unknown as GiteaClient, repository)).resolves.toBeUndefined(); + expect(getPullRequest).toHaveBeenCalledWith("owner", "repo", 12); + expect(canRepairFeatureDelivery("merged")).toBe(false); + expect(canRepairFeatureDelivery("failed")).toBe(false); + }); + it("refuses writes if Gitea merged the PR before ProjectBrain saw its webhook", async () => { + const getPullRequest = vi.fn().mockResolvedValue({ state: "closed", merged: true }); + await expect(assertDeliveryWriteAllowed(delivery, "chatgpt/existing", { getPullRequest } as unknown as GiteaClient, repository)).rejects.toThrow("FEATURE_DELIVERY_PULL_REQUEST_NOT_WRITABLE"); + }); +}); diff --git a/backend/test/finding-reconciliation.test.ts b/backend/test/finding-reconciliation.test.ts new file mode 100644 index 0000000..0112ac5 --- /dev/null +++ b/backend/test/finding-reconciliation.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const repositoryFinding = { findUnique: vi.fn(), findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), create: vi.fn() }; + const findingOccurrence = { upsert: vi.fn() }; + const giteaIssueLink = { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn() }; + const outboxEvent = { create: vi.fn() }; + const findingAutomationPolicy = { upsert: vi.fn() }; + const tx = { repositoryFinding, findingOccurrence, giteaIssueLink, outboxEvent }; + const transaction = vi.fn(async (operation: unknown) => typeof operation === "function" ? operation(tx) : Promise.all(operation as Promise[])); + const issueClient = { createIssueComment: vi.fn(), editIssue: vi.fn() }; + const analysisJobItem = { findMany: vi.fn() }; + const findingInvestigation = { findMany: vi.fn(), update: vi.fn() }; + return { repositoryFinding, findingOccurrence, giteaIssueLink, outboxEvent, findingAutomationPolicy, transaction, issueClient, analysisJobItem, findingInvestigation }; +}); + +vi.mock("../src/db/prisma.js", () => ({ prisma: { + repositoryFinding: mocks.repositoryFinding, + findingOccurrence: mocks.findingOccurrence, + giteaIssueLink: mocks.giteaIssueLink, + outboxEvent: mocks.outboxEvent, + findingAutomationPolicy: mocks.findingAutomationPolicy, + $transaction: mocks.transaction, + analysisJobItem: mocks.analysisJobItem, + findingInvestigation: mocks.findingInvestigation, +} })); +vi.mock("../src/integrations/gitea/service.js", () => ({ getGiteaIssueClient: () => mocks.issueClient })); + +import { loadEnv } from "../src/config/env.js"; +import { closeAutomaticallyResolvedFindingIssues, finalizeFindingVerificationForJob, reconcileFindingIssueWebhook, reconcileRepositoryFindings } from "../src/repositories/findingRepository.js"; + +const env = loadEnv({ + NODE_ENV: "test", + DATABASE_URL: "postgresql://example.invalid/projectbrain", + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", + PROJECTBRAIN_GITEA_TOKEN: "read-token-with-at-least-twenty-characters", + PROJECTBRAIN_GITEA_WRITE_TOKEN: "write-token-with-at-least-twenty-characters", +}); + +const legacyFinding = { + id: "finding-legacy", + fingerprint: "legacy-fingerprint", + projectId: "project-1", + repositoryLinkId: "link-1", + owner: "Acme", + repository: "Repo", + ruleCode: "LATEST_ACTION_FAILED", + subject: "repository", + title: "Legacy", + message: "Legacy action failure", + severity: "critical", + confidence: 95, + riskScore: 90, + status: "issue_open", + occurrenceCount: 10, + clearCount: 0, + firstSeenAt: new Date("2026-08-20T00:00:00Z"), + lastSeenAt: new Date("2026-08-27T00:00:00Z"), + resolvedAt: null, + suppressedAt: null, + suppressedUntil: null, + suppressionReason: null, + lastAnalysisJobId: "job-old", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.findingOccurrence.upsert.mockResolvedValue({ id: "occurrence-1" }); + mocks.giteaIssueLink.upsert.mockResolvedValue({ id: "issue-link-1" }); + mocks.outboxEvent.create.mockResolvedValue({ id: "outbox-1" }); +}); + +describe("finding reconciliation lifecycle", () => { + it.each(["DEFAULT_BRANCH_ACTION_FAILED", "DEFAULT_BRANCH_UNPROTECTED", "OPEN_PR_BACKLOG", "TOPIC_BRANCH_ACTION_FAILED"])("does not clear an unevaluated %s finding", async (ruleCode) => { + mocks.repositoryFinding.findMany.mockResolvedValue([{ ...legacyFinding, ruleCode, clearCount: 1 }]); + for (const evaluatedRuleCodes of [undefined, ["REPOSITORY_ARCHIVED"]]) { + await reconcileRepositoryFindings({ jobId: "partial-job", analysisJobItemId: "partial-item", + owner: "Acme", repository: "Repo", defaultBranch: "main", findings: [], evaluatedRuleCodes }); + } + expect(mocks.repositoryFinding.update).not.toHaveBeenCalled(); + expect(mocks.findingOccurrence.upsert).not.toHaveBeenCalled(); + }); + + it("still resolves after two distinct explicitly evaluated clean scans", async () => { + const current = { ...legacyFinding, ruleCode: "DEFAULT_BRANCH_ACTION_FAILED" }; + mocks.repositoryFinding.findMany.mockImplementation(async () => [current]); + mocks.repositoryFinding.update.mockImplementation(async ({ data }) => Object.assign(current, data)); + for (const jobId of ["clean-one", "clean-two"]) { + await reconcileRepositoryFindings({ jobId, analysisJobItemId: jobId, owner: "Acme", repository: "Repo", + defaultBranch: "main", findings: [], evaluatedRuleCodes: [current.ruleCode] }); + expect(current.clearCount).toBe(jobId === "clean-one" ? 1 : 2); + expect(current.status).toBe(jobId === "clean-one" ? "issue_open" : "resolved"); + } + expect(mocks.findingOccurrence.upsert).toHaveBeenCalledTimes(2); + }); + + it.each(["failed", "pending", "running", "cancelled"])("never verifies a %s analysis item even with a clean occurrence", async (status) => { + mocks.analysisJobItem.findMany.mockResolvedValue([{ status, findingOccurrences: [{ findingId: "finding-legacy", observed: false }] }]); + expect(await finalizeFindingVerificationForJob(env, "job")).toEqual([]); + expect(mocks.findingInvestigation.findMany).not.toHaveBeenCalled(); + expect(mocks.repositoryFinding.update).not.toHaveBeenCalled(); + }); + + it.each([false, true])("requires an explicit clean occurrence for the exact finding (present: %s)", async (present) => { + mocks.analysisJobItem.findMany.mockResolvedValue([{ status: "succeeded", owner: "Acme", repository: "Repo", + findingOccurrences: [{ findingId: present ? "finding-legacy" : "other-finding", observed: false }] }]); + mocks.findingInvestigation.findMany.mockResolvedValue([{ id: "investigation", findingId: "finding-legacy", + finding: { projectId: null, issueLink: null }, deliveryPlan: null }]); + mocks.repositoryFinding.update.mockResolvedValue({}); + const result = await finalizeFindingVerificationForJob(env, "job"); + expect(result).toEqual(present ? [{ findingId: "finding-legacy", resolved: true }] : []); + expect(mocks.repositoryFinding.update).toHaveBeenCalledTimes(present ? 1 : 0); + }); + it("migrates an active legacy critical in place and preserves its history and issue identity", async () => { + mocks.repositoryFinding.findUnique.mockResolvedValueOnce(null); + mocks.repositoryFinding.findFirst.mockResolvedValueOnce(legacyFinding); + mocks.repositoryFinding.update + .mockImplementationOnce(async ({ data }: { data: Record }) => ({ ...legacyFinding, ...data })) + .mockImplementationOnce(async ({ data }: { data: Record }) => ({ ...legacyFinding, ...data, id: legacyFinding.id })); + mocks.repositoryFinding.findMany.mockResolvedValueOnce([]); + + const reconciled = await reconcileRepositoryFindings({ + jobId: "job-new", + analysisJobItemId: "item-new", + projectId: "project-1", + repositoryLinkId: "link-1", + owner: "Acme", + repository: "Repo", + defaultBranch: "main", + findings: [{ + severity: "critical", + code: "DEFAULT_BRANCH_ACTION_FAILED", + subject: "default:ci.yml", + message: "CI failed", + evidence: { scope: "default_branch", runUrl: "https://gitea.example/actions/runs/7", consecutiveFailures: 2 }, + }], + }); + + expect(reconciled[0].id).toBe("finding-legacy"); + expect(mocks.repositoryFinding.update.mock.calls[0][0]).toMatchObject({ + where: { id: "finding-legacy" }, + data: { ruleCode: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", status: "triaged" }, + }); + expect(mocks.repositoryFinding.update.mock.calls[1][0]).toMatchObject({ + where: { id: "finding-legacy" }, + data: { occurrenceCount: { increment: 1 }, lastAnalysisJobId: "job-new" }, + }); + expect(mocks.findingOccurrence.upsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { findingId_analysisJobItemId: { findingId: "finding-legacy", analysisJobItemId: "item-new" } }, + })); + }); + + it("does not increment occurrence or clear counters twice when an item is retried in the same job", async () => { + const current = { ...legacyFinding, fingerprint: "current", ruleCode: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", lastAnalysisJobId: "job-current" }; + mocks.repositoryFinding.findUnique.mockResolvedValueOnce(current); + mocks.repositoryFinding.update.mockImplementationOnce(async ({ data }: { data: Record }) => ({ ...current, ...data })); + mocks.repositoryFinding.findMany.mockResolvedValueOnce([{ ...legacyFinding, id: "missing", lastAnalysisJobId: "job-current" }]); + + await reconcileRepositoryFindings({ + jobId: "job-current", + analysisJobItemId: "item-current", + owner: "Acme", + repository: "Repo", + defaultBranch: "main", + findings: [{ severity: "critical", code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml", message: "CI failed" }], + }); + + expect(mocks.repositoryFinding.update).toHaveBeenCalledTimes(1); + expect(mocks.repositoryFinding.update.mock.calls[0][0].data.occurrenceCount).toBe(10); + }); + + it("closes only resolved linked issues whose project policy enables automatic closure", async () => { + const resolved = { + ...legacyFinding, + status: "resolved", + resolvedAt: new Date(), + issueLink: { id: "issue-link-1", state: "open", issueNumber: 7 }, + project: { findingAutomationPolicy: { autoCloseResolved: true } }, + }; + const detailed = { + ...resolved, + fingerprint: "resolved-fingerprint", + repositoryLink: { accessMode: "read_write" }, + investigations: [], + occurrences: [{ observed: true, detailJson: { evidence: { runUrl: "https://gitea.example/actions/runs/7" } }, createdAt: new Date() }], + }; + mocks.repositoryFinding.findMany.mockResolvedValueOnce([resolved]); + mocks.repositoryFinding.findUnique.mockResolvedValue(detailed); + mocks.repositoryFinding.update.mockResolvedValue(detailed); + mocks.issueClient.createIssueComment.mockResolvedValue({ id: 1 }); + mocks.issueClient.editIssue.mockResolvedValue({ id: 7, number: 7, title: "Finding", body: "body", state: "closed", html_url: "https://gitea.example/issues/7", labels: [], created_at: "2026-08-20T00:00:00Z", updated_at: "2026-08-28T00:00:00Z", closed_at: "2026-08-28T00:00:00Z" }); + + await expect(closeAutomaticallyResolvedFindingIssues(env)).resolves.toEqual(["finding-legacy"]); + expect(mocks.issueClient.createIssueComment).toHaveBeenCalledOnce(); + expect(mocks.issueClient.editIssue).toHaveBeenCalledWith("Acme", "Repo", 7, { state: "closed" }); + + vi.clearAllMocks(); + mocks.repositoryFinding.findMany.mockResolvedValueOnce([{ ...resolved, project: { findingAutomationPolicy: { autoCloseResolved: false } } }]); + await expect(closeAutomaticallyResolvedFindingIssues(env)).resolves.toEqual([]); + expect(mocks.issueClient.editIssue).not.toHaveBeenCalled(); + }); + + it("keeps an automatically resolved finding resolved when the issue-close webhook arrives later", async () => { + mocks.giteaIssueLink.findUnique.mockResolvedValueOnce({ + id: "issue-link-1", + findingId: "finding-legacy", + finding: { ...legacyFinding, status: "suppressed", resolvedAt: new Date("2026-08-28T00:00:00Z") }, + }); + mocks.giteaIssueLink.update.mockResolvedValueOnce({ id: "issue-link-1", state: "closed" }); + mocks.repositoryFinding.update.mockResolvedValueOnce({ ...legacyFinding, status: "resolved", projectId: "project-1" }); + + await reconcileFindingIssueWebhook({ + owner: "Acme", + repository: "Repo", + issueNumber: 7, + state: "closed", + title: "Finding", + url: "https://gitea.example/issues/7", + actor: "projectbrain", + }); + + expect(mocks.repositoryFinding.update).toHaveBeenCalledWith({ + where: { id: "finding-legacy" }, + data: { status: "resolved", suppressedAt: null, suppressedUntil: null, suppressionReason: null }, + }); + }); + + it("still suppresses a genuinely active finding when its issue is manually closed", async () => { + mocks.giteaIssueLink.findUnique.mockResolvedValueOnce({ + id: "issue-link-1", + findingId: "finding-legacy", + finding: { ...legacyFinding, status: "issue_open", resolvedAt: null }, + }); + mocks.giteaIssueLink.update.mockResolvedValueOnce({ id: "issue-link-1", state: "closed" }); + mocks.repositoryFinding.update.mockResolvedValueOnce({ ...legacyFinding, status: "suppressed", projectId: "project-1" }); + + await reconcileFindingIssueWebhook({ + owner: "Acme", + repository: "Repo", + issueNumber: 7, + state: "closed", + title: "Finding", + url: "https://gitea.example/issues/7", + actor: "jens", + }); + + expect(mocks.repositoryFinding.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: "finding-legacy" }, + data: expect.objectContaining({ status: "suppressed", suppressionReason: "Gitea issue handmatig gesloten door jens" }), + })); + }); +}); diff --git a/backend/test/gitea-autonomy-execution.test.ts b/backend/test/gitea-autonomy-execution.test.ts new file mode 100644 index 0000000..1b65256 --- /dev/null +++ b/backend/test/gitea-autonomy-execution.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { hasGiteaAutonomyCompletionEvidence } from "../src/repositories/autonomyRepository.js"; +import { autonomyGiteaBranch, isGiteaAutoExecutionBranch } from "../src/services/giteaAutonomyExecution.js"; + +describe("Gitea Auto execution contract", () => { + it("derives one deterministic atomic-claim branch from a durable work id", () => { + expect(autonomyGiteaBranch("PHX-003")).toBe("projectbrain/auto/phx-003"); + expect(autonomyGiteaBranch(" UI / Polish #4 ")).toBe("projectbrain/auto/ui-polish-4"); + expect(isGiteaAutoExecutionBranch("projectbrain/auto/phx-003")).toBe(true); + expect(isGiteaAutoExecutionBranch("chatgpt/other-work")).toBe(false); + }); + + it("requires merge, exact head and validation evidence before Gitea completion is authoritative", () => { + expect(hasGiteaAutonomyCompletionEvidence([ + "gitea:auto:merge:abc", + "gitea:auto:head:def", + ])).toBe(false); + expect(hasGiteaAutonomyCompletionEvidence([ + "gitea:auto:merge:abc", + "gitea:auto:head:def", + "gitea:auto:validation:42", + ])).toBe(true); + }); +}); diff --git a/backend/test/gitea-integration.test.ts b/backend/test/gitea-integration.test.ts new file mode 100644 index 0000000..6e12bf1 --- /dev/null +++ b/backend/test/gitea-integration.test.ts @@ -0,0 +1,519 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import { GiteaClient, GiteaApiError } from "../src/integrations/gitea/client.js"; +import { assertChatGptBranch, assertExpectedFileSha, assertFileSize, assertProjectBrainBranch, assertReadablePath, assertSuccessfulActionRuns, normalizeRepositoryPath } from "../src/integrations/gitea/policy.js"; +import type { GiteaActionRun } from "../src/integrations/gitea/types.js"; +import { exchangeAuthorizationCode, exchangeRefreshToken, issueAuthorizationCode, registerOAuthClient, verifyMcpAccessToken } from "../src/security/mcpOAuth.js"; +import { createHash, createHmac } from "node:crypto"; +import { hasGiteaAdminAccess, verifyGiteaWebhookSignature } from "../src/routes/giteaIntegration.js"; +import { assertFeatureDeliveryActionEvidence, assertFeatureDeliveryPlanComplete, canSyncFeatureDeliveryValidation, statusAfterFeatureDeliveryValidationSync } from "../src/repositories/featureDeliveryRepository.js"; +import { augmentProjectBrainInitialize, handleProjectBrainSkillMethod, projectBrainSkills } from "../src/mcp/skills.js"; +import { isMcpHubServiceToken, mcpHubActor } from "../src/security/mcpServiceAuth.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createProjectBrainMcpServer } from "../src/mcp/server.js"; +import { PROJECTBRAIN_WORKSPACE_MIME_TYPE, PROJECTBRAIN_WORKSPACE_URI } from "../src/mcp/widget.js"; +import { PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE, PROJECTBRAIN_ANALYSIS_WIDGET_URI } from "../src/mcp/analysisWidget.js"; +import { findingIssueMarker, findingRiskScore, renderFindingEvidence, repositoryFindingFingerprint } from "../src/repositories/findingRepository.js"; +import { canReuseProjectForRepository, isPortfolioManagedPolicy, resolvePortfolioRepositoryStatus } from "../src/services/portfolioControlPlane.js"; +import { PortfolioRepositoryStatus } from "@prisma/client"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { PROJECTBRAIN_GITEA_WEBHOOK_EVENTS } from "../src/integrations/gitea/service.js"; +import { existingRepositoryFromProjectSetup, giteaRepositoryNameCandidate, giteaRepositoryNameForProject, projectSlugFromInput } from "../src/services/projectRepositoryProvisioning.js"; + +const env = loadEnv({ + NODE_ENV: "test", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_MCP_ENABLED: "true", PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", + PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters", +}); + +describe("Gitea repository policy", () => { + it("accepts 58/200 analysis selections, rejects 201, and keeps reconciliation bounded to 50", async () => { + const registration = vi.spyOn(McpServer.prototype, "registerTool"); + const server = createProjectBrainMcpServer(env, "analysis-selection-contract-test"); + try { + const analysis = registration.mock.calls.find(([name]) => name === "start_repository_analysis")![1]; + const schema = z.object(analysis.inputSchema as z.ZodRawShape); + for (const field of ["project_ids", "repository_ids"]) { + for (const count of [58, 200, 201]) { + const values = Array.from({ length: count }, (_, index) => `repository-${index}`); + expect(schema.safeParse({ [field]: values }).success).toBe(count <= 200); + } + } + const reconcile = registration.mock.calls.find(([name]) => name === "reconcile_portfolio_repositories")![1]; + const reconcileSchema = z.object(reconcile.inputSchema as z.ZodRawShape); + expect(reconcileSchema.safeParse({ repository_full_names: Array.from({ length: 50 }, (_, index) => `Owner/repo-${index}`) }).success).toBe(true); + expect(reconcileSchema.safeParse({ repository_full_names: Array.from({ length: 51 }, (_, index) => `Owner/repo-${index}`) }).success).toBe(false); + } finally { + registration.mockRestore(); + await server.close(); + } + }); + it("only realigns policies that still belong to the portfolio control plane", () => { + expect(isPortfolioManagedPolicy()).toBe(true); + expect(isPortfolioManagedPolicy("system")).toBe(true); + expect(isPortfolioManagedPolicy("system:portfolio-control-plane")).toBe(true); + expect(isPortfolioManagedPolicy("web:owner")).toBe(false); + }); + + it("normalizes safe paths and blocks traversal, Git internals, and secrets", () => { + expect(normalizeRepositoryPath("/src\\app.ts")).toBe("src/app.ts"); + expect(() => normalizeRepositoryPath("../outside.txt")).toThrow("REPOSITORY_PATH_FORBIDDEN"); + expect(() => normalizeRepositoryPath(".git/config")).toThrow("REPOSITORY_PATH_FORBIDDEN"); + expect(() => assertReadablePath(".env")).toThrow("SENSITIVE_PATH_FORBIDDEN"); + expect(assertReadablePath(".env.example")).toBe(".env.example"); + }); + + it("allows any bounded non-default work branch and files", () => { + expect(assertChatGptBranch("chatgpt/add-search", "main")).toBe("chatgpt/add-search"); + expect(assertChatGptBranch("projectbrain/continue-in-web", "main")).toBe("projectbrain/continue-in-web"); + expect(assertChatGptBranch("m15-backup-restore-dr", "main")).toBe("m15-backup-restore-dr"); + expect(assertChatGptBranch("feature/normal", "main")).toBe("feature/normal"); + expect(() => assertChatGptBranch("main", "main")).toThrow("DEFAULT_BRANCH_WRITE_FORBIDDEN"); + expect(() => assertFileSize("x".repeat(512 * 1024 + 1))).toThrow("MCP_FILE_TOO_LARGE"); + }); + + it("keeps ProjectBrain workbench writes on non-default branches", () => { + expect(assertProjectBrainBranch("projectbrain/add-project-dashboard", "main")).toBe("projectbrain/add-project-dashboard"); + expect(assertProjectBrainBranch("chatgpt/continue-in-workbench", "main")).toBe("chatgpt/continue-in-workbench"); + expect(assertProjectBrainBranch("feature/normal", "main")).toBe("feature/normal"); + expect(() => assertProjectBrainBranch("main", "main")).toThrow("DEFAULT_BRANCH_WRITE_FORBIDDEN"); + expect(() => assertProjectBrainBranch("projectbrain/../main", "main")).toThrow("PROJECTBRAIN_BRANCH_FORBIDDEN"); + }); + + it("rejects stale updates and create-versus-update races", () => { + expect(() => assertExpectedFileSha("sha-opened", "sha-current")).toThrow("GITEA_FILE_CONFLICT"); + expect(() => assertExpectedFileSha(undefined, "sha-created-elsewhere")).toThrow("GITEA_FILE_CONFLICT"); + expect(() => assertExpectedFileSha("sha-deleted", undefined)).toThrow("GITEA_FILE_CONFLICT"); + expect(assertExpectedFileSha("sha-current", "sha-current")).toBeUndefined(); + expect(assertExpectedFileSha(undefined, undefined)).toBeUndefined(); + }); +}); + +describe("Gitea webhook verification", () => { + it("subscribes linked repositories to push-driven analysis and closed-loop events", () => { + expect(PROJECTBRAIN_GITEA_WEBHOOK_EVENTS).toEqual(["push", "pull_request", "issues", "issue_comment"]); + }); + + it("accepts only the HMAC-SHA256 signature of the untouched body", () => { + const body = Buffer.from('{"action":"closed","number":7}'); const secret = "a-webhook-secret-with-enough-entropy"; + const signature = createHmac("sha256", secret).update(body).digest("hex"); + expect(verifyGiteaWebhookSignature(body, signature, secret)).toBe(true); + expect(verifyGiteaWebhookSignature(Buffer.from(`${body.toString()} `), signature, secret)).toBe(false); + expect(verifyGiteaWebhookSignature(body, "invalid", secret)).toBe(false); + }); +}); + +describe("Gitea API client", () => { + it("accepts Gitea's empty HTTP 200 merge response without inventing a failed merge", async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version") + ? new Response(JSON.stringify({ version: "1.27.3" }), { status: 200 }) + : new Response(null, { status: 200 })); + const client = new GiteaClient("https://gitea.example.com", "test-token", fetcher as typeof fetch); + await expect(client.mergePullRequest("Owner", "Repo", 7, { method: "merge", headCommitId: "a".repeat(40) })).resolves.toBeUndefined(); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it("creates a private initialized repository through the bounded user endpoint", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify({ + id: 17, + name: "new-project", + full_name: "Acme/new-project", + private: true, + archived: false, + default_branch: "main", + html_url: "https://gitea.example.com/Acme/new-project", + updated_at: "2026-09-02T00:00:00Z", + owner: { login: "Acme" }, + permissions: { admin: true, push: true, pull: true }, + }), { status: 201, headers: { "content-type": "application/json" } })); + const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); + + const repository = await client.createRepository({ + name: "new-project", + description: "Created by ProjectBrain", + private: true, + }); + + expect(repository.full_name).toBe("Acme/new-project"); + expect(String(fetcher.mock.calls[0][0])).toBe("https://gitea.example.com/api/v1/user/repos"); + expect(fetcher.mock.calls[0][1]?.method).toBe("POST"); + expect(fetcher.mock.calls[0][1]?.body).toBe(JSON.stringify({ + name: "new-project", + description: "Created by ProjectBrain", + private: true, + auto_init: true, + default_branch: "main", + })); + }); + + it("uses token authentication and encodes repository paths", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify({ type: "file", path: "src/a b.ts", sha: "abc", size: 1, encoding: "base64", content: "eA==" }), { status: 200, headers: { "content-type": "application/json" } })); + const client = new GiteaClient("https://gitea.example.com/", "secret-token", fetcher as typeof fetch); + await client.getContents("Acme", "Repo", "src/a b.ts", "main"); + const [url, init] = fetcher.mock.calls[0]; + expect(String(url)).toContain("/api/v1/repos/Acme/Repo/contents/src/a%20b.ts?ref=main"); + expect((init?.headers as Record).Authorization).toBe("token secret-token"); + }); + + it("maps remote failures without exposing the configured token", async () => { + const client = new GiteaClient("https://gitea.example.com", "very-secret-token", (async () => new Response(JSON.stringify({ message: "not found" }), { status: 404 })) as typeof fetch); + await expect(client.getRepository("owner", "missing")).rejects.toMatchObject({ status: 404, code: "GITEA_HTTP_404", message: "not found" }); + }); + + it("paginates the complete visible repository portfolio", async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => { + const page = new URL(String(input)).searchParams.get("page"); + const count = page === "1" ? 100 : page === "2" ? 3 : 0; + return new Response(JSON.stringify(Array.from({ length: count }, (_, index) => ({ + id: Number(page) * 1000 + index, name: `repo-${page}-${index}`, full_name: `Acme/repo-${page}-${index}`, + private: true, archived: false, default_branch: "main", html_url: "https://gitea.example/repo", + updated_at: "2026-08-26T00:00:00Z", owner: { login: "Acme" }, permissions: { push: true }, + }))), { status: 200, headers: { "content-type": "application/json" } }); + }); + const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); + expect(await client.listRepositories()).toHaveLength(103); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it("continues past a server-clamped 50-item page and never follows foreign Link URLs", async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + expect(url.origin).toBe("https://gitea.example.com"); + expect(url.pathname).toBe("/api/v1/user/repos"); + expect(url.searchParams.get("limit")).toBe("100"); + const page = Number(url.searchParams.get("page")); + const count = page === 1 ? 50 : page === 2 ? 8 : 0; + return new Response(JSON.stringify(Array.from({ length: count }, (_, index) => ({ id: (page - 1) * 50 + index + 1 }))), { + headers: { "Link": '; rel="next"', "X-Total-Count": "58" }, + }); + }); + expect(await new GiteaClient("https://gitea.example.com", "fixture-token", fetcher as typeof fetch).listRepositories()).toHaveLength(58); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it("rejects repeated pages rather than returning a partial inventory", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify([{ id: 1 }]))); + await expect(new GiteaClient("https://gitea.example.com", "fixture-token", fetcher as typeof fetch).listRepositories()) + .rejects.toMatchObject({ code: "GITEA_INVENTORY_NONADVANCING" }); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("fails closed when the bounded inventory window is exhausted", async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => + new Response(JSON.stringify([{ id: Number(new URL(String(input)).searchParams.get("page")) }]))); + await expect(new GiteaClient("https://gitea.example.com", "fixture-token", fetcher as typeof fetch).listRepositories()) + .rejects.toMatchObject({ code: "GITEA_INVENTORY_LIMIT_EXCEEDED" }); + expect(fetcher).toHaveBeenCalledTimes(10); + }); + + it("propagates a later-page failure without returning the earlier inventory", async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => + new URL(String(input)).searchParams.get("page") === "1" + ? new Response(JSON.stringify([{ id: 1 }])) + : new Response(JSON.stringify({ message: "unavailable" }), { status: 503 })); + await expect(new GiteaClient("https://gitea.example.com", "fixture-token", fetcher as typeof fetch).listRepositories()) + .rejects.toMatchObject({ code: "GITEA_HTTP_503" }); + }); + + it("creates, patches, comments on, and labels issues through bounded API calls", async () => { + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/comments")) return new Response(JSON.stringify({ id: 4, body: "verified", created_at: "2026-08-26T00:00:00Z" }), { status: 201 }); + if (url.endsWith("/labels")) return new Response(JSON.stringify([]), { status: 200 }); + return new Response(JSON.stringify({ id: 7, number: 7, title: "Finding", body: "body", state: "open", html_url: "https://gitea.example/issues/7", created_at: "2026-08-26T00:00:00Z", updated_at: "2026-08-26T00:00:00Z" }), { status: init?.method === "PATCH" ? 200 : 201 }); + }); + const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); + + await client.createIssue("Acme", "Repo", { title: "Finding", body: "body", labels: [1, 2] }); + await client.editIssue("Acme", "Repo", 7, { state: "closed" }); + await client.createIssueComment("Acme", "Repo", 7, "verified"); + await client.replaceIssueLabels("Acme", "Repo", 7, [1, 2]); + + expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(["POST", "PATCH", "POST", "PUT"]); + expect(fetcher.mock.calls[0][1]?.body).toBe(JSON.stringify({ title: "Finding", body: "body", labels: [1, 2] })); + expect(String(fetcher.mock.calls[3][0])).toContain("/issues/7/labels"); + }); + + it("merges an exact pull request head and deletes its encoded work branch", async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version") + ? new Response(JSON.stringify({ version: "1.27.3" }), { status: 200, headers: { "content-type": "application/json" } }) + : new Response(undefined, { status: 204 })); + const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); + + await client.mergePullRequest("Acme", "Repo", 17, { method: "squash", headCommitId: "a".repeat(40) }); + await client.deleteBranch("Acme", "Repo", "feature/a b"); + + expect(String(fetcher.mock.calls[0][0])).toContain("/version"); + expect(String(fetcher.mock.calls[1][0])).toContain("/pulls/17/merge"); + expect(fetcher.mock.calls[1][1]?.method).toBe("POST"); + expect(fetcher.mock.calls[1][1]?.body).toBe(JSON.stringify({ + do: "squash", + head_commit_id: "a".repeat(40), + force_merge: false, + merge_when_checks_succeed: false, + delete_branch_after_merge: false, + })); + expect(String(fetcher.mock.calls[2][0])).toContain("/branches/feature%2Fa%20b"); + expect(fetcher.mock.calls[2][1]?.method).toBe("DELETE"); + }); + + it.each([ + ["1.25.5", "Do"], + ["1.26.4", "do"], + ["1.27.3", "do"], + ] as const)("uses the versioned merge strategy field for Gitea %s", async (version, strategyKey) => { + const fetcher = vi.fn(async (input: string | URL | Request) => String(input).endsWith("/version") + ? new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } }) + : new Response(undefined, { status: 204 })); + const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); + + await client.mergePullRequest("Acme", "Repo", 17, { method: "merge", headCommitId: "b".repeat(40) }); + await client.mergePullRequest("Acme", "Repo", 18, { method: "squash", headCommitId: "c".repeat(40) }); + + expect(fetcher.mock.calls.filter(([input]) => String(input).endsWith("/version"))).toHaveLength(1); + const firstBody = JSON.parse(String(fetcher.mock.calls[1][1]?.body)) as Record; + expect(firstBody[strategyKey]).toBe("merge"); + expect(firstBody[strategyKey === "Do" ? "do" : "Do"]).toBeUndefined(); + }); + + it("waits for every required pull-request status context before merging", async () => { + let statusReads = 0; + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("/branch_protections/main")) return new Response(JSON.stringify({ enable_status_check: true, status_check_contexts: ["Managed validation / full (pull_request)"] }), { status: 200 }); + if (url.endsWith("/commits/" + "d".repeat(40) + "/status")) { + statusReads += 1; + return new Response(JSON.stringify({ state: statusReads === 1 ? "pending" : "success", statuses: [{ context: "Managed validation / full (pull_request)", status: statusReads === 1 ? "pending" : "success" }] }), { status: 200 }); + } + return new Response(JSON.stringify({ message: "unexpected request" }), { status: 500 }); + }); + const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); + + const result = await client.waitForRequiredPullRequestChecks("Acme", "Repo", "main", "d".repeat(40), { timeoutMs: 1_000, pollIntervalMs: 0 }); + + expect(result).toEqual({ requiredContexts: ["Managed validation / full (pull_request)"], state: "success" }); + expect(statusReads).toBe(2); + }); +}); + +describe("portfolio onboarding policy", () => { + it("distinguishes existing-repository intake from new repository provisioning", () => { + expect(existingRepositoryFromProjectSetup({ firstRun: { repositoryProfile: { repository: "Acme/existing" } } })).toBe("Acme/existing"); + expect(existingRepositoryFromProjectSetup({ firstRun: { repositoryProfile: { preferredStack: "TypeScript" } } })).toBeUndefined(); + expect(projectSlugFromInput(undefined, "My New Project")).toBe("my-new-project"); + expect(giteaRepositoryNameForProject("My Invalid / Project")).toBe("my-invalid-project"); + expect(giteaRepositoryNameCandidate("my-project", 2)).toBe("my-project-2"); + }); + + it("enables automatic reconciliation with a one-minute default", () => { + const configured = loadEnv({ + NODE_ENV: "test", + DATABASE_URL: "postgresql://example.invalid/projectbrain", + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + }); + expect(configured.PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES).toBe(true); + expect(configured.PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS).toBe(60); + }); + + it("accepts a separate least-privilege token for issue writes", () => { + const configured = loadEnv({ + ...process.env, + NODE_ENV: "test", + DATABASE_URL: "postgresql://example.invalid/projectbrain", + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_GITEA_WRITE_TOKEN: "issue-write-token-with-twenty-characters", + }); + expect(configured.PROJECTBRAIN_GITEA_WRITE_TOKEN).toBe("issue-write-token-with-twenty-characters"); + }); + + it("keeps explicit exclusions durable while a real link always wins", () => { + expect(resolvePortfolioRepositoryStatus(false)).toBe(PortfolioRepositoryStatus.discovered); + expect(resolvePortfolioRepositoryStatus(false, PortfolioRepositoryStatus.excluded)).toBe(PortfolioRepositoryStatus.excluded); + expect(resolvePortfolioRepositoryStatus(true, PortfolioRepositoryStatus.excluded)).toBe(PortfolioRepositoryStatus.onboarded); + }); + + it("does not reuse a project slug that already belongs to another repository", () => { + expect(canReuseProjectForRepository([], "Jens", "ProjectBrain")).toBe(true); + expect(canReuseProjectForRepository([{ owner: "jens", repository: "projectbrain" }], "Jens", "ProjectBrain")).toBe(true); + expect(canReuseProjectForRepository([{ owner: "Other", repository: "ProjectBrain" }], "Jens", "ProjectBrain")).toBe(false); + expect(canReuseProjectForRepository([{ owner: "Jens", repository: "Different" }], "Jens", "ProjectBrain")).toBe(false); + }); +}); + +describe("durable finding identity", () => { + it("deduplicates repository findings case-insensitively and keeps rule subjects distinct", () => { + const first = repositoryFindingFingerprint("Acme", "Repo", "DEFAULT_BRANCH_UNPROTECTED", "main"); + expect(repositoryFindingFingerprint("acme", "repo", "DEFAULT_BRANCH_UNPROTECTED", "main")).toBe(first); + expect(repositoryFindingFingerprint("acme", "repo", "DEFAULT_BRANCH_UNPROTECTED", "develop")).not.toBe(first); + expect(findingIssueMarker({ id: "finding-1", fingerprint: first })).toContain("projectbrain:finding=finding-1"); + }); + + it("keeps workflow subjects distinct and ranks actionable evidence deterministically", () => { + expect(repositoryFindingFingerprint("Acme", "Repo", "DEFAULT_BRANCH_ACTION_FAILED", "main:ci.yml")) + .not.toBe(repositoryFindingFingerprint("Acme", "Repo", "DEFAULT_BRANCH_ACTION_FAILED", "main:deploy.yml")); + expect(findingRiskScore({ severity: "critical", code: "DEFAULT_BRANCH_ACTION_FAILED", message: "failed", evidence: { scope: "default_branch", consecutiveFailures: 2 } })) + .toBeGreaterThan(findingRiskScore({ severity: "critical", code: "SCHEDULED_ACTION_FAILED", message: "failed", evidence: { scope: "scheduled", consecutiveFailures: 2 } })); + expect(findingRiskScore({ severity: "warning", code: "SCHEDULED_ACTION_FAILED", message: "failed", evidence: { scope: "scheduled", consecutiveFailures: 2 } })) + .toBeGreaterThan(findingRiskScore({ severity: "info", code: "TOPIC_BRANCH_ACTION_FAILED", message: "failed", evidence: { scope: "pull_request", consecutiveFailures: 1 } })); + }); + + it("renders the exact Gitea run evidence for managed issues", () => { + const markdown = renderFindingEvidence({ workflow: "ci.yml", branch: "main", event: "push", conclusion: "failure", consecutiveFailures: 3, runUrl: "https://gitea.example/actions/runs/7" }); + expect(markdown).toContain("[open run](https://gitea.example/actions/runs/7)"); + expect(markdown).toContain("ci.yml"); + expect(markdown).toContain("main"); + }); +}); + +describe("MCP OAuth 2.1 PKCE", () => { + it("registers a public client, exchanges a PKCE code, and refreshes access", () => { + const redirectUri = "https://chatgpt.com/connector/callback"; + const client = registerOAuthClient(env, { redirectUris: [redirectUri], clientName: "ChatGPT" }); + const verifier = "a".repeat(48); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const code = issueAuthorizationCode(env, { clientId: client.clientId, redirectUri, codeChallenge: challenge, scope: "mcp:tools", subject: "owner", credentialVersion: 1, audience: "https://projectbrain.example.com/mcp" }); + const pair = exchangeAuthorizationCode(env, { code, clientId: client.clientId, redirectUri, codeVerifier: verifier }); + expect(verifyMcpAccessToken(env, pair.accessToken)).toMatchObject({ subject: "owner", credentialVersion: 1, scope: "mcp:tools", audience: "https://projectbrain.example.com/mcp" }); + expect(verifyMcpAccessToken(env, exchangeRefreshToken(env, pair.refreshToken, client.clientId).accessToken).subject).toBe("owner"); + expect(() => exchangeAuthorizationCode(env, { code, clientId: client.clientId, redirectUri, codeVerifier: verifier })).toThrow("OAUTH_CODE_ALREADY_USED"); + }); + + it("rejects redirect substitution and an invalid PKCE verifier", () => { + const client = registerOAuthClient(env, { redirectUris: ["https://chatgpt.com/connector/callback"] }); + expect(() => registerOAuthClient(env, { redirectUris: ["http://attacker.example/callback"] })).toThrow("OAUTH_REDIRECT_INVALID"); + const code = issueAuthorizationCode(env, { clientId: client.clientId, redirectUri: client.redirectUris[0], codeChallenge: createHash("sha256").update("correct".repeat(8)).digest("base64url"), scope: "mcp:tools", subject: "owner", credentialVersion: 1, audience: "https://projectbrain.example.com/mcp" }); + expect(() => exchangeAuthorizationCode(env, { code, clientId: client.clientId, redirectUri: client.redirectUris[0], codeVerifier: "wrong".repeat(10) })).toThrow("OAUTH_PKCE_INVALID"); + }); + + it("requires HTTPS for an enabled production MCP origin", () => { + expect(() => loadEnv({ NODE_ENV: "production", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", PROJECTBRAIN_AUTH_SECRET: "production-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_MCP_ENABLED: "true", PROJECTBRAIN_PUBLIC_URL: "http://projectbrain.example.com", PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters" })).toThrow("must use HTTPS"); + }); +}); + +describe("Gitea administration authorization", () => { + it("allows only the ProjectBrain owner role", () => { + expect(hasGiteaAdminAccess("owner")).toBe(true); + expect(hasGiteaAdminAccess("member")).toBe(false); + expect(hasGiteaAdminAccess()).toBe(false); + }); +}); + +describe("ProjectBrain MCP Hub and Skills integration", () => { + it("compares the Hub token safely and constrains the audit actor", () => { + const secret = "projectbrain-hub-token-with-more-than-32-characters"; + expect(isMcpHubServiceToken(secret, secret)).toBe(true); + expect(isMcpHubServiceToken(secret, `${secret}x`)).toBe(false); + expect(isMcpHubServiceToken(secret, "wrong-token-with-more-than-32-characters")).toBe(false); + expect(mcpHubActor("chatgpt-web")).toBe("mcp-hub:chatgpt-web"); + expect(mcpHubActor("../../spoofed actor")).toBe("mcp-hub:service"); + }); + + it("advertises digest-verified native ProjectBrain skills", () => { + const listed = handleProjectBrainSkillMethod("skills/list", {}) as { skills: typeof projectBrainSkills }; + expect(listed.skills).toHaveLength(4); + const selected = listed.skills[0]; + expect(handleProjectBrainSkillMethod("skills/get", { uri: selected.uri })).toEqual({ skill: selected }); + const resource = handleProjectBrainSkillMethod("resources/read", { uri: selected.uri }) as { contents: Array<{ text: string }> }; + expect(resource.contents[0].text).toContain("# ProjectBrain operator"); + const initialized = augmentProjectBrainInitialize({ result: { capabilities: {} } }) as { result: { capabilities: { extensions: Record } } }; + expect(initialized.result.capabilities.extensions).toHaveProperty("io.modelcontextprotocol/skills"); + }); + + it("serves every advertised ChatGPT UI template as a versioned MCP Apps resource", async () => { + const server = createProjectBrainMcpServer(env, "template-contract-test"); + const client = new Client({ name: "template-contract-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + + const [listedTools, listedResources] = await Promise.all([client.listTools(), client.listResources()]); + expect(listedTools.tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([ + "get_portfolio_control_plane", "list_portfolio_repositories", "reconcile_portfolio_repositories", "set_portfolio_repository_exclusion", + "list_repository_findings", "get_repository_finding", "create_finding_issue", "start_finding_investigation", + "get_finding_investigation", "save_finding_investigation", "create_delivery_from_finding", "verify_finding_resolution", + "apply_feature_changeset", "merge_feature_delivery", + ])); + const changeset = listedTools.tools.find((tool) => tool.name === "apply_feature_changeset"); + const merge = listedTools.tools.find((tool) => tool.name === "merge_feature_delivery"); + expect(changeset?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: false }); + expect(merge?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: true, idempotentHint: true }); + const advertisedUris = listedTools.tools.flatMap((tool) => { + const meta = tool._meta as { ui?: { resourceUri?: unknown }; "openai/outputTemplate"?: unknown } | undefined; + return [meta?.ui?.resourceUri, meta?.["openai/outputTemplate"]].filter((uri): uri is string => typeof uri === "string"); + }); + + expect(advertisedUris).toEqual([PROJECTBRAIN_WORKSPACE_URI, PROJECTBRAIN_WORKSPACE_URI, PROJECTBRAIN_ANALYSIS_WIDGET_URI, PROJECTBRAIN_ANALYSIS_WIDGET_URI]); + expect(listedResources.resources).toEqual(expect.arrayContaining([ + expect.objectContaining({ uri: PROJECTBRAIN_WORKSPACE_URI, mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE }), + expect.objectContaining({ uri: PROJECTBRAIN_ANALYSIS_WIDGET_URI, mimeType: PROJECTBRAIN_ANALYSIS_WIDGET_MIME_TYPE }), + ])); + + for (const uri of new Set(advertisedUris)) { + const resource = await client.readResource({ uri }); + expect(resource.contents).toHaveLength(1); + expect(resource.contents[0]).toMatchObject({ uri, mimeType: PROJECTBRAIN_WORKSPACE_MIME_TYPE }); + expect("text" in resource.contents[0] ? resource.contents[0].text : "").toContain(""); + if (uri === PROJECTBRAIN_ANALYSIS_WIDGET_URI) { + const text = "text" in resource.contents[0] ? resource.contents[0].text : ""; + expect(text).toContain("projectbrain.analysis.status"); + expect(text).toContain("projectbrain.analysis.results"); + } + expect(resource.contents[0]._meta).toMatchObject({ + ui: { prefersBorder: true, domain: env.PROJECTBRAIN_PUBLIC_URL, csp: { connectDomains: [], resourceDomains: [] } }, + }); + } + } finally { + await client.close(); + await server.close(); + } + }); +}); + +describe("ChatGPT feature delivery approval", () => { + const completePlan = { + problem: "Users cannot carry an idea through to a reviewed repository change.", desiredOutcome: "A persisted and review-gated feature delivery.", + goals: ["Keep scope explicit"], acceptanceCriteria: ["Owner approval is required"], implementationPlan: ["Create an isolated branch"], validationCommands: ["pnpm test"], + }; + + it("accepts complete plans and rejects incomplete approval contracts", () => { + expect(assertFeatureDeliveryPlanComplete(completePlan)).toBeUndefined(); + expect(() => assertFeatureDeliveryPlanComplete({ ...completePlan, acceptanceCriteria: [] })).toThrow("FEATURE_DELIVERY_PLAN_INCOMPLETE"); + expect(() => assertFeatureDeliveryPlanComplete({ ...completePlan, validationCommands: [] })).toThrow("FEATURE_DELIVERY_PLAN_INCOMPLETE"); + }); + + it("accepts only successful Gitea Actions evidence for the current branch head", () => { + const evidence = { provider: "gitea_actions" as const, runId: 42, runNumber: 7, url: "https://gitea.example.com/runs/42", workflowPath: "ci.yml@refs/heads/chatgpt/feature", headSha: "current-sha", conclusion: "success" as const, verifiedAt: new Date().toISOString() }; + expect(assertFeatureDeliveryActionEvidence(evidence, "current-sha")).toBeUndefined(); + expect(() => assertFeatureDeliveryActionEvidence(evidence, "newer-sha")).toThrow("FEATURE_DELIVERY_CI_EVIDENCE_REQUIRED"); + expect(() => assertFeatureDeliveryActionEvidence(undefined, "current-sha")).toThrow("FEATURE_DELIVERY_CI_EVIDENCE_REQUIRED"); + }); + + it("refreshes exact-head validation evidence without closing an open pull request", () => { + expect(canSyncFeatureDeliveryValidation("executing")).toBe(true); + expect(canSyncFeatureDeliveryValidation("validating")).toBe(true); + expect(canSyncFeatureDeliveryValidation("pull_request_open")).toBe(true); + expect(canSyncFeatureDeliveryValidation("merged")).toBe(false); + expect(statusAfterFeatureDeliveryValidationSync("executing")).toBe("validating"); + expect(statusAfterFeatureDeliveryValidationSync("pull_request_open")).toBe("pull_request_open"); + }); + + it("requires every Gitea Actions workflow for the exact branch head to succeed", () => { + const run = (id: number, conclusion: string, headSha = "current-sha"): GiteaActionRun => ({ + id, run_number: id, display_title: "CI", path: ".gitea/workflows/ci.yml", event: "push", head_sha: headSha, + head_branch: "chatgpt/feature", status: "completed", conclusion, html_url: `https://gitea.example.com/runs/${id}`, + }); + expect(assertSuccessfulActionRuns([run(1, "success"), run(2, "success")], "chatgpt/feature", "current-sha")).toHaveLength(2); + expect(() => assertSuccessfulActionRuns([run(1, "success"), run(2, "failure")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + expect(() => assertSuccessfulActionRuns([run(1, "success", "stale-sha")], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + }); +}); diff --git a/backend/test/gitea-portfolio-mcp.test.ts b/backend/test/gitea-portfolio-mcp.test.ts new file mode 100644 index 0000000..2c6ce48 --- /dev/null +++ b/backend/test/gitea-portfolio-mcp.test.ts @@ -0,0 +1,83 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import { + parsePortfolioFetchId, + portfolioFetchId, +} from "../src/integrations/gitea/portfolio.js"; +import { registerPortfolioMcpTools } from "../src/mcp/portfolioTools.js"; +import { createProjectBrainMcpServer } from "../src/mcp/server.js"; + +const env = loadEnv({ + NODE_ENV: "test", + DATABASE_URL: "postgresql://example.invalid/projectbrain", + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_MCP_ENABLED: "true", + PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com", + PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", + PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters", +}); + +describe("read-only Gitea portfolio MCP projection", () => { + it("round-trips canonical repository/ref/path fetch identifiers", () => { + const id = portfolioFetchId( + { owner: "Jens", repository: "Mediarr" }, + "codex/example-branch", + "src/a file.ts", + ); + expect(id).toBe( + "gitea-repository://Jens/Mediarr/codex%2Fexample-branch/src%2Fa%20file.ts", + ); + expect(parsePortfolioFetchId(id)).toEqual({ + owner: "Jens", + repository: "Mediarr", + ref: "codex/example-branch", + path: "src/a file.ts", + }); + expect(() => parsePortfolioFetchId("gitea://project/main/file.ts")).toThrow( + "MCP_FETCH_ID_INVALID", + ); + }); + + it("advertises portfolio tools as distinct read-only capabilities", async () => { + const server = createProjectBrainMcpServer(env, "portfolio-contract-test"); + registerPortfolioMcpTools(server, env, "portfolio-contract-test"); + const client = new Client({ name: "portfolio-contract-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const listed = await client.listTools(); + const tools = new Map(listed.tools.map((tool) => [tool.name, tool])); + for (const name of [ + "list_portfolio_repositories", + "search_portfolio_repositories", + "fetch_portfolio_file", + ]) { + const tool = tools.get(name); + expect(tool, `${name} should be registered`).toBeDefined(); + expect(tool?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }); + } + expect(tools.get("list_portfolio_repositories")?.annotations?.openWorldHint).toBe(true); + expect(tools.get("search_portfolio_repositories")?.annotations?.openWorldHint).toBe(false); + expect(tools.get("fetch_portfolio_file")?.annotations?.openWorldHint).toBe(false); + + // Existing delivery mutations remain separate, linked-project tools. + expect(tools.has("write_feature_file")).toBe(true); + expect(tools.has("search")).toBe(true); + expect(tools.has("fetch")).toBe(true); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/backend/test/mcp-efficiency.test.ts b/backend/test/mcp-efficiency.test.ts new file mode 100644 index 0000000..8f656f7 --- /dev/null +++ b/backend/test/mcp-efficiency.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { serializeAnalysisJob } from "../src/repositories/analysisJobRepository.js"; +import { buildProjectContextResume } from "../src/services/contextResolver.js"; + +describe("MCP efficiency hints", () => { + it("finishes open PRs before starting newer delivery branches", () => { + const resume = buildProjectContextResume( + [{ id: "repo", owner: "Jens", repository: "app", accessMode: "read_write", defaultBranch: "main" }], + [{ id: "new", title: "New plan", status: "approved", approvalState: "approved" }, { id: "existing", title: "Finish me", status: "pull_request_open", approvalState: "approved", runs: [{ pullRequestNumber: 7 }] }], [], + ); + expect(resume.nextAction).toMatchObject({ type: "review-and-merge", deliveryId: "existing", pullRequestNumber: 7 }); + }); + it("returns one durable resume action for an active delivery", () => { + const resume = buildProjectContextResume( + [{ id: "repo-1", owner: "Jens", repository: "app", accessMode: "read_write", defaultBranch: "main" }], + [{ id: "delivery-1", title: "Improve MCP", status: "executing", approvalState: "approved", runs: [{ branch: "chatgpt/mcp", headSha: "abc" }] }], + [], + "2026-08-27T00:00:00.000Z", + ); + + expect(resume).toMatchObject({ + repositoryCount: 1, + activeDelivery: { id: "delivery-1", branch: "chatgpt/mcp" }, + nextAction: { type: "continue-implementation", deliveryId: "delivery-1" }, + }); + expect(resume.stateVersion).toMatch(/^[0-9a-f]{24}$/); + }); + + it("tells clients when and whether to poll analysis state", () => { + const running = serializeAnalysisJob({ id: "job-1", status: "running", progressCurrent: 1, progressTotal: 4, updatedAt: new Date("2026-08-27T00:00:00.000Z") }); + const succeeded = serializeAnalysisJob({ id: "job-1", status: "succeeded", progressCurrent: 4, progressTotal: 4, updatedAt: new Date("2026-08-27T00:01:00.000Z") }); + + expect(running).toMatchObject({ terminal: false, pollAfterMs: 4_000, nextAction: "wait-for-change" }); + expect(succeeded).toMatchObject({ terminal: true, pollAfterMs: 0, nextAction: "read-results" }); + expect(running.stateVersion).not.toBe(succeeded.stateVersion); + }); +}); diff --git a/backend/test/mcp-skills.test.ts b/backend/test/mcp-skills.test.ts new file mode 100644 index 0000000..d099f18 --- /dev/null +++ b/backend/test/mcp-skills.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { augmentProjectBrainInitialize, handleProjectBrainSkillMethod } from "../src/mcp/skills.js"; + +type SkillList = { skills: Array<{ uri: string }> }; +type SkillResource = { contents: Array<{ text: string }> }; + +describe("ProjectBrain MCP skills", () => { + it("publishes canonical public workflows for repository bootstrap and delivery", () => { + const listed = handleProjectBrainSkillMethod("skills/list", {}) as SkillList; + const uris = listed.skills.map((skill) => skill.uri); + + expect(uris).toContain("skill://projectbrain/repository-bootstrap/SKILL.md"); + expect(uris).toContain("skill://projectbrain/feature-delivery/SKILL.md"); + + const bootstrap = handleProjectBrainSkillMethod("resources/read", { uri: "skill://projectbrain/repository-bootstrap/SKILL.md" }) as SkillResource; + expect(bootstrap.contents[0]?.text).toContain("gitea.repository.create"); + expect(bootstrap.contents[0]?.text).toContain("projectbrain.portfolio.repository.reconcile"); + + const delivery = handleProjectBrainSkillMethod("resources/read", { uri: "skill://projectbrain/feature-delivery/SKILL.md" }) as SkillResource; + expect(delivery.contents[0]?.text).toContain("projectbrain.delivery.changeset.apply"); + expect(delivery.contents[0]?.text).toContain("projectbrain.delivery.merge"); + expect(delivery.contents[0]?.text).toContain("gitea.workspace.run"); + expect(delivery.contents[0]?.text).not.toContain("apply_feature_changeset"); + expect(delivery.contents[0]?.text).not.toContain("merge_feature_delivery"); + }); + + it("publishes the Gitea-first Auto execution contract", () => { + const operator = handleProjectBrainSkillMethod("resources/read", { uri: "skill://projectbrain/projectbrain-operator/SKILL.md" }) as SkillResource; + const text = operator.contents[0]?.text ?? ""; + + expect(text).toContain("Gitea is the only primary repository execution surface"); + expect(text).toContain("projectbrain/auto/"); + expect(text).toContain("atomic execution claim"); + expect(text).toContain("exact-head validation evidence"); + expect(text).toContain("missing or interrupted report_auto_work callback"); + expect(text).toContain("No OpenAI API key is required"); + }); + + it("advertises Gitea as the primary Auto execution surface during MCP initialization", () => { + const initialized = augmentProjectBrainInitialize({ result: { capabilities: {} } }) as { + result: { instructions: string }; + }; + + expect(initialized.result.instructions).toContain( + "Gitea as its only primary repository execution surface", + ); + expect(initialized.result.instructions).toContain("projectbrain/auto/"); + expect(initialized.result.instructions).toContain("Branch existence is the atomic claim"); + expect(initialized.result.instructions).toContain( + "report_auto_work are backward-compatible coordination/bookkeeping surfaces only", + ); + expect(initialized.result.instructions).toContain( + "Never write directly to the default branch", + ); + }); +}); diff --git a/backend/test/native-ci-discovery.test.ts b/backend/test/native-ci-discovery.test.ts new file mode 100644 index 0000000..6325c14 --- /dev/null +++ b/backend/test/native-ci-discovery.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { GiteaClient } from "../src/integrations/gitea/client.js"; +import { assertSuccessfulActionRuns, exactHeadActionRuns } from "../src/integrations/gitea/policy.js"; +import type { GiteaActionRun } from "../src/integrations/gitea/types.js"; + +const branch = "codex/native-ci"; +const sha = "a".repeat(40); +const context = "CI / build-test (pull_request)"; +const run = (id: number, overrides: Partial = {}): GiteaActionRun => ({ + id, run_number: id, display_title: "CI", path: ".gitea/workflows/ci.yml", + event: "pull_request", head_sha: sha, head_branch: "", status: "completed", + conclusion: "success", html_url: `https://gitea.example/runs/${id}`, ...overrides, +}); + +describe("native PR validation discovery", () => { + it("discovers an empty branch by exact SHA and checks available PR identity", () => { + const matching = run(1, { pull_requests: [{ number: 30, head: { ref: branch, sha } }] }); + expect(exactHeadActionRuns([ + matching, run(2), run(3, { head_sha: "stale" }), + run(4, { head_branch: "other" }), + run(5, { pull_requests: [{ number: 31, head: { ref: "other", sha } }] }), + run(6, { event: "workflow_dispatch" }), + ], branch, sha)).toEqual([matching, run(2)]); + expect(assertSuccessfulActionRuns([matching], branch, sha)).toEqual([matching]); + }); + + it.each(["pending", "queued", "waiting", "in_progress"])("does not authorize %s", (status) => { + expect(() => assertSuccessfulActionRuns([run(1, { status, conclusion: undefined })], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + }); + + it.each(["cancelled", "canceled", "failure"])("does not authorize a %s job that never started", (conclusion) => { + expect(() => assertSuccessfulActionRuns([run(1, { conclusion })], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + }); + + it("uses the newest attempt of each workflow and preserves all evidence", () => { + const failure = run(2, { conclusion: "failure" }); + expect(() => assertSuccessfulActionRuns([failure, run(1, { status: "pending" })], branch, sha)).toThrow(); + const success = run(3); + expect(assertSuccessfulActionRuns([failure, success], branch, sha)).toEqual([failure, success]); + expect(() => assertSuccessfulActionRuns([success, run(4, { status: "queued" })], branch, sha)).toThrow(); + }); + + it("never lets dispatch success replace PR failure even for the same workflow path", () => { + expect(() => assertSuccessfulActionRuns([ + run(1, { conclusion: "failure" }), + run(2, { event: "workflow_dispatch", head_branch: branch }), + ], branch, sha)).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + }); + + it("queries by exact SHA without the upstream branch filter", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify({ total_count: 1, workflow_runs: [run(1)] }))); + await new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch).listActionRuns("Jens", "Repo", { branch, headSha: sha }); + const url = new URL(String(fetcher.mock.calls[0]![0])); + expect(url.searchParams.get("head_sha")).toBe(sha); + expect(url.searchParams.has("branch")).toBe(false); + }); + + it.each([ + [[{ context, status: "failure" }, { context, status: "pending" }], "GITEA_REQUIRED_CHECKS_FAILED"], + [[{ context: "CI / build-test (workflow_dispatch)", status: "success" }], "GITEA_REQUIRED_CHECKS_PENDING"], + [[{ context: `${context} extra`, status: "success" }], "GITEA_REQUIRED_CHECKS_PENDING"], + [[{ context, status: "pending" }], "GITEA_REQUIRED_CHECKS_PENDING"], + ] as const)("requires the exact latest protected status %#", async (statuses, error) => { + const fetcher = vi.fn(async (url: URL | RequestInfo) => new Response(JSON.stringify(String(url).includes("branch_protections") + ? { enable_status_check: true, status_check_contexts: [context] } : { statuses }))); + const client = new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch); + await expect(client.waitForRequiredPullRequestChecks("Jens", "Repo", "main", sha, { timeoutMs: 0 })).rejects.toMatchObject({ code: error }); + expect(String(fetcher.mock.calls[1]![0])).toContain(`/commits/${sha}/status`); + }); + + it("accepts a newer native success after an old native failure", async () => { + const fetcher = vi.fn(async (url: URL | RequestInfo) => new Response(JSON.stringify(String(url).includes("branch_protections") + ? { enable_status_check: true, status_check_contexts: [context] } + : { statuses: [{ context, status: "success" }, { context, status: "failure" }] }))); + await expect(new GiteaClient("https://gitea.example", "test-only", fetcher as typeof fetch) + .waitForRequiredPullRequestChecks("Jens", "Repo", "main", sha, { timeoutMs: 0 })).resolves.toMatchObject({ state: "success" }); + }); +}); diff --git a/backend/test/operatingSystemServices.test.ts b/backend/test/operatingSystemServices.test.ts new file mode 100644 index 0000000..30cc7fc --- /dev/null +++ b/backend/test/operatingSystemServices.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { parseCommandIntent } from "../src/services/commandIntent.js"; +import { detectStuckConditions, interpretConnectorHealth } from "../src/services/diagnostics.js"; +import { buildPulse } from "../src/services/pulse.js"; +import { deliveryValidationsAreGreen } from "../src/services/projectCompletion.js"; +import { buildParallelGroups, rankReadyWork, type WorkGraphNodeView } from "../src/services/workGraph.js"; + +const graphNode = (patch: Partial = {}): WorkGraphNodeView => ({ + id: "node-1", projectId: "project-1", stableKey: "node-1", source: "test", sourceId: "node-1", type: "feature", title: "Work", canonicalLifecycle: "ready", domainLifecycle: "pending", scope: "committed", priority: 50, risk: "normal", blockingStatus: "open", executionEligibility: "ready", attemptCount: 0, consecutiveFailureCount: 0, dependencies: [], dependents: [], completionEvidence: [], createdAt: "2026-09-12T10:00:00.000Z", updatedAt: "2026-09-12T10:00:00.000Z", ...patch, +}); + +describe("operating-system user-facing services", () => { + it("turns natural language into constrained policy instead of an execution bypass", () => { + expect(parseCommandIntent("Continue this project but don't deploy.")).toMatchObject({ + objective: "continue", + policy: { deploy: "blocked", automaticRecovery: true }, + allowedScope: ["required", "committed"], + }); + expect(parseCommandIntent("Finish everything required for release.")).toMatchObject({ objective: "release", requestedCompletionState: "definition_of_done", policy: { deploy: "unchanged" } }); + }); + + it("distinguishes functional connector failure from process health", () => { + expect(interpretConnectorHealth({ process: true, transport: true, authentication: true, api: true, functional_read: false })).toBe("degraded"); + expect(interpretConnectorHealth({ process: true, transport: true, api: false })).toBe("unavailable"); + expect(interpretConnectorHealth({ process: true, transport: true, authentication: true, authorization: true, api: true, functional_read: true, write_readiness: true })).toBe("healthy"); + }); + + it("detects reclaimable stale work and keeps the repair explicit", () => { + const now = Date.parse("2026-09-12T12:00:00.000Z"); + expect(detectStuckConditions({ + now, + running: [{ id: "lease-1", leaseExpiresAt: "2026-09-12T11:00:00.000Z" }], + queued: [{ id: "queue-1", queuedAt: "2026-09-12T11:00:00.000Z", consumerPresent: false }], + })).toMatchObject([ + { kind: "expired_lease", repairable: true }, + { kind: "queue_without_consumer", repairable: true }, + ]); + }); + + it("summarizes meaningful pulse events instead of returning a raw event dump", () => { + const pulse = buildPulse([ + { at: "2026-09-12T11:55:00.000Z", kind: "work.completed", title: "Delivery merged", summary: "The delivery was merged." }, + { at: "2026-09-12T11:56:00.000Z", kind: "heartbeat", title: "heartbeat", summary: "technical noise", meaningful: false }, + { at: "2026-09-12T11:57:00.000Z", kind: "work.blocked", title: "Validation failed", summary: "Retrying automatically." }, + ], Date.parse("2026-09-12T12:00:00.000Z")); + expect(pulse.counts).toMatchObject({ meaningful: 2, completed: 1, blockers: 1 }); + expect(pulse.nextLikelyAction).toContain("Retrying"); + }); + + it("treats a released delivery as a meaningful result while keeping noise out", () => { + const pulse = buildPulse([ + { at: "2026-09-12T11:55:00.000Z", kind: "delivery.released", title: "Release completed", summary: "The release is live." }, + { at: "2026-09-12T11:56:00.000Z", kind: "work.claimed", title: "Claimed", summary: "technical lease", meaningful: false }, + ], Date.parse("2026-09-12T12:00:00.000Z")); + expect(pulse.counts).toMatchObject({ meaningful: 1, completed: 1, active: 0 }); + }); + + it("does not invent an operating action for unsupported language", () => { + expect(parseCommandIntent("Tell me a joke")).toMatchObject({ objective: "unknown", confidence: 35 }); + }); + + it("keeps Future work out of ready scheduling and separates conflicting groups", () => { + const ready = [ + graphNode({ id: "a", stableKey: "a", metadata: { touchedPaths: ["src/shared/a.ts"] } }), + graphNode({ id: "b", stableKey: "b", metadata: { touchedPaths: ["src/shared/a.ts"] } }), + graphNode({ id: "c", stableKey: "c", scope: "future" }), + ]; + expect(rankReadyWork(ready).map((item) => item.id)).toEqual(["a", "b"]); + expect(buildParallelGroups(rankReadyWork(ready))).toHaveLength(2); + }); + + it("does not mark a delivery complete before its declared validations have run", () => { + expect(deliveryValidationsAreGreen(["pnpm test"], [])).toBe(false); + expect(deliveryValidationsAreGreen(["pnpm test"], [{ status: "passed" }])).toBe(true); + expect(deliveryValidationsAreGreen(["pnpm test"], [{ status: "failed" }])).toBe(false); + expect(deliveryValidationsAreGreen([], [])).toBe(true); + }); +}); diff --git a/backend/test/portfolioControlPlane.test.ts b/backend/test/portfolioControlPlane.test.ts new file mode 100644 index 0000000..1dd9000 --- /dev/null +++ b/backend/test/portfolioControlPlane.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import type { AutonomyProjectRuntime } from "../src/services/autonomyRuntime.js"; +import { buildAutoHumanAttentionPriority, PORTFOLIO_CATALOG_CACHE_MS, shouldRefreshPortfolioCatalog, summarizeDeliveryActivity, summarizeLiveExecution } from "../src/services/portfolioControlPlane.js"; + +const autoProject = (patch: Partial = {}): AutonomyProjectRuntime => ({ + projectId: "project-db-id", + projectSlug: "demo", + projectName: "Demo", + enabled: true, + status: "running", + lifecycle: "ready", + pendingWorkCount: 3, + completedWorkCount: 1, + maxWorkItemsPerRun: 5, + maxRunMinutes: 30, + stalled: false, + ...patch, +}); + +describe("portfolio delivery activity semantics", () => { + it("counts stored delivery phases separately from executor liveness", () => { + expect(summarizeDeliveryActivity({ + draft: 2, + approved: 3, + executing: 4, + validating: 5, + pull_request_open: 6, + merged: 7, + verified: 8, + released: 9, + })).toEqual({ + active: 9, + queued: 5, + waitingExternal: 6, + mergedAwaitingVerification: 7, + completed: 17, + }); + }); + + it("defaults missing delivery buckets to zero", () => { + expect(summarizeDeliveryActivity({})).toEqual({ + active: 0, + queued: 0, + waitingExternal: 0, + mergedAwaitingVerification: 0, + completed: 0, + }); + }); +}); + +describe("confirmed execution", () => { + const now = Date.parse("2026-09-09T12:00:00Z"); + const working = autoProject({ activeWorkKind: "implement", activeRunId: "run-1", executorRunId: "run-1", executorStatus: "working", executorLastSeenAt: new Date(now - 1000).toISOString(), leaseExpiresAt: new Date(now + 60000).toISOString() }); + it("requires enabled state, valid lease and fresh working heartbeat together", () => { + expect(summarizeLiveExecution([working], 120, now).verifiedActiveProjects).toBe(1); + for (const patch of [{ enabled: false }, { executorStatus: "stale" as const }, { executorStatus: "idle" as const }, { activeWorkKind: undefined }, { leaseExpiresAt: new Date(now).toISOString() }, { executorLastSeenAt: new Date(now - 121000).toISOString() }, { executorLastSeenAt: "invalid" }, { executorLastSeenAt: new Date(now + 1000).toISOString() }]) { + expect(summarizeLiveExecution([{ ...working, ...patch }], 120, now).verifiedActiveProjects).toBe(0); + } + }); + it("distinguishes paused automation from orphaned claims", () => { + expect(summarizeLiveExecution([{ ...working, executorRunId: "different-run" }], 120, now).verifiedActiveProjects).toBe(0); + expect(summarizeLiveExecution([{ ...working, activeRunId: undefined }], 120, now).verifiedActiveProjects).toBe(0); + expect(summarizeLiveExecution([{ ...working, enabled: false }], 120, now)).toMatchObject({ verifiedActiveProjects: 0, enabledProjects: 0, unconfirmedLeaseProjects: 0 }); + expect(summarizeLiveExecution([{ ...working, executorStatus: "offline" }], 120, now).unconfirmedLeaseProjects).toBe(1); + }); +}); + +describe("portfolio Auto human attention", () => { + it("surfaces true human Auto blockers as portfolio attention", () => { + const priority = buildAutoHumanAttentionPriority([ + autoProject({ status: "blocked", blockerKind: "human", blockerReason: "Owner decision required" }), + autoProject({ projectId: "retry-db", projectSlug: "retry", projectName: "Retry", status: "blocked", blockerKind: "retryable", blockerReason: "Runner timeout" }), + ]); + + expect(priority).toMatchObject({ + id: "auto-human-blockers", + projectId: "project-db-id", + requiresHumanAttention: true, + count: 1, + route: "/projects/demo/attention", + }); + }); + + it("does not turn retryable or disabled Auto state into human attention", () => { + expect(buildAutoHumanAttentionPriority([ + autoProject({ status: "blocked", blockerKind: "retryable" }), + autoProject({ enabled: false, status: "blocked", blockerKind: "human" }), + ])).toBeUndefined(); + }); +}); + +describe("portfolio catalog freshness", () => { + it("serves a recent durable catalog before asking Gitea for another inventory", () => { + const now = Date.parse("2026-09-13T12:00:00.000Z"); + expect(shouldRefreshPortfolioCatalog(new Date(now - PORTFOLIO_CATALOG_CACHE_MS), now)).toBe(false); + expect(shouldRefreshPortfolioCatalog(new Date(now - PORTFOLIO_CATALOG_CACHE_MS - 1), now)).toBe(true); + expect(shouldRefreshPortfolioCatalog(undefined, now)).toBe(true); + }); +}); diff --git a/backend/test/project-create-route.test.ts b/backend/test/project-create-route.test.ts new file mode 100644 index 0000000..5f7cfbc --- /dev/null +++ b/backend/test/project-create-route.test.ts @@ -0,0 +1,78 @@ +import Fastify from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; +import { registerProjectRoutes } from "../src/routes/projects.js"; + +describe("project create setup contract", () => { + const apps: Array> = []; + + afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + }); + + it("returns the rich wizard setup only after the create request succeeds", async () => { + const app = Fastify(); + apps.push(app); + await registerProjectRoutes(app, { dataSource: "fixtures" }); + + const response = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { + id: "durable-wizard", + name: "Durable wizard", + description: "A project whose complete wizard state survives the create boundary.", + domain: "webapp", + primaryGoal: "Persist the complete setup", + targetAudience: "Project owners", + dna: { + vision: "Keep project setup durable", + goals: ["Persist the complete setup", "Reload it safely"], + nonGoals: ["Do not navigate before success"], + targetAudience: "Project owners", + principles: ["Review before truth"], + successMetrics: [{ metric: "setup-roundtrip", target: "= true", operator: "=" }], + maturityStage: "Discovery", + revision: 1, + }, + setupState: { + id: "must-not-override", + firstRun: { + documentationDepth: "Full - exhaustive design system", + repositoryProfile: { preferredStack: "React + Fastify", repository: "Acme/durable-wizard" }, + }, + constraints: [{ id: "constraint-1", description: "No false success" }], + workspaceState: { + projectId: "durable-wizard", + primaryObjective: "Build the first durable slice", + focusAreas: [], + openTasksCount: 2, + openBugCount: 0, + activeSection: "workspace", + updatedAt: "2026-08-30T00:00:00.000Z", + notes: ["Created from the wizard"], + }, + }, + }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json().data).toMatchObject({ + id: "durable-wizard", + name: "Durable wizard", + dna: { + goals: ["Persist the complete setup", "Reload it safely"], + targetAudience: "Project owners", + }, + constraints: [{ id: "constraint-1", description: "No false success" }], + firstRun: { + documentationDepth: "Full - exhaustive design system", + repositoryProfile: { preferredStack: "React + Fastify", repository: "Acme/durable-wizard" }, + }, + workspaceState: { + projectId: "durable-wizard", + primaryObjective: "Build the first durable slice", + openTasksCount: 2, + }, + }); + }, 15_000); +}); diff --git a/backend/test/project-setup-state.test.ts b/backend/test/project-setup-state.test.ts new file mode 100644 index 0000000..cf6f293 --- /dev/null +++ b/backend/test/project-setup-state.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { mergeProjectSetupState, sanitizeProjectSetupState } from "../src/repositories/projectSetupState.js"; + +describe("project setup state persistence", () => { + it("round-trips rich wizard fields while rejecting unsupported top-level state", () => { + const setup = sanitizeProjectSetupState({ + id: "spoofed-project", + name: "Spoofed name", + firstRun: { documentationDepth: "Deep", repoFolders: ["docs/00-project"] }, + constraints: [{ id: "constraint-1", description: "Keep data durable" }], + workspaceState: { projectId: "wizard-project", primaryObjective: "Ship the first slice" }, + repositoryIntakes: [{ id: "not-part-of-project-setup" }], + }); + + expect(setup).toMatchObject({ + firstRun: { documentationDepth: "Deep", repoFolders: ["docs/00-project"] }, + constraints: [{ id: "constraint-1", description: "Keep data durable" }], + workspaceState: { projectId: "wizard-project", primaryObjective: "Ship the first slice" }, + }); + expect(setup).not.toHaveProperty("id"); + expect(setup).not.toHaveProperty("name"); + expect(setup).not.toHaveProperty("repositoryIntakes"); + }); + + it("always lets canonical database fields win over setup state", () => { + expect(mergeProjectSetupState( + { activeContextPackId: "starter-pack", workspaceState: { primaryObjective: "Wizard objective" }, name: "Spoofed" }, + { id: "canonical", name: "Canonical" }, + )).toEqual({ + activeContextPackId: "starter-pack", + workspaceState: { primaryObjective: "Wizard objective" }, + id: "canonical", + name: "Canonical", + }); + }); +}); diff --git a/backend/test/projectHealthSemantics.test.ts b/backend/test/projectHealthSemantics.test.ts new file mode 100644 index 0000000..e9846d9 --- /dev/null +++ b/backend/test/projectHealthSemantics.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { projectHealth } from "../src/services/projectHealthSemantics.js"; + +const finding = (severity = "warning", status = "new") => ({ severity, status }); +const health = (repositoryFindings: ReturnType[] = [], syncStatuses: string[] = ["ready"]) => + projectHealth({ repositoryFindings, giteaRepositories: syncStatuses.map((syncStatus) => ({ syncStatus })) }); + +describe("project health semantics", () => { + it("does not label a returned warning such as branch sprawl as a critical risk", () => { + expect(health([finding("warning", "regressed")])).toEqual({ + score: 80, healthStatus: "warn", activeFindingCount: 1, criticalCount: 0, warningCount: 1, regressedCount: 1, + }); + }); + + it.each(["new", "triaged", "regressed", "issue_open"])("preserves critical failures in status %s", (status) => { + expect(health([finding("critical", status)]).healthStatus).toBe("risk"); + }); + + it("preserves aggregate low-score risk without a critical finding", () => { + expect(health(Array.from({ length: 11 }, () => finding()))).toMatchObject({ score: 45, healthStatus: "risk" }); + expect(health(Array.from({ length: 10 }, () => finding()))).toMatchObject({ score: 50, healthStatus: "warn" }); + }); + + it("retains recurrence evidence and its aggregate penalty", () => { + expect(health(Array.from({ length: 3 }, () => finding("warning", "regressed")))).toMatchObject({ + score: 40, healthStatus: "risk", regressedCount: 3, warningCount: 3, + }); + expect(health([finding("info", "regressed")])).toMatchObject({ score: 85, healthStatus: "warn", regressedCount: 1 }); + }); + + it("excludes resolved and suppressed findings without hiding active findings", () => { + expect(health([finding("critical", "resolved"), finding("critical", "suppressed"), finding("info")])).toMatchObject({ + score: 100, healthStatus: "healthy", activeFindingCount: 1, criticalCount: 0, + }); + }); + + it("retains repository availability penalties and bounded scores", () => { + expect(health([], ["failed"])).toMatchObject({ score: 80, healthStatus: "warn" }); + expect(health(Array.from({ length: 10 }, () => finding("critical")), ["failed", "failed", "failed"])).toMatchObject({ + score: 0, healthStatus: "risk", + }); + expect(health()).toMatchObject({ score: 100, healthStatus: "healthy" }); + }); +}); diff --git a/backend/test/projectKnowledge.test.ts b/backend/test/projectKnowledge.test.ts new file mode 100644 index 0000000..50c565b --- /dev/null +++ b/backend/test/projectKnowledge.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { reconcileTruthCandidates } from "../src/services/projectKnowledge.js"; + +describe("project knowledge truth reconciliation", () => { + it("flags ambiguous disagreement as one actionable conflict", () => { + const conflicts = reconcileTruthCandidates([ + { key: "architecture", claim: "uses service a", source: "memory", authority: "memory", confidence: 90, entryId: "memory:1" }, + { key: "architecture", claim: "uses service b", source: "documentation", authority: "roadmap", confidence: 80, entryId: "documentation:1" }, + ]); + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ status: "attention", entryIds: ["memory:1", "documentation:1"] }); + }); + + it("lets verified repository truth supersede stale descriptive memory", () => { + const conflicts = reconcileTruthCandidates([ + { key: "architecture", claim: "uses service a", source: "memory", authority: "memory", confidence: 95, entryId: "memory:1" }, + { key: "architecture", claim: "uses service b", source: "repository", authority: "repository", confidence: 95, verifiedAt: "2026-09-12T10:00:00.000Z", entryId: "repo:1" }, + ]); + expect(conflicts[0]).toMatchObject({ status: "reconciled", winningEntryId: "repo:1" }); + }); +}); diff --git a/backend/test/recoveryPolicy.test.ts b/backend/test/recoveryPolicy.test.ts new file mode 100644 index 0000000..8d2c1fc --- /dev/null +++ b/backend/test/recoveryPolicy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { decideRecovery } from "../src/services/recoveryPolicy.js"; +import { normalizeAutonomyState, retryAutonomyBlockerIfDue } from "../src/repositories/autonomyRepository.js"; + +describe("bounded Auto Mode recovery policy", () => { + it("walks through retry, cooldown and alternate recovery before escalation", () => { + const sequence = [1, 2, 3, 4, 5, 6].map((recoveryAttempts) => decideRecovery({ + failureCount: recoveryAttempts, + recoveryAttempts, + recoveryStartedAt: "2026-09-12T10:00:00.000Z", + maxRecoveryMinutes: 120, + now: Date.parse("2026-09-12T10:10:00.000Z"), + }).stage); + expect(sequence).toEqual(["retry", "retry", "cooldown", "retry", "alternate_recovery", "escalated"]); + }); + + it("stops on the wall-clock budget even if attempts are low", () => { + expect(decideRecovery({ + failureCount: 1, + recoveryAttempts: 1, + recoveryStartedAt: "2026-09-12T08:00:00.000Z", + maxRecoveryMinutes: 30, + now: Date.parse("2026-09-12T08:31:00.000Z"), + })).toMatchObject({ stage: "escalated", exhausted: true, reason: "recovery_wall_clock_limit" }); + }); + + it("escalates a persisted blocker when its cooldown wakes after the wall-clock budget", () => { + const state = normalizeAutonomyState({ + enabled: true, + maxRecoveryMinutes: 30, + recoveryStartedAt: "2026-09-12T08:00:00.000Z", + blocker: { at: "2026-09-12T08:00:00.000Z", phaseId: "phase-1", reason: "Runner unavailable", kind: "retryable", retryAt: "2026-09-12T08:31:00.000Z", recoveryStage: "retry", recoveryAttempts: 1, recoveryStartedAt: "2026-09-12T08:00:00.000Z" }, + roadmap: { id: "roadmap-1", title: "Release", status: "blocked", currentPhaseIndex: 0, importedAt: "2026-09-12T08:00:00.000Z", phases: [{ id: "phase-1", title: "Build", objective: "Build", status: "blocked", items: [] }] }, + }); + const result = retryAutonomyBlockerIfDue(state, Date.parse("2026-09-12T08:31:00.000Z")); + expect(result.blocker).toMatchObject({ recoveryStage: "escalated" }); + expect(result.status).toBe("blocked"); + expect(result.roadmap?.status).toBe("blocked"); + }); +}); diff --git a/backend/test/remediation-regressions.test.ts b/backend/test/remediation-regressions.test.ts new file mode 100644 index 0000000..6b94599 --- /dev/null +++ b/backend/test/remediation-regressions.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import { GiteaClient } from "../src/integrations/gitea/client.js"; +import { assertSuccessfulActionRuns } from "../src/integrations/gitea/policy.js"; +import type { GiteaActionRun, GiteaContent } from "../src/integrations/gitea/types.js"; +import { decodeFile, MAX_MCP_READ_BYTES } from "../src/mcp/resultHelpers.js"; +import { serializeAnalysisJob } from "../src/repositories/analysisJobRepository.js"; + +const actionRun = (input: { + id: number; + conclusion: string; + workflow?: string; + status?: string; + headSha?: string; +}): GiteaActionRun => ({ + id: input.id, + run_number: input.id, + display_title: input.workflow ?? "CI", + path: `.gitea/workflows/${input.workflow ?? "ci.yml"}`, + event: "push", + head_sha: input.headSha ?? "current-sha", + head_branch: "chatgpt/feature", + status: input.status ?? "completed", + conclusion: input.conclusion, + html_url: `https://gitea.example.com/runs/${input.id}`, +}); + +const productionEnv = { + NODE_ENV: "production", + DATABASE_URL: "postgresql://projectbrain:strong-db-password@postgres:5432/projectbrain", + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "production-secret-with-at-least-thirty-two-characters", +} as const; + +describe("review remediation regressions", () => { + it("authorizes a successful rerun of the same workflow and revision without discarding evidence", () => { + const runs = [ + actionRun({ id: 10, conclusion: "failure" }), + actionRun({ id: 11, conclusion: "success" }), + ]; + + expect(assertSuccessfulActionRuns(runs, "chatgpt/feature", "current-sha")).toEqual(runs); + }); + + it("still fails closed when the latest run of any workflow is not successful", () => { + expect(() => assertSuccessfulActionRuns([ + actionRun({ id: 10, conclusion: "success", workflow: "quality.yml" }), + actionRun({ id: 11, conclusion: "failure", workflow: "security.yml" }), + ], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + + expect(() => assertSuccessfulActionRuns([ + actionRun({ id: 12, conclusion: "success" }), + actionRun({ id: 13, conclusion: "", status: "in_progress" }), + ], "chatgpt/feature", "current-sha")).toThrow("FEATURE_DELIVERY_CI_NOT_SUCCESSFUL"); + }); + + it("rejects shipped placeholder credentials in production", () => { + expect(() => loadEnv({ + ...productionEnv, + PROJECTBRAIN_OWNER_PASSWORD: "replace-with-a-unique-password-of-at-least-12-characters", + })).toThrow("PROJECTBRAIN_OWNER_PASSWORD must not use a placeholder"); + + expect(() => loadEnv({ + ...productionEnv, + PROJECTBRAIN_AUTH_SECRET: "replace-with-at-least-32-random-characters", + })).toThrow("PROJECTBRAIN_AUTH_SECRET must not use a placeholder"); + + expect(() => loadEnv({ + ...productionEnv, + DATABASE_URL: "postgresql://projectbrain:projectbrain@postgres:5432/projectbrain", + })).toThrow("DATABASE_URL database password must not use a placeholder"); + }); + + it("bounds complete MCP repository file reads", () => { + const file = (size: number): GiteaContent => ({ + type: "file", + name: "large.txt", + path: "large.txt", + sha: "abc", + size, + encoding: "base64", + content: Buffer.from("safe content").toString("base64"), + html_url: "https://gitea.example/large.txt", + }); + + expect(decodeFile(file(MAX_MCP_READ_BYTES))).toBe("safe content"); + expect(() => decodeFile(file(MAX_MCP_READ_BYTES + 1))).toThrow("MCP_FILE_READ_TOO_LARGE"); + }); + + it("deletes the managed repository webhook through an exact bounded endpoint", async () => { + const fetcher = vi.fn(async () => new Response(undefined, { status: 204 })); + const client = new GiteaClient("https://gitea.example.com", "service-token", fetcher as typeof fetch); + + await client.deleteRepositoryWebhook("Acme", "Repo", 42); + + expect(String(fetcher.mock.calls[0][0])).toContain("/api/v1/repos/Acme/Repo/hooks/42"); + expect(fetcher.mock.calls[0][1]?.method).toBe("DELETE"); + }); + + it("treats partially successful analysis jobs as terminal but actionable", () => { + const job = serializeAnalysisJob({ + id: "job-partial", + kind: "portfolio_health", + status: "partial", + actor: "owner", + progressCurrent: 7, + progressTotal: 10, + summaryText: "7 repositories geanalyseerd, 3 mislukt.", + summaryJson: { repositoryCount: 10, succeeded: 7, failed: 3, findingCount: 2 }, + errorCode: "ANALYSIS_PARTIAL_FAILURE", + cancelRequestedAt: null, + queuedAt: new Date("2026-08-29T00:00:00Z"), + startedAt: new Date("2026-08-29T00:00:01Z"), + heartbeatAt: new Date("2026-08-29T00:01:00Z"), + completedAt: new Date("2026-08-29T00:01:01Z"), + expiresAt: new Date("2026-09-28T00:00:00Z"), + createdAt: new Date("2026-08-29T00:00:00Z"), + updatedAt: new Date("2026-08-29T00:01:01Z"), + }); + + expect(job).toMatchObject({ + status: "partial", + terminal: true, + pollAfterMs: 0, + nextAction: "read-results-and-inspect-failures", + }); + }); +}); diff --git a/backend/test/repository-analysis.test.ts b/backend/test/repository-analysis.test.ts new file mode 100644 index 0000000..d14dccb --- /dev/null +++ b/backend/test/repository-analysis.test.ts @@ -0,0 +1,419 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import { GiteaClient } from "../src/integrations/gitea/client.js"; +import type { LinkedRepository } from "../src/integrations/gitea/service.js"; +import type { GiteaActionRun, GiteaPullRequest } from "../src/integrations/gitea/types.js"; +import { analyzeLinkedRepository, classifyActionFindings, loadActiveWorkflowEvents, parseWorkflowTriggerEvents } from "../src/services/repositoryAnalysis.js"; +import { projectAnalysisItemScope, resolveAnalysisTargets } from "../src/repositories/analysisJobRepository.js"; + +const envInput = { + NODE_ENV: "test", + DATABASE_URL: "postgresql://example.invalid/projectbrain", + PROJECTBRAIN_DATA_SOURCE: "database", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple", + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_GITEA_URL: "https://gitea.example.com", + PROJECTBRAIN_GITEA_TOKEN: "a-token-with-at-least-twenty-characters", +} satisfies NodeJS.ProcessEnv; +const env = loadEnv(envInput); + +const link: LinkedRepository = { + id: "repo-1", projectId: "project-1", projectSlug: "project-one", projectName: "Project One", + owner: "Acme", repository: "Repo", fullName: "Acme/Repo", defaultBranch: "main", accessMode: "read_write", + role: "primary", isPrimary: true, syncStatus: "ready", +}; + +const actionRun = (input: Partial & Pick): GiteaActionRun => ({ + display_title: "CI", + path: "ci.yml@refs/heads/main", + event: "push", + head_sha: `sha-${input.run_number}`, + head_branch: "main", + status: "completed", + conclusion: "success", + html_url: `https://gitea.example/actions/runs/${input.id}`, + completed_at: `2026-08-28T00:${String(input.run_number).padStart(2, "0")}:00Z`, + ...input, +}); + +const pullRequest = (number: number, branch: string): GiteaPullRequest => ({ + id: number, + number, + title: `PR ${number}`, + state: "open", + html_url: `https://gitea.example/pulls/${number}`, + merged: false, + created_at: "2026-08-28T00:00:00Z", + updated_at: "2026-08-28T00:00:00Z", + head: { ref: branch, sha: `head-${number}` }, + base: { ref: "main", sha: "main-sha" }, +}); + +describe("actionable Gitea Actions classification", () => { + it.each([ + { includeBranches: false, includePullRequests: true, includeActions: true, absent: ["REPOSITORY_EMPTY", "DEFAULT_BRANCH_UNPROTECTED", "BRANCH_SPRAWL"] }, + { includeBranches: true, includePullRequests: false, includeActions: true, absent: ["OPEN_PR_BACKLOG", "TOPIC_BRANCH_ACTION_FAILED"] }, + { includeBranches: true, includePullRequests: true, includeActions: false, absent: ["DEFAULT_BRANCH_ACTION_FAILED", "SCHEDULED_ACTION_FAILED", "TOPIC_BRANCH_ACTION_FAILED"] }, + { includeBranches: true, includePullRequests: true, includeActions: true, absent: [] }, + ])("records only actual rule coverage for partial analysis %j", async ({ absent, ...request }) => { + const client = new GiteaClient("https://gitea.example", "fixture-token"); + vi.spyOn(client, "getRepository").mockResolvedValue({ default_branch: "main", archived: false, private: true, permissions: { push: true } } as never); + const branches = vi.spyOn(client, "listBranches").mockResolvedValue([{ name: "main", protected: true }] as never); + const prs = vi.spyOn(client, "listPullRequests").mockResolvedValue([]); + const actions = vi.spyOn(client, "listActionRuns").mockResolvedValue({ workflow_runs: [actionRun({ id: 1, run_number: 1 })] } as never); + vi.spyOn(client, "getContents").mockResolvedValue([]); + const detail = await analyzeLinkedRepository(env, link, request, client); + for (const code of absent) expect(detail.evaluatedRuleCodes).not.toContain(code); + expect(branches).toHaveBeenCalledTimes(request.includeBranches ? 1 : 0); + expect(prs).toHaveBeenCalledTimes(request.includePullRequests ? 1 : 0); + expect(actions).toHaveBeenCalledTimes(request.includeActions ? 1 : 0); + if (!absent.length) expect(detail.evaluatedRuleCodes).toEqual(expect.arrayContaining([ + "DEFAULT_BRANCH_UNPROTECTED", "OPEN_PR_BACKLOG", "DEFAULT_BRANCH_ACTION_FAILED", "TOPIC_BRANCH_ACTION_FAILED", + ])); + }); + it("does not revive an older failure that finished after a newer successful run", () => { + const findings = classifyActionFindings({ defaultBranch: "main", recentRuns: [], openPullRequests: [], defaultBranchRuns: [ + actionRun({ id: 1, run_number: 1, conclusion: "failure", completed_at: "2026-09-09T12:00:00Z" }), + actionRun({ id: 2, run_number: 2, conclusion: "success", completed_at: "2026-09-09T11:00:00Z" }), + ] }); + expect(findings).toEqual([]); + }); + + it("retains a newer failure even if an older successful run finishes later", () => { + const findings = classifyActionFindings({ defaultBranch: "main", recentRuns: [], openPullRequests: [], defaultBranchRuns: [ + actionRun({ id: 1, run_number: 1, conclusion: "success", completed_at: "2026-09-09T12:00:00Z" }), + actionRun({ id: 2, run_number: 2, conclusion: "failure", completed_at: "2026-09-09T11:00:00Z" }), + ] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", evidence: { runId: 2 } }); + }); + + it("does not hide a failing schedule behind a successful push in the same workflow", () => { + const findings = classifyActionFindings({ defaultBranch: "main", recentRuns: [], openPullRequests: [], defaultBranchRuns: [ + actionRun({ id: 1, run_number: 1, event: "schedule", conclusion: "failure" }), + actionRun({ id: 2, run_number: 2, event: "push", conclusion: "success" }), + ] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ code: "SCHEDULED_ACTION_FAILED", severity: "warning", evidence: { consecutiveFailures: 1 } }); + }); + + it("does not count push failures toward the scheduled failure threshold", () => { + const findings = classifyActionFindings({ defaultBranch: "main", recentRuns: [], openPullRequests: [], defaultBranchRuns: [ + actionRun({ id: 1, run_number: 1, conclusion: "failure" }), + actionRun({ id: 2, run_number: 2, conclusion: "failure" }), + actionRun({ id: 3, run_number: 3, event: "schedule", conclusion: "failure" }), + ] }); + expect(findings).toHaveLength(2); + expect(findings.find((finding) => finding.code === "SCHEDULED_ACTION_FAILED")).toMatchObject({ severity: "warning", evidence: { consecutiveFailures: 1 } }); + expect(findings.find((finding) => finding.code === "DEFAULT_BRANCH_ACTION_FAILED")).toMatchObject({ severity: "critical", evidence: { consecutiveFailures: 2 } }); + }); + + it.each(["pull_request", "pull_request_target", "push"])("treats an explicit PR ref as PR evidence even with head_branch main (%s)", (event) => { + const run = actionRun({ id: 2, run_number: 2, event, path: "ci.yml@refs/pull/7/merge", head_branch: "main", conclusion: "failure" }); + const findings = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [actionRun({ id: 1, run_number: 1 }), run], recentRuns: [run], openPullRequests: [pullRequest(7, "codex/work")] }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ code: "TOPIC_BRANCH_ACTION_FAILED", severity: "info", evidence: { branch: "codex/work" } }); + }); + + it("ignores in-flight agent branches while retaining a real default-branch failure", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [actionRun({ id: 1, run_number: 1, conclusion: "failure" })], + recentRuns: [ + actionRun({ id: 2, run_number: 2, head_branch: "codex/work", path: "ci.yml@refs/heads/codex/work", conclusion: "failure" }), + actionRun({ id: 1, run_number: 1, conclusion: "failure" }), + ], + openPullRequests: [pullRequest(2, "codex/work")], + }); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", severity: "critical", subject: "default:ci.yml" }); + }); + + it("escalates a scheduled workflow only after three consecutive failures", () => { + const scheduled = (count: number) => classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: Array.from({ length: count }, (_, index) => actionRun({ + id: 10 - index, + run_number: 10 - index, + event: "schedule", + path: "canary.yml@refs/heads/main", + conclusion: "failure", + })), + recentRuns: [], + openPullRequests: [], + })[0]; + + expect(scheduled(2)).toMatchObject({ code: "SCHEDULED_ACTION_FAILED", severity: "warning", evidence: { consecutiveFailures: 2 } }); + expect(scheduled(3)).toMatchObject({ code: "SCHEDULED_ACTION_FAILED", severity: "critical", evidence: { consecutiveFailures: 3 } }); + }); + + it("keeps the previous completed failure while a newer run is still in progress", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [ + actionRun({ id: 3, run_number: 3, status: "in_progress", conclusion: undefined }), + actionRun({ id: 2, run_number: 2, conclusion: "failure" }), + ], + recentRuns: [], + openPullRequests: [], + }); + + expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", evidence: { runId: 2 } }); + }); + + it("does not treat cancelled or skipped conclusions as failures", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [actionRun({ id: 2, run_number: 2, conclusion: "cancelled" }), actionRun({ id: 1, run_number: 1, conclusion: "failure" })], + recentRuns: [], + openPullRequests: [], + }); + + expect(findings).toEqual([]); + }); + + it("defensively rejects non-default runs even if Gitea ignores the branch query", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [ + actionRun({ id: 3, run_number: 3, head_branch: "codex/work", path: "ci.yml@refs/heads/codex/work", conclusion: "failure" }), + actionRun({ id: 2, run_number: 2, head_branch: "main", conclusion: "success" }), + ], + recentRuns: [], + openPullRequests: [], + }); + + expect(findings).toEqual([]); + }); + + it("keeps workflows independent and includes actionable run evidence", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [ + actionRun({ id: 5, run_number: 5, path: "build.yml@refs/heads/main", conclusion: "success" }), + actionRun({ id: 4, run_number: 4, path: "deploy.yml@refs/heads/main", conclusion: "failure", html_url: "https://gitea.example/actions/runs/4" }), + ], + recentRuns: [], + openPullRequests: [], + }); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + code: "DEFAULT_BRANCH_ACTION_FAILED", + subject: "default:deploy.yml", + evidence: { workflow: "deploy.yml", branch: "main", runUrl: "https://gitea.example/actions/runs/4" }, + }); + }); + + it("ignores historical failures for workflow files removed from the default branch", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [ + actionRun({ id: 5, run_number: 5, path: "managed-validation.yml@refs/heads/main", conclusion: "success" }), + actionRun({ id: 4, run_number: 4, path: "ci.yml@refs/heads/main", conclusion: "failure" }), + ], + recentRuns: [], + openPullRequests: [], + activeWorkflows: ["managed-validation.yml"], + }); + + expect(findings).toEqual([]); + }); + + it("retains failures for workflow files that still exist on the default branch", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [actionRun({ id: 4, run_number: 4, path: "ci.yml@refs/heads/main", conclusion: "failure" })], + recentRuns: [], + openPullRequests: [], + activeWorkflows: ["ci.yml"], + }); + + expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml" }); + }); + + it("ignores a historical event removed from a workflow while retaining current events", () => { + const historicalPush = actionRun({ id: 11, run_number: 11, path: "unraid-deploy.yml@refs/heads/main", event: "push", conclusion: "failure" }); + const manualFailure = actionRun({ id: 12, run_number: 12, path: "unraid-deploy.yml@refs/heads/main", event: "workflow_dispatch", conclusion: "failure" }); + const currentEvents = new Map([["unraid-deploy.yml", new Set(["workflow_dispatch"])]]); + + const obsolete = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [historicalPush], recentRuns: [], openPullRequests: [], activeWorkflowEvents: currentEvents }); + expect(obsolete.some((finding) => finding.code === "DEFAULT_BRANCH_ACTION_FAILED")).toBe(false); + + const current = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [manualFailure, historicalPush], recentRuns: [], openPullRequests: [], activeWorkflowEvents: currentEvents }); + expect(current[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:unraid-deploy.yml", evidence: { event: "workflow_dispatch", runId: 12 } }); + }); + + it("keeps historical failures actionable when current workflow events are unknown", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [actionRun({ id: 4, run_number: 4, path: "ci.yml@refs/heads/main", conclusion: "failure" })], + recentRuns: [], + openPullRequests: [], + activeWorkflowEvents: new Map([["ci.yml", undefined]]), + }); + expect(findings[0]).toMatchObject({ code: "DEFAULT_BRANCH_ACTION_FAILED", subject: "default:ci.yml" }); + }); + + it("parses common top-level workflow trigger forms and rejects ambiguous syntax", () => { + expect([...parseWorkflowTriggerEvents("on: push\njobs: {}") ?? []]).toEqual(["push"]); + expect([...parseWorkflowTriggerEvents("on: [push, pull_request]\njobs: {}") ?? []]).toEqual(["push", "pull_request"]); + expect([...parseWorkflowTriggerEvents("on:\n push:\n branches: [main]\n workflow_dispatch:\njobs: {}") ?? []]).toEqual(["push", "workflow_dispatch"]); + expect(parseWorkflowTriggerEvents("on: { push: null }\njobs: {}"), "inline maps stay conservative").toBeUndefined(); + expect(parseWorkflowTriggerEvents("jobs:\n build:\n runs-on: ubuntu-latest"), "missing on stays conservative").toBeUndefined(); + }); + + it("loads current workflow events and falls back conservatively when one file cannot be read", async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("manual.yml")) return new Response(JSON.stringify({ type: "file", name: "manual.yml", path: ".gitea/workflows/manual.yml", sha: "manual", size: 20, encoding: "base64", content: Buffer.from("on:\n workflow_dispatch:\n").toString("base64") }), { status: 200 }); + return new Response(JSON.stringify({ message: "unavailable" }), { status: 503 }); + }); + const client = new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch); + const events = await loadActiveWorkflowEvents(client, "Acme", "Repo", "main", [ + { type: "file", name: "manual.yml", path: ".gitea/workflows/manual.yml", sha: "manual", size: 20 }, + { type: "file", name: "unknown.yml", path: ".gitea/workflows/unknown.yml", sha: "unknown", size: 20 }, + ]); + expect([...(events?.get("manual.yml") ?? [])]).toEqual(["workflow_dispatch"]); + expect(events?.has("unknown.yml")).toBe(true); + expect(events?.get("unknown.yml")).toBeUndefined(); + }); + + it("downgrades an open pull-request failure to info and ignores closed pull requests", () => { + const run = actionRun({ + id: 8, + run_number: 8, + event: "pull_request", + head_branch: undefined, + path: "release-gates.yml@refs/pull/7/head", + conclusion: "failure", + }); + const active = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [actionRun({ id: 1, run_number: 1 })], recentRuns: [run], openPullRequests: [pullRequest(7, "feature/release")] }); + const closed = classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [actionRun({ id: 1, run_number: 1 })], recentRuns: [run], openPullRequests: [] }); + + expect(active).toHaveLength(1); + expect(active[0]).toMatchObject({ code: "TOPIC_BRANCH_ACTION_FAILED", severity: "info", evidence: { branch: "feature/release" } }); + expect(closed).toEqual([]); + }); + + it("recognizes Gitea pull-request merge refs", () => { + const findings = classifyActionFindings({ + defaultBranch: "main", + defaultBranchRuns: [actionRun({ id: 1, run_number: 1 })], + recentRuns: [actionRun({ id: 9, run_number: 9, head_branch: undefined, event: "pull_request", path: "ci.yml@refs/pull/9/merge", conclusion: "failure" })], + openPullRequests: [pullRequest(9, "feature/merge-ref")], + }); + + expect(findings[0]).toMatchObject({ code: "TOPIC_BRANCH_ACTION_FAILED", evidence: { branch: "feature/merge-ref" } }); + }); + + it("reports absent default-branch CI and an agent branch configured as default separately", () => { + expect(classifyActionFindings({ defaultBranch: "main", defaultBranchRuns: [], recentRuns: [], openPullRequests: [] })[0]).toMatchObject({ code: "DEFAULT_BRANCH_CI_ABSENT", severity: "warning" }); + expect(classifyActionFindings({ defaultBranch: "codex/temporary", defaultBranchRuns: [], recentRuns: [], openPullRequests: [] })).toEqual([ + expect.objectContaining({ code: "DEFAULT_BRANCH_IS_AGENT_BRANCH", severity: "warning", subject: "codex/temporary" }), + ]); + }); + + it("reclassifies the frozen twelve-repository production sample without hiding failures", () => { + const failed = (branch: string, event: string = "push", path = "ci.yml") => actionRun({ id: 10, run_number: 10, head_branch: branch, event, path: `${path}@refs/heads/${branch}`, conclusion: "failure" }); + const cases = [ + { repository: "Gifted", defaultBranch: "main", defaults: [failed("main")], recent: [] }, + { repository: "ITWorx-Pulse", defaultBranch: "master", defaults: [failed("master")], recent: [failed("codex/integration")] }, + { repository: "Microsoft-Cloud-Operations-Platform", defaultBranch: "main", defaults: [failed("main", "push", "unraid-deploy.yml")], recent: [] }, + { repository: "MobilityOps", defaultBranch: "master", defaults: [failed("master", "schedule", "live-canary.yml"), actionRun({ ...failed("master", "schedule", "live-canary.yml"), id: 9, run_number: 9 }), actionRun({ ...failed("master", "schedule", "live-canary.yml"), id: 8, run_number: 8 })], recent: [] }, + { repository: "PentestForge", defaultBranch: "main", defaults: [], recent: [failed("chatgpt/m1-control-plane", "workflow_dispatch", "managed-validation.yml")] }, + { repository: "Portfolio", defaultBranch: "main", defaults: [failed("main", "push", "deploy.yml")], recent: [] }, + { repository: "RAGcore", defaultBranch: "main", defaults: [failed("main", "push", "unraid-deploy.yml")], recent: [] }, + { repository: "chimera-retroarch", defaultBranch: "codex/ps5-loader-entry-diagnosis", defaults: [failed("codex/ps5-loader-entry-diagnosis", "schedule", "crowdin-daily.yml")], recent: [] }, + { repository: "geointel", defaultBranch: "main", defaults: [], recent: [actionRun({ id: 7, run_number: 7, head_branch: undefined, event: "pull_request", path: "release-gates.yml@refs/pull/1/head", conclusion: "failure" })], pullRequests: [pullRequest(1, "feature/geointel")] }, + { repository: "itworx-knowledge-hub", defaultBranch: "main", defaults: [failed("main")], recent: [] }, + { repository: "opscenter", defaultBranch: "main", defaults: [actionRun({ id: 6, run_number: 6, head_branch: "main", conclusion: "success" })], recent: [failed("chatgpt/remediation")] }, + { repository: "spoortrack", defaultBranch: "main", defaults: [], recent: [actionRun({ id: 5, run_number: 5, head_branch: undefined, event: "pull_request", path: "ci.yml@refs/pull/1/head", conclusion: "failure" })], pullRequests: [pullRequest(1, "feature/spoortrack")] }, + ]; + const classified = cases.map((entry) => ({ + repository: entry.repository, + findings: classifyActionFindings({ defaultBranch: entry.defaultBranch, defaultBranchRuns: entry.defaults, recentRuns: entry.recent, openPullRequests: entry.pullRequests ?? [] }), + })); + + expect(classified.filter((entry) => entry.findings.some((finding) => finding.severity === "critical")).map((entry) => entry.repository)).toEqual([ + "Gifted", "ITWorx-Pulse", "Microsoft-Cloud-Operations-Platform", "MobilityOps", "Portfolio", "RAGcore", "itworx-knowledge-hub", + ]); + expect(classified.find((entry) => entry.repository === "opscenter")?.findings).toEqual([]); + expect(classified.find((entry) => entry.repository === "PentestForge")?.findings.map((finding) => finding.code)).toEqual(["DEFAULT_BRANCH_CI_ABSENT"]); + expect(classified.find((entry) => entry.repository === "chimera-retroarch")?.findings.map((finding) => finding.code)).toEqual(["DEFAULT_BRANCH_IS_AGENT_BRANCH"]); + expect(classified.find((entry) => entry.repository === "geointel")?.findings.map((finding) => finding.code)).toEqual(["DEFAULT_BRANCH_CI_ABSENT", "TOPIC_BRANCH_ACTION_FAILED"]); + expect(classified.find((entry) => entry.repository === "spoortrack")?.findings.map((finding) => finding.code)).toEqual(["DEFAULT_BRANCH_CI_ABSENT", "TOPIC_BRANCH_ACTION_FAILED"]); + }); +}); + +describe("bounded repository analysis", () => { + it("matches project-scoped jobs by internal id or browser-facing slug", () => { + expect(projectAnalysisItemScope("web-privacy-auditor")).toEqual({ + OR: [{ projectId: "web-privacy-auditor" }, { projectSlug: "web-privacy-auditor" }], + }); + }); + + it("discovers unlinked current and future repositories for an unscoped portfolio job", () => { + const repositories = [ + { id: 1, name: "Repo", full_name: "Acme/Repo", private: true, archived: false, default_branch: "main", html_url: "https://gitea.example/Acme/Repo", updated_at: "2026-08-26T00:00:00Z", permissions: { push: true }, owner: { login: "Acme" } }, + { id: 2, name: "Future", full_name: "Acme/Future", private: true, archived: false, default_branch: "trunk", html_url: "https://gitea.example/Acme/Future", updated_at: "2026-08-26T00:00:00Z", permissions: { pull: true }, owner: { login: "Acme" } }, + ]; + const targets = resolveAnalysisTargets([link], repositories, { includeBranches: true, includePullRequests: true, includeActions: true }); + + expect(targets.map((target) => target.fullName)).toEqual(["Acme/Future", "Acme/Repo"]); + expect(targets.find((target) => target.fullName === "Acme/Repo")?.repositoryLinkId).toBe("repo-1"); + expect(targets.find((target) => target.fullName === "Acme/Future")).toMatchObject({ repositoryLinkId: undefined, projectSlug: "gitea-all", accessMode: "read_only" }); + }); + + it("keeps project-scoped jobs limited to explicit ProjectBrain links", () => { + const targets = resolveAnalysisTargets([link], [], { projectIds: ["project-one"], includeBranches: true, includePullRequests: true, includeActions: true }); + expect(targets).toHaveLength(1); + expect(targets[0].repositoryLinkId).toBe("repo-1"); + }); + + it("reduces several Gitea reads to a compact finding summary", async () => { + const branches = Array.from({ length: 26 }, (_, index) => ({ name: index === 0 ? "main" : `branch-${index}`, commit: { id: `sha-${index}` }, protected: false })); + const pullRequests = Array.from({ length: 11 }, (_, index) => ({ id: index, number: index, title: `PR ${index}`, state: "open", html_url: `https://gitea.example/pr/${index}`, merged: false, created_at: "2026-08-26T00:00:00Z", updated_at: "2026-08-26T00:00:00Z", head: { ref: `branch-${index}`, sha: `sha-${index}` }, base: { ref: "main", sha: "main-sha" } })); + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("/actions/runs")) return new Response(JSON.stringify({ total_count: 1, workflow_runs: [{ id: 1, run_number: 1, display_title: "CI", path: "ci.yml", event: "push", head_sha: "sha", head_branch: "main", status: "completed", conclusion: "failure", html_url: "https://gitea.example/run/1" }] }), { status: 200 }); + if (url.includes("/contents/.gitea/workflows/ci.yml")) return new Response(JSON.stringify({ type: "file", name: "ci.yml", path: ".gitea/workflows/ci.yml", sha: "workflow-file-sha", size: 20, encoding: "base64", content: Buffer.from("on:\n push:\n").toString("base64") }), { status: 200 }); + if (url.includes("/contents/.gitea/workflows")) return new Response(JSON.stringify([{ type: "file", name: "ci.yml", path: ".gitea/workflows/ci.yml", sha: "workflow-sha", size: 1 }]), { status: 200 }); + if (url.includes("/branches")) return new Response(JSON.stringify(branches), { status: 200 }); + if (url.includes("/pulls")) return new Response(JSON.stringify(pullRequests), { status: 200 }); + return new Response(JSON.stringify({ id: 1, name: "Repo", full_name: "Acme/Repo", private: true, archived: false, default_branch: "main", html_url: "https://gitea.example/Acme/Repo", updated_at: "2026-08-26T00:00:00Z", permissions: { push: true }, owner: { login: "Acme" } }), { status: 200 }); + }); + const detail = await analyzeLinkedRepository(env, link, { includeBranches: true, includePullRequests: true, includeActions: true }, new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch)); + + expect(detail.branchSummary).toMatchObject({ total: 26, defaultProtected: false }); + expect(detail.pullRequestSummary).toEqual({ open: 11 }); + expect(detail.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["DEFAULT_BRANCH_UNPROTECTED", "BRANCH_SPRAWL", "OPEN_PR_BACKLOG", "DEFAULT_BRANCH_ACTION_FAILED"])); + expect(detail.findings.find((finding) => finding.code === "DEFAULT_BRANCH_ACTION_FAILED")?.evidence).toMatchObject({ runUrl: "https://gitea.example/run/1", branch: "main" }); + expect(JSON.stringify(detail).length).toBeLessThan(8_000); + expect(fetcher).toHaveBeenCalledTimes(7); + expect(fetcher.mock.calls.map(([input]) => String(input))).toContainEqual(expect.stringContaining("branch=main")); + }); + + it("treats Gitea's empty-repository pull-request 404 as an empty collection", async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("/branches")) return new Response("[]", { status: 200 }); + if (url.includes("/pulls")) return new Response(JSON.stringify({ message: "not found" }), { status: 404 }); + if (url.includes("/actions/runs")) return new Response(JSON.stringify({ total_count: 0, workflow_runs: [] }), { status: 200 }); + if (url.includes("/contents/.gitea/workflows")) return new Response(JSON.stringify({ message: "not found" }), { status: 404 }); + return new Response(JSON.stringify({ id: 1, name: "Repo", full_name: "Acme/Repo", private: true, archived: false, default_branch: "main", html_url: "https://gitea.example/Acme/Repo", updated_at: "2026-08-26T00:00:00Z", permissions: { push: true }, owner: { login: "Acme" } }), { status: 200 }); + }); + + const detail = await analyzeLinkedRepository(env, link, { includeBranches: true, includePullRequests: true, includeActions: true }, new GiteaClient("https://gitea.example.com", "secret-token", fetcher as typeof fetch)); + + expect(detail.pullRequestSummary).toEqual({ open: 0 }); + expect(detail.findings).toEqual([{ severity: "info", code: "REPOSITORY_EMPTY", message: "Repository bevat nog geen commit of branch." }]); + }); + + it("enforces safe worker concurrency and timeout bounds in configuration", () => { + expect(() => loadEnv({ ...envInput, PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY: "3" })).toThrow(); + expect(() => loadEnv({ ...envInput, PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS: "10" })).toThrow(); + }); +}); diff --git a/backend/test/repository-autonomy.test.ts b/backend/test/repository-autonomy.test.ts new file mode 100644 index 0000000..4a62ba1 --- /dev/null +++ b/backend/test/repository-autonomy.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { isSafeGitProjectSourcePath } from "../src/services/gitSourceSync.js"; +import { chooseNextAutonomyWork } from "../src/services/autonomyScheduler.js"; +import { + buildRepositoryAutonomyRoadmap, + isRepositoryManagedRoadmap, + mergeRepositoryAutonomyRoadmap, +} from "../src/services/repositoryAutonomy.js"; + +const roadmapSource = { + path: "ROADMAP.md", + content: `# Phoenix roadmap + +## M1 — Safe software foundation +Build the strict software foundation while lab qualification can happen independently. + +## M2 — K3s lab +Provision only after the external lab gate is cleared.`, + contentSha: "roadmap-sha", +}; + +const backlogSource = (extraTasks: Array> = []) => ({ + path: "docs/implementation/backlog.json", + contentSha: `backlog-sha-${extraTasks.length}`, + content: JSON.stringify({ + schema_version: "1.0", + project: "Phoenix", + execution_enabled: false, + tasks: [ + { + id: "PHX-001", + title: "Read-only lab qualification", + milestone: "M1", + depends_on: [], + requirements: ["R02"], + plan: "docs/implementation/01-foundation.md", + execution: "qualification", + status: "blocked_external", + evidence: [], + verified_commit: null, + }, + { + id: "PHX-002", + title: "Strict contracts and development harness", + milestone: "M1", + depends_on: [], + requirements: ["R03"], + plan: "docs/implementation/01-foundation.md", + execution: "code", + status: "planned", + evidence: [], + verified_commit: null, + }, + { + id: "PHX-003", + title: "API, database, durable jobs and outbox", + milestone: "M1", + depends_on: ["PHX-002"], + requirements: ["R04"], + plan: "docs/implementation/01-foundation.md", + execution: "code", + status: "planned", + evidence: [], + verified_commit: null, + }, + { + id: "PHX-006", + title: "Safe K3s bootstrap", + milestone: "M2", + depends_on: ["PHX-001", "PHX-002"], + requirements: ["R06"], + plan: "docs/implementation/01-foundation.md", + execution: "lab", + status: "planned", + evidence: [], + verified_commit: null, + }, + ...extraTasks, + ], + }), +}); + +describe("repository-native Auto Mode", () => { + it("syncs planning truth without opening the source filter to arbitrary JSON", () => { + expect(isSafeGitProjectSourcePath("ROADMAP.md")).toBe(true); + expect(isSafeGitProjectSourcePath("docs/implementation/backlog.json")).toBe(true); + expect(isSafeGitProjectSourcePath("docs/implementation/roadmap.json")).toBe(true); + expect(isSafeGitProjectSourcePath("docs/implementation/random-data.json")).toBe(false); + expect(isSafeGitProjectSourcePath("config/backlog.json")).toBe(false); + expect(isSafeGitProjectSourcePath(".env")).toBe(false); + }); + + it("turns a structured repository backlog into dependency-aware autonomous work", () => { + const roadmap = buildRepositoryAutonomyRoadmap({ + roadmap: roadmapSource, + backlog: backlogSource(), + importedAt: "2026-09-21T12:00:00.000Z", + }); + + expect(roadmap).toBeDefined(); + expect(isRepositoryManagedRoadmap(roadmap)).toBe(true); + expect(roadmap?.phases.map((phase) => phase.title)).toEqual([ + "M1 — Safe software foundation", + "M2 — K3s lab", + ]); + + const items = new Map(roadmap?.phases.flatMap((phase) => phase.items).map((item) => [item.id, item])); + expect(items.get("PHX-001")).toMatchObject({ status: "pending", waitingExternal: true, source: "roadmap" }); + expect(items.get("PHX-002")).toMatchObject({ status: "pending", scope: "committed" }); + expect(items.get("PHX-002")).not.toHaveProperty("waitingExternal"); + expect(items.get("PHX-003")).toMatchObject({ dependsOn: ["PHX-002"] }); + expect(items.get("PHX-006")).toMatchObject({ waitingExternal: true, dependsOn: ["PHX-001", "PHX-002"] }); + expect(roadmap?.phases.every((phase) => phase.brainstormConverged)).toBe(true); + + const next = chooseNextAutonomyWork(roadmap!.phases, roadmap!.currentPhaseIndex, "balanced"); + expect(next?.item.id).toBe("PHX-002"); + }); + + it("does not let a planning refresh newly complete an existing runtime item", () => { + const current = buildRepositoryAutonomyRoadmap({ + roadmap: roadmapSource, + backlog: backlogSource(), + importedAt: "2026-09-21T12:00:00.000Z", + })!; + const currentItem = current.phases.flatMap((phase) => phase.items).find((item) => item.id === "PHX-002")!; + currentItem.status = "pending"; + + const verifiedBacklog = JSON.parse(backlogSource().content) as { tasks: Array> }; + const task = verifiedBacklog.tasks.find((entry) => entry.id === "PHX-002")!; + task.status = "verified"; + task.verified_commit = "deadbeef"; + + const incoming = buildRepositoryAutonomyRoadmap({ + roadmap: { ...roadmapSource, contentSha: "roadmap-sha-verified" }, + backlog: { ...backlogSource(), content: JSON.stringify(verifiedBacklog), contentSha: "backlog-sha-verified" }, + importedAt: "2026-09-21T13:00:00.000Z", + })!; + + const reconciled = mergeRepositoryAutonomyRoadmap(current, incoming); + const item = reconciled.phases.flatMap((phase) => phase.items).find((entry) => entry.id === "PHX-002")!; + expect(item.status).toBe("pending"); + }); + + it("preserves verified runtime progress and explicit assignments when repository planning changes", () => { + const current = buildRepositoryAutonomyRoadmap({ + roadmap: roadmapSource, + backlog: backlogSource(), + importedAt: "2026-09-21T12:00:00.000Z", + })!; + const phx002 = current.phases.flatMap((phase) => phase.items).find((item) => item.id === "PHX-002")!; + phx002.status = "completed"; + phx002.attempts = 2; + phx002.deliveryId = "delivery-phx-002"; + phx002.completionEvidence = ["merge:abc"]; + + current.phases[0].items.unshift({ + id: "assignment-1", + title: "Explicit owner task", + instruction: "Keep this assignment across repo refreshes.", + source: "user_assignment", + status: "pending", + attempts: 0, + }); + + const incoming = buildRepositoryAutonomyRoadmap({ + roadmap: { ...roadmapSource, contentSha: "roadmap-sha-2" }, + backlog: backlogSource([{ + id: "PHX-004", + title: "Auth and target guard", + milestone: "M1", + depends_on: ["PHX-003"], + requirements: ["R05"], + plan: "docs/implementation/01-foundation.md", + execution: "code", + status: "planned", + evidence: [], + verified_commit: null, + }]), + importedAt: "2026-09-21T13:00:00.000Z", + })!; + + const reconciled = mergeRepositoryAutonomyRoadmap(current, incoming); + const items = new Map(reconciled.phases.flatMap((phase) => phase.items).map((item) => [item.id, item])); + + expect(items.get("PHX-002")).toMatchObject({ + status: "completed", + attempts: 2, + deliveryId: "delivery-phx-002", + completionEvidence: expect.arrayContaining(["merge:abc"]), + }); + expect(items.get("assignment-1")).toMatchObject({ source: "user_assignment", status: "pending" }); + expect(items.get("PHX-004")).toMatchObject({ dependsOn: ["PHX-003"], status: "pending" }); + }); +}); diff --git a/backend/test/repository-ownership.test.ts b/backend/test/repository-ownership.test.ts new file mode 100644 index 0000000..2c9a7ef --- /dev/null +++ b/backend/test/repository-ownership.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + assertExplicitRepositoryTransfer, + assertRepositoryTransferHasNoDeliveryHistory, + GiteaRepositoryOwnershipConflictError, + GiteaRepositoryTransferBlockedError, +} from "../src/integrations/gitea/service.js"; +import { integrationError } from "../src/routes/giteaIntegration.js"; + +describe("Gitea repository ownership", () => { + const existing = { projectId: "project-a", project: { name: "Project A" } }; + + it("blocks a silent move to another project with an HTTP 409 conflict", () => { + let conflict: unknown; + try { + assertExplicitRepositoryTransfer(existing, "project-b", "Acme/platform", false); + } catch (error) { + conflict = error; + } + + expect(conflict).toBeInstanceOf(GiteaRepositoryOwnershipConflictError); + expect(integrationError(conflict)).toMatchObject({ + status: 409, + code: "GITEA_REPOSITORY_ALREADY_LINKED", + }); + }); + + it("allows same-project updates and an explicitly confirmed transfer", () => { + expect(() => assertExplicitRepositoryTransfer(existing, "project-a", "Acme/platform", false)).not.toThrow(); + expect(() => assertExplicitRepositoryTransfer(existing, "project-b", "Acme/platform", true)).not.toThrow(); + }); + + it("blocks even an explicit transfer when delivery history would cross project boundaries", () => { + let conflict: unknown; + try { + assertRepositoryTransferHasNoDeliveryHistory("Acme/platform", 2); + } catch (error) { + conflict = error; + } + + expect(conflict).toBeInstanceOf(GiteaRepositoryTransferBlockedError); + expect(integrationError(conflict)).toMatchObject({ + status: 409, + code: "GITEA_REPOSITORY_TRANSFER_BLOCKED", + }); + expect(() => assertRepositoryTransferHasNoDeliveryHistory("Acme/platform", 0)).not.toThrow(); + }); +}); diff --git a/backend/test/review-attention-policy.test.ts b/backend/test/review-attention-policy.test.ts new file mode 100644 index 0000000..9470ea6 --- /dev/null +++ b/backend/test/review-attention-policy.test.ts @@ -0,0 +1,27 @@ +import { ReviewStatus } from "@prisma/client"; +import { describe, expect, it } from "vitest"; +import { reviewRequiresHumanAttention } from "../src/repositories/reviewAttentionPolicy.js"; + +const review = (overrides: Partial<{ status: ReviewStatus; source: string; sourceType: string; createdBy: string }> = {}) => ({ + status: ReviewStatus.pending, + source: "projectbrain_runtime", + sourceType: "documentation_update", + createdBy: "api", + ...overrides, +}); + +describe("review attention policy", () => { + it("keeps genuine unresolved reviews visible", () => { + expect(reviewRequiresHumanAttention(review())).toBe(true); + }); + + it("keeps seed and smoke evidence out of the owner's attention count", () => { + expect(reviewRequiresHumanAttention(review({ createdBy: "seed" }))).toBe(false); + expect(reviewRequiresHumanAttention(review({ source: "rc41-smoke-proof" }))).toBe(false); + expect(reviewRequiresHumanAttention(review({ sourceType: "SMOKE_IMPORT" }))).toBe(false); + }); + + it("requires an unresolved status", () => { + expect(reviewRequiresHumanAttention(review({ status: ReviewStatus.accepted }))).toBe(false); + }); +}); diff --git a/backend/test/review-capture-state-machine.test.ts b/backend/test/review-capture-state-machine.test.ts new file mode 100644 index 0000000..fb5bcfc --- /dev/null +++ b/backend/test/review-capture-state-machine.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { loadEnv } from "../src/config/env.js"; + +const ownerPassword = "correct-horse-battery-staple"; +const appEnv = () => loadEnv({ + NODE_ENV: "test", + LOG_LEVEL: "silent", + PROJECTBRAIN_DATA_SOURCE: "fixture", + PROJECTBRAIN_OWNER_USERNAME: "owner", + PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, + PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", + PROJECTBRAIN_SESSION_TTL_HOURS: "1", + PROJECTBRAIN_TRUST_PROXY: "false", +}); + +const apps: Awaited>[] = []; + +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); +}); + +describe("capture and review state machines", () => { + it("classifies once, queues once, and keeps final review decisions immutable", async () => { + const app = await buildApp({ env: appEnv() }); + apps.push(app); + const login = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "owner", password: ownerPassword }, + }); + const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session"); + const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`; + const csrfToken = login.json().data.csrfToken as string; + let operation = 0; + const mutate = (method: "POST" | "PATCH", url: string, payload: Record = {}) => + app.inject({ + method, + url, + payload, + headers: { + cookie, + "x-csrf-token": csrfToken, + "idempotency-key": `review-state-machine-${++operation}`, + }, + }); + + const capture = await mutate("POST", "/api/projects/widefrog/capture-items", { + type: "note", + content: "A decision that must pass through one governed review.", + source: "state-machine-test", + }); + expect(capture.statusCode).toBe(201); + const captureId = capture.json().data.id as string; + + const prematureQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`); + expect(prematureQueue.statusCode).toBe(409); + expect(prematureQueue.json().error.code).toBe("CAPTURE_NOT_CLASSIFIED"); + + expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${captureId}/classify`)).statusCode).toBe(200); + const queued = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`); + expect(queued.statusCode).toBe(201); + const reviewId = queued.json().data.createdEntityId as string; + + const duplicateQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`); + expect(duplicateQueue.statusCode).toBe(409); + expect(duplicateQueue.json().error.code).toBe("CAPTURE_ALREADY_IN_REVIEW"); + const reclassify = await mutate("PATCH", `/api/projects/widefrog/capture-items/${captureId}/classify`); + expect(reclassify.statusCode).toBe(409); + expect(reclassify.json().error.code).toBe("CAPTURE_ALREADY_IN_REVIEW"); + + const edited = await mutate("PATCH", `/api/projects/widefrog/review-queue/${reviewId}`, { + description: "Edited review content that still needs a final decision.", + }); + expect(edited.statusCode).toBe(200); + expect(edited.json().data.description).toContain("Edited review content"); + + const invalidMerge = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/merge`, { + targetEntityId: "does-not-exist", + }); + expect(invalidMerge.statusCode).toBe(409); + expect(invalidMerge.json().error.code).toBe("REVIEW_TARGET_NOT_FOUND"); + + const unsupportedConversion = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/convert`, { + target: "decision", + }); + expect(unsupportedConversion.statusCode).toBe(501); + expect(unsupportedConversion.json().error.code).toBe("DATABASE_MODE_REQUIRED"); + + expect((await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`)).statusCode).toBe(200); + const overwrite = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/reject`); + expect(overwrite.statusCode).toBe(409); + expect(overwrite.json().error.code).toBe("REVIEW_ALREADY_DECIDED"); + const editFinal = await mutate("PATCH", `/api/projects/widefrog/review-queue/${reviewId}`, { notes: "overwrite" }); + expect(editFinal.statusCode).toBe(409); + expect(editFinal.json().error.code).toBe("REVIEW_ALREADY_DECIDED"); + }); +}); diff --git a/backend/test/system-readiness.test.ts b/backend/test/system-readiness.test.ts new file mode 100644 index 0000000..e25304d --- /dev/null +++ b/backend/test/system-readiness.test.ts @@ -0,0 +1,96 @@ +import Fastify from "fastify"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { loadEnv } from "../src/config/env.js"; +import type { AutonomyRuntimeSnapshot } from "../src/services/autonomyRuntime.js"; +import { registerSystemReadinessRoutes } from "../src/routes/systemReadiness.js"; + +const mocks = vi.hoisted(() => ({ runtime: vi.fn(), query: vi.fn(), worker: vi.fn() })); +vi.mock("../src/services/autonomyRuntime.js", () => ({ loadAutonomyRuntime: mocks.runtime })); +vi.mock("../src/db/prisma.js", () => ({ prisma: { + $queryRaw: mocks.query, + project: { count: vi.fn(async () => 1) }, + blueprintGraph: { count: vi.fn(async () => 1) }, + portfolioRepository: { count: vi.fn(async () => 1) }, + giteaRepositoryLink: { count: vi.fn(async () => 1) }, + analysisWorkerHeartbeat: { findUnique: mocks.worker }, + analysisJob: { count: vi.fn(async () => 0) }, +} })); + +const snapshot = (): AutonomyRuntimeSnapshot => ({ + executors: [], projects: [], + counts: { connectedExecutors: 0, enabledProjects: 0, claimableProjects: 0, + activeProjects: 0, pendingWorkItems: 0, blockedProjects: 0, + waitingRoadmapProjects: 0, stalledProjects: 0 }, +}); +const apps: ReturnType[] = []; +const requestReadiness = async (runtime: AutonomyRuntimeSnapshot) => { + mocks.runtime.mockResolvedValue(runtime); + const app = Fastify(); + apps.push(app); + await registerSystemReadinessRoutes(app, { env: loadEnv({ + NODE_ENV: "test", PROJECTBRAIN_DATA_SOURCE: "database", + DATABASE_URL: "postgresql://fixture:fixture@localhost:5432/fixture", + PROJECTBRAIN_OWNER_USERNAME: "fixture-owner", + PROJECTBRAIN_OWNER_PASSWORD: "fixture-only-owner-password", + PROJECTBRAIN_AUTH_SECRET: "fixture-only-auth-secret-at-least-32-characters", + }) }); + const response = await app.inject({ method: "GET", url: "/api/system/readiness" }); + expect(response.statusCode).toBe(200); + return response.json().data; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.query.mockResolvedValue([{ count: 1n }]); + mocks.worker.mockResolvedValue({ status: "ready", lastSeenAt: new Date() }); +}); +afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); + +describe("autonomy readiness demand", () => { + it.each([false, true])("does not require an executor when all Auto work is paused (historical heartbeat: %s)", async (historical) => { + const runtime = snapshot(); + if (historical) runtime.executor = { status: "stale", instanceId: "old-web-task", + startedAt: "2026-01-01T00:00:00.000Z", lastSeenAt: "2026-01-01T00:00:00.000Z" }; + const data = await requestReadiness(runtime); + expect(data.status).toBe("ready"); + expect(data.checks.find((check: { id: string }) => check.id === "autonomy-executor")).toMatchObject({ status: "skip" }); + expect(data.autonomyExecutor).toMatchObject({ status: "ready", connectedExecutors: 0 }); + expect(data.autonomyExecutor.lastSeenAt).toBe(runtime.executor?.lastSeenAt); + }); + + it.each(["enabledProjects", "claimableProjects", "activeProjects", "pendingWorkItems"] as const)( + "preserves warning without an executor when %s is positive", async (count) => { + const runtime = snapshot(); + runtime.counts[count] = 1; + const data = await requestReadiness(runtime); + expect(data.status).toBe("degraded"); + expect(data.checks.find((check: { id: string }) => check.id === "autonomy-executor")).toMatchObject({ status: "warn" }); + expect(data.autonomyExecutor.connectedExecutors).toBe(0); + }, + ); + + it("keeps fresh connected executor and pending work ready", async () => { + const runtime = snapshot(); + runtime.counts.connectedExecutors = runtime.counts.enabledProjects = runtime.counts.pendingWorkItems = 1; + const data = await requestReadiness(runtime); + expect(data.status).toBe("ready"); + expect(data.checks.find((check: { id: string }) => check.id === "autonomy-executor")).toMatchObject({ status: "pass" }); + }); + + it("retains a stale heartbeat warning when enabled work still needs the executor", async () => { + const runtime = snapshot(); + runtime.counts.enabledProjects = runtime.counts.pendingWorkItems = 1; + runtime.executor = { status: "stale", instanceId: "old-web-task", + startedAt: "2026-01-01T00:00:00.000Z", lastSeenAt: "2026-01-01T00:00:00.000Z" }; + const data = await requestReadiness(runtime); + expect(data.status).toBe("degraded"); + expect(data.autonomyExecutor).toMatchObject({ status: "degraded", connectedExecutors: 0, + lastSeenAt: runtime.executor.lastSeenAt }); + expect(data.checks.find((check: { id: string }) => check.id === "autonomy-executor")).toMatchObject({ status: "warn" }); + }); + + it("does not suppress other readiness warnings while Auto is paused", async () => { + mocks.worker.mockResolvedValue(null); + expect((await requestReadiness(snapshot())).status).toBe("degraded"); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..2a63b42 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..9723780 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/security/**/*.ts", "src/plugins/authentication.ts", "src/routes/auth.ts"], + thresholds: { statements: 70, branches: 60, functions: 70, lines: 70 }, + }, + }, +}); diff --git a/components.json b/components.json new file mode 100644 index 0000000..62e1011 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/config/architecture-budgets.json b/config/architecture-budgets.json new file mode 100644 index 0000000..9cadfc6 --- /dev/null +++ b/config/architecture-budgets.json @@ -0,0 +1,32 @@ +{ + "maximumNewSourceLines": 800, + "temporaryFileExceptions": { + "src/shared/api/b1ReadBackend.ts": { "maxLines": 4050, "reason": "Development-only compatibility backend; frozen against endpoint growth." }, + "src/shared/lib/projectAudit.ts": { "maxLines": 3700, "reason": "Historical audit registry pending data extraction." }, + "src/shared/contexts/ProjectContext.tsx": { "maxLines": 630, "reason": "Composition boundary only; data-source, read-model and all cohesive project command lifecycles are extracted." }, + "src/features/projects/pages/RuntimeReadiness.tsx": { "maxLines": 2450, "reason": "Operator panels pending service extraction." }, + "src/features/repo-builder/pages/RepoBuilder.tsx": { "maxLines": 1850, "reason": "Repository workflow pending hook extraction." }, + "src/shared/lib/model.ts": { "maxLines": 1700, "reason": "Legacy aggregate domain model pending package split." }, + "backend/src/types/api.ts": { "maxLines": 1600, "reason": "Legacy transport declarations pending shared-contract migration." }, + "src/features/history/pages/ProjectHistory.tsx": { "maxLines": 1560, "reason": "History filters and views pending component extraction." }, + "src/features/operations/lib/operationRuntime.ts": { "maxLines": 1550, "reason": "Runtime orchestration pending service split." }, + "src/shared/lib/mock-data.ts": { "maxLines": 1500, "reason": "Development fixtures; production bundle exclusion is checked separately." }, + "src/shared/api/realClient.ts": { "maxLines": 1460, "reason": "Generated-client migration boundary." }, + "src/features/projects/pages/Workspace.tsx": { "maxLines": 1250, "reason": "Workspace simplification is active in Phase 5." }, + "src/shared/api/mockClient.ts": { "maxLines": 1160, "reason": "Development client; production bundle exclusion is checked separately." }, + "src/features/operations/pages/ReviewQueue.tsx": { "maxLines": 1120, "reason": "Review action extraction remains scheduled." }, + "src/shared/lib/blueprintEngine.ts": { "maxLines": 1000, "reason": "Domain engine has independent coverage and is frozen." }, + "src/features/blueprint/pages/BlueprintEngine.tsx": { "maxLines": 930, "reason": "Blueprint workflow awaits panel extraction." }, + "src/features/brainstorm/pages/BrainstormLab.tsx": { "maxLines": 880, "reason": "Brainstorm orchestration awaits hook extraction." }, + "src/features/handoffs/pages/CodexHandoffs.tsx": { "maxLines": 830, "reason": "Handoff workflow awaits form extraction." }, + "src/features/release/pages/ReleaseCenter.tsx": { "maxLines": 960, "reason": "Release panels await role-aware disclosure extraction." } + }, + "bundle": { + "maxEntryBytes": 525000, + "maxRouteBytes": 130000, + "routeExceptions": { + "ProjectAuditCenter-": { "maxBytes": 150000, "reason": "Historical audit registry is intentionally isolated and pending data extraction." }, + "RuntimeReadiness-": { "maxBytes": 205000, "reason": "Operator readiness panels are isolated and pending service extraction." } + } + } +} diff --git a/config/v1-contract-freeze.json b/config/v1-contract-freeze.json new file mode 100644 index 0000000..09530bc --- /dev/null +++ b/config/v1-contract-freeze.json @@ -0,0 +1,34 @@ +{ + "version": "1.0.0", + "migrations": [ + "20260606000000_init_project_workspace", + "20260606010000_p5_capture_review_parity", + "20260606020000_p6_truth_updates_operating_log", + "20260606030000_p7_current_state_handoff_codex", + "20260606040000_p8_docs_knowledge_memory", + "20260609070000_v52_blueprint_persistence", + "20260609120000_v68_brainstorm_cpu_persistence", + "20260609130000_v72_initiatives_persistence", + "20260624150000_rc39_release_evidence_records", + "20260729000000_v1_authentication", + "20260821000000_gitea_mcp_ecosystem", + "20260821010000_entra_oidc_login", + "20260825000000_unified_delivery_core", + "20260826020000_delivery_review_feedback", + "20260826030000_async_repository_analysis", + "20260826040000_closed_loop_findings", + "20260826050000_portfolio_control_plane", + "20260829211000_partial_analysis_status", + "20260830000000_git_source_library", + "20260902000000_attention_provenance_cleanup", + "20260902130000_autonomy_executor_heartbeat", + "20260912190000_autonomous_operating_system_foundation", + "20260913090000_portfolio_project_pins" + ], + "hashes": { + "backend/prisma/schema.prisma": "732782755683059eb783c7b24e7c4e1e152ad7c05e6adfd247c35c2e4cb4e876", + "packages/contracts/index.js": "94076de904edfb58ae0457c1da536c9b75e71a08239ef318b0ed78f900803594", + "packages/contracts/index.d.ts": "a41e4e2ea6eec5187e00dc5978e3b1ab83233156154f9f613f06f9510ebf1c7f", + "docs/openapi/projectbrain-v1.json": "03b028346617c694ec4723c89135cb97d3287bd22ed0f2e0620bd2869a84cef4" + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..18002eb --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +services: + postgres: + image: postgres:16-alpine + container_name: projectbrain-postgres + ports: + - "5432:5432" + environment: + POSTGRES_DB: projectbrain + POSTGRES_USER: projectbrain + POSTGRES_PASSWORD: projectbrain + volumes: + - projectbrain-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U projectbrain -d projectbrain"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + projectbrain-postgres-data: diff --git a/docker-compose.release.yml b/docker-compose.release.yml new file mode 100644 index 0000000..ce30f33 --- /dev/null +++ b/docker-compose.release.yml @@ -0,0 +1,18 @@ +services: + frontend: + image: projectbrain-web:${PROJECTBRAIN_RELEASE_SHA:?set PROJECTBRAIN_RELEASE_SHA} + build: null + container_name: projectbrain-${PROJECTBRAIN_DEPLOY_SLOT:-stage}-frontend + + backend: + image: projectbrain-api:${PROJECTBRAIN_RELEASE_SHA:?set PROJECTBRAIN_RELEASE_SHA} + build: null + container_name: projectbrain-${PROJECTBRAIN_DEPLOY_SLOT:-stage}-api + + analysis-worker: + image: projectbrain-api:${PROJECTBRAIN_RELEASE_SHA:?set PROJECTBRAIN_RELEASE_SHA} + build: null + container_name: projectbrain-${PROJECTBRAIN_DEPLOY_SLOT:-stage}-analysis-worker + + postgres: + container_name: projectbrain-${PROJECTBRAIN_DEPLOY_SLOT:-stage}-postgres diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ae3362 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,131 @@ +services: + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + args: + VITE_PROJECTBRAIN_API_URL: ${VITE_PROJECTBRAIN_API_URL:-} + VITE_PROJECTBRAIN_CLIENT_MODE: ${VITE_PROJECTBRAIN_CLIENT_MODE:-real} + VITE_PROJECTBRAIN_RELEASE_TAG: ${VITE_PROJECTBRAIN_RELEASE_TAG:-} + VITE_PROJECTBRAIN_COMMIT: ${VITE_PROJECTBRAIN_COMMIT:-} + VITE_PROJECTBRAIN_BRANCH: ${VITE_PROJECTBRAIN_BRANCH:-} + VITE_PROJECTBRAIN_BUILD_TIME: ${VITE_PROJECTBRAIN_BUILD_TIME:-} + VITE_PROJECTBRAIN_SOURCE: ${VITE_PROJECTBRAIN_SOURCE:-} + container_name: projectbrain-frontend + depends_on: + backend: + condition: service_healthy + ports: + - "${PROJECTBRAIN_WEB_PORT:-1209}:8080" + restart: unless-stopped + + backend: + image: projectbrain-api:local + build: + context: . + dockerfile: backend/Dockerfile + container_name: projectbrain-api + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: 4000 + LOG_LEVEL: ${LOG_LEVEL:-info} + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL to the production PostgreSQL connection string} + PROJECTBRAIN_DATA_SOURCE: ${PROJECTBRAIN_DATA_SOURCE:-database} + PROJECTBRAIN_SEED_ON_START: ${PROJECTBRAIN_SEED_ON_START:-true} + PROJECTBRAIN_OWNER_USERNAME: ${PROJECTBRAIN_OWNER_USERNAME:-owner} + PROJECTBRAIN_OWNER_PASSWORD: ${PROJECTBRAIN_OWNER_PASSWORD:?Set PROJECTBRAIN_OWNER_PASSWORD to a unique secret} + PROJECTBRAIN_AUTH_SECRET: ${PROJECTBRAIN_AUTH_SECRET:?Set PROJECTBRAIN_AUTH_SECRET to at least 32 random characters} + PROJECTBRAIN_SESSION_TTL_HOURS: ${PROJECTBRAIN_SESSION_TTL_HOURS:-24} + # The API is reachable through the frontend proxy only. Trusting that + # hop preserves the real client IP for rate limiting and audit events. + PROJECTBRAIN_TRUST_PROXY: ${PROJECTBRAIN_TRUST_PROXY:-true} + PROJECTBRAIN_GITEA_URL: ${PROJECTBRAIN_GITEA_URL:-} + PROJECTBRAIN_GITEA_TOKEN: ${PROJECTBRAIN_GITEA_TOKEN:-} + PROJECTBRAIN_GITEA_WRITE_TOKEN: ${PROJECTBRAIN_GITEA_WRITE_TOKEN:-} + PROJECTBRAIN_GITEA_WEBHOOK_SECRET: ${PROJECTBRAIN_GITEA_WEBHOOK_SECRET:-} + PROJECTBRAIN_MCP_ENABLED: ${PROJECTBRAIN_MCP_ENABLED:-false} + PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE: ${PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE:-1200} + PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: ${PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES:-true} + PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: ${PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS:-60} + PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS: ${PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS:-5400} + PROJECTBRAIN_HUB_SERVICE_TOKEN: ${PROJECTBRAIN_HUB_SERVICE_TOKEN:-} + PROJECTBRAIN_PUBLIC_URL: ${PROJECTBRAIN_PUBLIC_URL:-} + PROJECTBRAIN_ENTRA_TENANT_ID: ${PROJECTBRAIN_ENTRA_TENANT_ID:-} + PROJECTBRAIN_ENTRA_CLIENT_ID: ${PROJECTBRAIN_ENTRA_CLIENT_ID:-} + PROJECTBRAIN_ENTRA_CLIENT_SECRET: ${PROJECTBRAIN_ENTRA_CLIENT_SECRET:-} + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/api/system/readiness').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + restart: unless-stopped + + analysis-worker: + image: projectbrain-api:local + build: + context: . + dockerfile: backend/Dockerfile + container_name: projectbrain-analysis-worker + command: ["node", "dist/workers/analysisWorker.js"] + depends_on: + backend: + condition: service_healthy + environment: + NODE_ENV: production + HOST: 0.0.0.0 + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL to the production PostgreSQL connection string} + PROJECTBRAIN_DATA_SOURCE: database + PROJECTBRAIN_OWNER_USERNAME: ${PROJECTBRAIN_OWNER_USERNAME:-owner} + PROJECTBRAIN_OWNER_PASSWORD: ${PROJECTBRAIN_OWNER_PASSWORD:?Set PROJECTBRAIN_OWNER_PASSWORD to a unique secret} + PROJECTBRAIN_AUTH_SECRET: ${PROJECTBRAIN_AUTH_SECRET:?Set PROJECTBRAIN_AUTH_SECRET to at least 32 random characters} + PROJECTBRAIN_GITEA_URL: ${PROJECTBRAIN_GITEA_URL:-} + PROJECTBRAIN_GITEA_TOKEN: ${PROJECTBRAIN_GITEA_TOKEN:-} + PROJECTBRAIN_GITEA_WRITE_TOKEN: ${PROJECTBRAIN_GITEA_WRITE_TOKEN:-} + PROJECTBRAIN_GITEA_WEBHOOK_SECRET: ${PROJECTBRAIN_GITEA_WEBHOOK_SECRET:-} + PROJECTBRAIN_MCP_ENABLED: ${PROJECTBRAIN_MCP_ENABLED:-false} + PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE: ${PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE:-1200} + PROJECTBRAIN_PUBLIC_URL: ${PROJECTBRAIN_PUBLIC_URL:-} + PROJECTBRAIN_ANALYSIS_WORKER_PORT: 4001 + PROJECTBRAIN_ANALYSIS_POLL_MS: ${PROJECTBRAIN_ANALYSIS_POLL_MS:-1000} + PROJECTBRAIN_ANALYSIS_BATCH_SIZE: ${PROJECTBRAIN_ANALYSIS_BATCH_SIZE:-5} + PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY: ${PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY:-2} + PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS: ${PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS:-1800} + PROJECTBRAIN_ANALYSIS_STALE_SECONDS: ${PROJECTBRAIN_ANALYSIS_STALE_SECONDS:-300} + PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES: ${PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES:-1440} + PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES: ${PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES:-true} + PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS: ${PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS:-60} + NODE_OPTIONS: --max-old-space-size=${PROJECTBRAIN_ANALYSIS_WORKER_HEAP_MB:-384} + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + mem_limit: ${PROJECTBRAIN_ANALYSIS_WORKER_MEMORY_LIMIT:-768m} + cpus: ${PROJECTBRAIN_ANALYSIS_WORKER_CPU_LIMIT:-1.0} + pids_limit: ${PROJECTBRAIN_ANALYSIS_WORKER_PIDS_LIMIT:-128} + restart: unless-stopped + + postgres: + image: postgres:16-alpine + container_name: projectbrain-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-projectbrain} + POSTGRES_USER: ${POSTGRES_USER:-projectbrain} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD to a unique secret} + volumes: + - projectbrain-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-projectbrain} -d $${POSTGRES_DB:-projectbrain}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + projectbrain-postgres-data: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..14797d5 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,66 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + client_max_body_size 1m; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always; + + location /api/ { + proxy_pass http://backend:4000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "no-store" always; + } + + location = /mcp { + proxy_pass http://backend:4000/mcp; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_connect_timeout 5s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + add_header Cache-Control "no-store" always; + } + + location /.well-known/ { + proxy_pass http://backend:4000/.well-known/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store" always; + } + + location /oauth/ { + proxy_pass http://backend:4000/oauth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store" always; + } + + location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docker/nginx.single.conf b/docker/nginx.single.conf new file mode 100644 index 0000000..58931d6 --- /dev/null +++ b/docker/nginx.single.conf @@ -0,0 +1,66 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + client_max_body_size 1m; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always; + + location /api/ { + proxy_pass http://127.0.0.1:4000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + add_header Cache-Control "no-store" always; + } + + location = /mcp { + proxy_pass http://127.0.0.1:4000/mcp; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_connect_timeout 5s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + add_header Cache-Control "no-store" always; + } + + location /.well-known/ { + proxy_pass http://127.0.0.1:4000/.well-known/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store" always; + } + + location /oauth/ { + proxy_pass http://127.0.0.1:4000/oauth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store" always; + } + + location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docker/unraid/projectbrain-container.env.example b/docker/unraid/projectbrain-container.env.example new file mode 100644 index 0000000..15e6968 --- /dev/null +++ b/docker/unraid/projectbrain-container.env.example @@ -0,0 +1,65 @@ +PROJECTBRAIN_WEB_PORT=1209 + +# Single-container app image settings. +PROJECTBRAIN_IMAGE=projectbrain-unraid:latest +PROJECTBRAIN_CONTAINER_NAME=projectbrain +PROJECTBRAIN_DOCKER_NETWORK=auto +PROJECTBRAIN_STOP_COMPOSE_APP_SERVICES=true +PROJECTBRAIN_REMOVE_OLD_POSTGRES=true +PROJECTBRAIN_APPDATA_PATH=/mnt/pool/appdata/projectbrain +PROJECTBRAIN_DEPLOY_NO_CACHE=false +PROJECTBRAIN_MIN_DOCKER_FREE_MB=2048 +PROJECTBRAIN_DOCKER_STORAGE_STRICT=false +PROJECTBRAIN_BACKUP_DIR=/mnt/pool/appdata/projectbrain-backups +PROJECTBRAIN_BACKUP_MAX_AGE_DAYS=2 +PROJECTBRAIN_REQUIRE_FRESH_BACKUP=false + +# Frontend is built for same-origin /api proxying inside the container. +VITE_PROJECTBRAIN_CLIENT_MODE=real +VITE_PROJECTBRAIN_API_URL= + +# Runtime backend settings. +PROJECTBRAIN_DATA_SOURCE=database +PROJECTBRAIN_SEED_ON_START=true +LOG_LEVEL=info +PROJECTBRAIN_USE_EXTERNAL_DATABASE=false +PROJECTBRAIN_OWNER_USERNAME=owner +PROJECTBRAIN_OWNER_PASSWORD= +PROJECTBRAIN_AUTH_SECRET= +PROJECTBRAIN_SESSION_TTL_HOURS=24 +PROJECTBRAIN_TRUST_PROXY=true +PROJECTBRAIN_GITEA_URL=https://gitea.example.com +PROJECTBRAIN_GITEA_TOKEN= +PROJECTBRAIN_GITEA_WRITE_TOKEN= +PROJECTBRAIN_GITEA_WEBHOOK_SECRET= +PROJECTBRAIN_MCP_ENABLED=false +PROJECTBRAIN_MCP_RATE_LIMIT_PER_MINUTE=1200 +PROJECTBRAIN_ANALYSIS_WORKER_PORT=4001 +PROJECTBRAIN_ANALYSIS_POLL_MS=1000 +PROJECTBRAIN_ANALYSIS_BATCH_SIZE=5 +PROJECTBRAIN_ANALYSIS_REPOSITORY_CONCURRENCY=2 +PROJECTBRAIN_ANALYSIS_JOB_TIMEOUT_SECONDS=1800 +PROJECTBRAIN_ANALYSIS_STALE_SECONDS=300 +PROJECTBRAIN_ANALYSIS_SCHEDULE_MINUTES=1440 +PROJECTBRAIN_AUTO_ONBOARD_REPOSITORIES=true +PROJECTBRAIN_REPOSITORY_RECONCILE_SECONDS=60 +PROJECTBRAIN_AUTONOMY_EXECUTOR_STALE_SECONDS=5400 +PROJECTBRAIN_API_HEAP_MB=512 +PROJECTBRAIN_ANALYSIS_WORKER_HEAP_MB=384 +PROJECTBRAIN_MEMORY_LIMIT=1536m +PROJECTBRAIN_MEMORY_RESERVATION=1024m +PROJECTBRAIN_CPU_LIMIT=2.0 +PROJECTBRAIN_PIDS_LIMIT=256 +PROJECTBRAIN_HUB_SERVICE_TOKEN= +PROJECTBRAIN_PUBLIC_URL=https://projectbrain.example.com + +# Optional Microsoft Entra ID login. Register this exact Web redirect URI: +# https://projectbrain.example.com/api/auth/entra/callback +PROJECTBRAIN_ENTRA_TENANT_ID= +PROJECTBRAIN_ENTRA_CLIENT_ID= +PROJECTBRAIN_ENTRA_CLIENT_SECRET= + +# Embedded PostgreSQL settings inside the single ProjectBrain container. +POSTGRES_DB=projectbrain +POSTGRES_USER=projectbrain +POSTGRES_PASSWORD= diff --git a/docker/unraid/projectbrain-icon.png b/docker/unraid/projectbrain-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dde2b205b6849069a1b792955c856511ede9c858 GIT binary patch literal 29208 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 u*U#=#Db!bJ*+rst0}CUO&7%r6pKlA!7*~4v!GC +PROJECTBRAIN_ENTRA_CLIENT_ID= +PROJECTBRAIN_ENTRA_CLIENT_SECRET= +``` + +Keep `PROJECTBRAIN_PUBLIC_URL=https://projectbrain.example.test` and `PROJECTBRAIN_DATA_SOURCE=database`. Restart or redeploy ProjectBrain after changing the values. Never place the client credential in a `VITE_` variable or browser configuration. + +## First-time account link + +1. Sign in with the local owner account. +2. Open **Settings → Microsoft Entra ID**. +3. Choose **Mijn Microsoft-account koppelen** and complete Microsoft sign-in. +4. Sign out. **Aanmelden met Microsoft** is now available on the login screen. + +ProjectBrain binds the identity to the immutable Entra tenant ID plus object ID (`tid` + `oid`). It deliberately does not auto-link on email address or user principal name. + +## Recovery and rotation + +- Keep the local owner password in the normal secret-management process. +- Unlinking Entra requires the local owner password in addition to the active session and CSRF token. +- Rotate the Entra client credential before expiry and update only the server `.env` value. +- An Entra callback state is one-time, stored only as a SHA-256 hash, and expires after ten minutes. diff --git a/docs/REAL_BACKEND_ARCHITECTURE_DECISION.md b/docs/REAL_BACKEND_ARCHITECTURE_DECISION.md new file mode 100644 index 0000000..92a4c6f --- /dev/null +++ b/docs/REAL_BACKEND_ARCHITECTURE_DECISION.md @@ -0,0 +1,370 @@ +# Real Backend Architecture Decision + +Status: Proposed decision +Date: 2026-06-06 +Scope: Move ProjectBrain from Vite mock middleware to a real backend architecture without implementing that backend yet. + +## Decision + +ProjectBrain should move to a dedicated TypeScript backend using Fastify, PostgreSQL, Prisma, Zod, and a background worker backed by Redis/BullMQ. The current Vite middleware should remain as a dev/demo contract surface until the frontend is switched behind a configuration flag. + +This keeps the product API-first, self-hostable, Docker-friendly, and aligned with the existing TypeScript contract layer while leaving room for later AI-assisted imports, codebase analysis jobs, file uploads, MCP integration, and ChatGPT/custom app connectors. + +## Current Backend-Mock State + +The current backend surface is implemented as Vite dev middleware in `src/shared/api/b1ReadBackend.ts` and mounted from `vite.config.ts` under `/api/*`. + +B1-B6 endpoint families now covered as mock contract surface: + +- B1: Projects, Workspace, DNA, context-pack summary. +- B2: Feature groups, Features, Evidence reads. +- B3: Context Packs and prompt generation mock. +- B4: Checks and review-board mutations. +- B5: Builds, Regressions, Snapshots. +- B6: Insights, Relationships, Knowledge, Memory Rules. + +Current mock limitations: + +- In-memory state only; data resets when the Vite process restarts. +- No database, migrations, transactions, row-level integrity, or durable idempotency store. +- No auth, permissions, tenancy, or user ownership model. +- No real AI calls; prompt/current-state style operations are deterministic mock outputs. +- No file upload storage, parsing pipeline, codebase scan worker, or long-running job runner. +- Vite middleware is development infrastructure, not a production backend boundary. +- Idempotency is process-local and keyed by method, path, and `Idempotency-Key`. + +Where state lives today: + +- `src/shared/lib/mock-data.ts`: central fixtures used by the UI and mock clients. +- `src/shared/api/mockClient.ts`: frontend in-memory store used by `projectBrainClient`. +- `src/shared/api/b1ReadBackend.ts`: separate Vite middleware in-memory state seeded from mock data. + +Contracts and docs currently present: + +- `docs/API_CONTRACT_PREP.md` +- `docs/BACKEND_B1_READ_API.md` +- `docs/BACKEND_B2_FEATURES_EVIDENCE_READ_API.md` +- `docs/BACKEND_B3_CONTEXT_PACKS_PROMPTS_API.md` +- `docs/BACKEND_B4_CHECKS_REVIEW_API.md` +- `docs/BACKEND_B5_BUILDS_REGRESSIONS_SNAPSHOTS_API.md` +- `docs/BACKEND_B6_INSIGHTS_RELATIONSHIPS_KNOWLEDGE_MEMORY_API.md` +- `src/shared/api/types.ts` +- `src/shared/api/contracts.ts` +- `src/shared/api/projectBrainClient.ts` + +Frontend consumption today: + +- UI pages should consume through `projectBrainClient` or higher-level selectors/actions. +- `projectBrainClient` currently delegates to `mockClient`. +- The Vite middleware is not yet the frontend source of truth. +- Future switching should happen inside the client boundary, not by changing pages directly. + +## Backend Options + +### A) Node/Fastify Backend + +Pros: + +- Best fit with the existing TypeScript contracts and model types. +- Shared Zod schemas can validate both API requests and frontend clients. +- Fastify has strong performance, structured plugins, JSON schema support, and good Docker ergonomics. +- Natural fit for API-first design, MCP servers, ChatGPT app connectors, and local self-hosting. +- Background jobs can use BullMQ/Redis with a separate worker process. +- File upload/import endpoints can stream to local disk or S3-compatible storage. + +Cons: + +- Codebase analysis and ML-heavy pipelines may eventually want Python helpers. +- Requires discipline around service/repository boundaries to avoid a large Node monolith. +- Worker deployment adds another runtime process. + +Migration effort: + +- Moderate. Existing TypeScript DTOs, contract docs, and mock behavior can be ported incrementally. + +Fit: + +- AI/codebase analysis later: good for orchestration; Python sidecars can be added for specialist analysis. +- File uploads/imports later: good. +- Self-hosting: excellent. +- Docker deployment: excellent. +- Future MCP/ChatGPT connector/API: excellent. + +### B) FastAPI Backend + +Pros: + +- Excellent for Python-native AI/code analysis, embeddings, parsing, and background processing. +- Strong OpenAPI generation and Pydantic validation. +- Good for future ML-heavy worker code. + +Cons: + +- Introduces a second primary language while the app contracts are already TypeScript. +- DTO drift risk between frontend TypeScript and backend Python models. +- More migration work to preserve existing TypeScript contract shape. +- Frontend and backend type sharing becomes generated rather than native. + +Migration effort: + +- Moderate to high. Requires translating contracts into Pydantic or generating schemas carefully. + +Fit: + +- AI/codebase analysis later: excellent. +- File uploads/imports later: excellent. +- Self-hosting: good. +- Docker deployment: good. +- Future MCP/ChatGPT connector/API: good, but TypeScript app connector work may be less direct. + +### C) Next.js-Style Fullstack + +Pros: + +- Single deployment surface if the product were already Next.js. +- API routes and UI can live together. + +Cons: + +- Not applicable without migrating the existing Vite SPA. +- Would create a framework migration before the backend architecture is settled. +- Long-running imports and codebase analysis jobs still need separate worker infrastructure. + +Migration effort: + +- High and not justified now. + +Fit: + +- AI/codebase analysis later: mixed. +- File uploads/imports later: good with extra worker setup. +- Self-hosting: good. +- Docker deployment: good. +- Future MCP/ChatGPT connector/API: good, but not worth the app migration cost. + +### D) Keep Vite Middleware Only + +Pros: + +- Lowest immediate effort. +- Good demo and contract-testing surface. +- Useful during frontend prototyping. + +Cons: + +- Not a production backend. +- No durable persistence, job runner, upload pipeline, auth boundary, or deployment separation. +- Dev server lifecycle makes idempotency and state unreliable. +- Encourages backend growth inside frontend tooling. + +Migration effort: + +- None short-term, but high long-term cleanup cost if it keeps expanding. + +Fit: + +- AI/codebase analysis later: poor. +- File uploads/imports later: poor. +- Self-hosting: poor for production. +- Docker deployment: demo only. +- Future MCP/ChatGPT connector/API: poor as a durable API target. + +## Recommended Architecture + +Use a dedicated Fastify backend in TypeScript. + +Recommended stack: + +- Runtime/framework: Node.js with Fastify. +- Database: PostgreSQL. +- ORM/query layer: Prisma initially, with raw SQL escape hatches for graph/query-heavy paths. +- Validation/schema: Zod as source-level request/response validation, optionally exported to OpenAPI. +- Background jobs: BullMQ with Redis, split into `api` and `worker` processes. +- File storage: local filesystem volume for self-hosting first, with S3-compatible adapter later. +- API structure: versioned REST under `/api/v1`, preserving current envelopes. +- Logging: Pino structured logs with request IDs. +- Error handling: centralized Fastify error handler returning the existing error envelope. +- Config/env: `.env` for local, `.env.example`, environment-specific Docker Compose values. +- Docker: `frontend`, `api`, `worker`, `postgres`, `redis`, and optional `storage` volume. + +Why this choice: + +- Keeps TypeScript contracts close to the frontend. +- Avoids a framework migration. +- Supports self-hosting and Docker from the beginning. +- Leaves room for Python analysis workers later without making Python the main API boundary. +- Fits future MCP/ChatGPT/custom app API use because the backend becomes a stable HTTP service. + +## Proposed Folder Structure + +```text +apps/ + web/ + # current Vite React app, moved later or referenced in place + api/ + src/ + server.ts + app.ts + config/ + plugins/ + routes/ + v1/ + projects.routes.ts + workspace.routes.ts + features.routes.ts + context-packs.routes.ts + checks.routes.ts + builds.routes.ts + knowledge.routes.ts + services/ + repositories/ + schemas/ + errors/ + idempotency/ + logging/ + worker/ + src/ + worker.ts + jobs/ + processors/ +packages/ + contracts/ + src/ + api-types.ts + schemas.ts + envelopes.ts +``` + +This repo does not need to be reorganized immediately. R1 can start with a smaller `backend/` folder if a monorepo move is too disruptive. + +## API Versioning + +- New backend routes should use `/api/v1`. +- Current Vite middleware may remain under `/api` for dev/demo until switch-over. +- Response envelope remains: + +```json +{ "data": {}, "meta": { "requestId": "...", "timestamp": "..." } } +``` + +- Error envelope remains: + +```json +{ "error": { "code": "...", "message": "...", "validation": [] }, "meta": { "requestId": "...", "timestamp": "..." } } +``` + +- Idempotency remains required for write endpoints using `Idempotency-Key`, but should move to a durable table: + - method + - path + - key + - request hash + - response body + - status code + - expiresAt + +## Migration Strategy + +### R1: Real backend skeleton + +- Add Fastify API skeleton. +- Add health endpoint. +- Add shared envelope helpers. +- Add request ID middleware. +- Add config/env validation. +- Add Docker Compose with Postgres and Redis. +- Do not switch frontend yet. + +### R2: Projects / Workspace / DNA reads + +- Implement B1 against database-backed repositories. +- Seed database from `mock-data.ts`. +- Keep Vite middleware as fallback/demo. + +### R3: Features / Evidence reads + +- Implement B2. +- Add feature/evidence schema migrations. +- Preserve pagination and filter response shape. + +### R4: Context Packs / Prompts mock generation + +- Implement context-pack reads/writes. +- Keep prompt generation deterministic mock-only. +- Store generated prompt records in the database. + +### R5: Checks / Review mutations + +- Implement checks, review board grouping, review actions, conversions. +- Add durable idempotency. + +### R6: Builds / Regressions / Snapshots + +- Implement build journal, regression memory, snapshot compare. +- Keep compare deterministic until richer diffing exists. + +### R7: Knowledge / Memory / Relationships / Insights + +- Implement B6 endpoints against the database. +- Preserve include-in-context-pack behavior. +- Add relationship integrity checks. + +### R8: Frontend API switch + +- Update `projectBrainClient` to choose real HTTP client or `mockClient` via config flag. +- Pages must continue to use `projectBrainClient`, selectors, and actions only. +- No page-level direct fetch calls. + +### R9: Persistence hardening + +- Add migrations, seed scripts, backup/restore guidance, indexes, idempotency TTL cleanup, and integration tests. + +### R10: Import/codebase analysis jobs + +- Add upload endpoints and job creation. +- Add worker processors for ZIP/codebase/document analysis. +- Add optional Python sidecar only for analysis tasks that genuinely need Python libraries. + +## Source of Truth Transition + +- `mock-data.ts` remains a fixture and demo seed source. +- The real PostgreSQL database becomes source of truth after R8/R9. +- Seed scripts should transform fixture data into database rows. +- Migration scripts should be repeatable for local self-hosting. +- The frontend must keep using `projectBrainClient`; switching source should be a client configuration concern. +- Vite middleware should be retired only after the real backend covers B1-B6 and the frontend switch is stable. + +## Docker / Self-Hosting Notes + +Recommended services: + +- `web`: Vite-built static frontend served by Nginx or a small Node static server. +- `api`: Fastify HTTP API. +- `worker`: BullMQ worker for imports and analysis. +- `postgres`: ProjectBrain database. +- `redis`: queue and job coordination. +- `storage`: mounted local volume for uploads and generated artifacts. + +Self-hosting defaults: + +- No paid API required for core CRUD/project memory. +- AI providers configured through optional environment variables later. +- Local file storage first, S3-compatible storage optional. +- Health checks for `web`, `api`, `worker`, `postgres`, and `redis`. + +## Alternatives Rejected + +- FastAPI as primary API: strong for analysis, but higher contract drift risk and more translation work from existing TypeScript contracts. +- Next.js fullstack: would require a frontend framework migration before backend needs justify it. +- Vite middleware as production backend: useful contract scaffold, but not durable or deployable as the product backend. + +## Open Questions + +- Should the repo become a formal monorepo now, or should R1 start with a `backend/` folder? +- Should Prisma be the long-term ORM, or should graph-heavy relationship queries move to Kysely/raw SQL later? +- Should file storage stay local-only for v1 self-hosting, or ship an S3-compatible adapter in the first backend release? +- What auth model is desired later: local admin, single-user self-hosted, or multi-user teams? +- Should Python analysis workers be planned from R10 or deferred until codebase import requirements are precise? + +## Final Recommendation + +Proceed with R1 as a dedicated Fastify backend skeleton, keeping the Vite middleware untouched as a contract/demo reference. Do not add B7 to the middleware. The next implementation phase should create the real backend foundation and implement only health/config/envelope plumbing before porting B1. diff --git a/docs/UX_AUTONOMY_PRODUCT_CONTRACT.md b/docs/UX_AUTONOMY_PRODUCT_CONTRACT.md new file mode 100644 index 0000000..438906e --- /dev/null +++ b/docs/UX_AUTONOMY_PRODUCT_CONTRACT.md @@ -0,0 +1,403 @@ +# ProjectBrain UX & Autonomy Product Contract + +Status: normative product contract + +## 1. Product promise + +ProjectBrain is a project operating system, not a collection of technical modules the user must operate manually. + +The default user flow is: + +```text +User intent + -> ProjectBrain understands context + -> ProjectBrain prepares bounded work + -> ProjectBrain executes what policy allows + -> ProjectBrain validates + -> ProjectBrain returns a result +``` + +ProjectBrain interrupts the user only when a real human decision is required. + +## 2. User-facing mental model + +The standard product vocabulary consists of five primary concepts: + +1. **Project** — everything ProjectBrain knows and manages for one software project. +2. **Opdracht** — a requested outcome, investigation, fix, feature or improvement. +3. **Beslissing** — a choice ProjectBrain cannot responsibly make autonomously. +4. **Resultaat** — completed work plus understandable validation evidence. +5. **Activiteit** — the durable chronological record of what happened. + +Technical domain objects remain valid internally but are not primary navigation concepts. + +| Internal concept | Default user presentation | +| --- | --- | +| Initiative | Opdracht / beslissing | +| Feature Delivery | Uitvoering van een opdracht | +| Review Queue | Jouw aandacht | +| Execution Feedback | Resultaat / bewijs | +| Context Pack | Internal AI context | +| Repository Intake | Automatic repository analysis | +| Repository Gap | Verbeterpunt / technical detail | +| Skill | Internal execution profile | +| Handoff | Internal transfer artifact | +| Session Pack | Internal execution artifact | +| Finding | Probleem / bevinding | +| Gitea Actions | Validation evidence | +| Build Loop | Technical orchestration diagnostics | +| Auto Mode controller | Technical autonomy diagnostics | + +## 3. Primary navigation contract + +A normal project exposes exactly five primary destinations: + +1. **Overzicht** +2. **Opdracht geven** +3. **Jouw aandacht** +4. **Resultaten** +5. **Activiteit** + +Repository workbenches, findings, Build Loop, Auto Mode controller/runtime diagnostics, runtime readiness, architecture, source internals, skills, handoffs, audits and release evidence belong under **Technische details**. + +A new standard feature MUST NOT add another primary project destination without changing this contract intentionally. + +## 4. Canonical lifecycle and user work states + +Every major subsystem projects its detailed state into the same seven-value +canonical lifecycle. This is the machine-facing contract shared by the +frontend and backend: + +```text +ready | running | waiting | needs_user | recovering | failed | done +``` + +The ordinary UI translates those values into the existing five concise user +presentations where a separate queue, recovery or terminal-failure label would +add noise: + +- **ProjectBrain werkt** — ProjectBrain can proceed autonomously and execution is actually active. +- **Jouw beslissing nodig** — a real product, scope, credential, authorization or risk decision is required. +- **Wacht op extern systeem** — ProjectBrain is waiting for CI, repository, deployment, scheduled executor capacity or another external system. +- **Geblokkeerd** — work cannot safely proceed and no automatic recovery is currently possible. +- **Klaar** — the result and evidence are available. + +`ready` means safe to start or claim, `running` means active execution is +confirmed, `waiting` means an external step is pending, `needs_user` is a real +human decision, `recovering` is bounded technical recovery, `failed` is a +terminal or currently stopped path, and `done` means the committed result is +available. Raw domain states such as `approved`, `validating`, +`pull_request_open`, `watch`, `current` or `pending` may be shown only in +technical details. + +### 4.1 Auto Mode truth model + +Auto Mode MUST expose three independent facts instead of collapsing them into one `running` label: + +1. **Desired state** — Auto is on/off because the user enabled or disabled autonomous execution. +2. **Operational state** — a scheduled executor is connected/recent, stale or offline. +3. **Work state** — queued, working, waiting, retrying, needs-decision or completed. + +`enabled=true` MUST NOT by itself be presented as active execution. A project with queued work and no recent executor must explicitly say that Auto is enabled but nothing is currently executing. + +### 4.2 Single project-work authority + +Normal project surfaces MUST derive their current work presentation from the same semantic resolver. At minimum **Projecten**, **Overzicht**, **Opdracht geven** and **Jouw aandacht** must agree on: + +- whether a human decision is required; +- whether work is truly executing; +- whether work is waiting on an external system; +- whether a technical failure is in automatic recovery; +- whether work is merely queued; +- whether a verified result exists. + +A list position or the first returned delivery MUST NOT be treated as the current task. Current work is selected by user-impact state first and recency second. A live human-decision state outranks technical activity; active execution outranks queued work; retryable recovery remains operational state rather than human attention. + +Navigation chrome that does not load the live work resolver MUST link to **Overzicht** instead of inventing a potentially stale next action. + +The current implementation authority is `src/shared/lib/projectWorkState.ts`. +`src/shared/lib/projectResume.ts` owns project-entry priority and +`src/shared/lib/routeRegistry.ts` owns the five primary project destinations. +The backend emits the same lifecycle on Auto runtime summaries; these fields +are projections of durable state, not a second persistence model. + +## 5. Delegation and authorization policy + +Default delegation mode: **Autonoom uitvoeren**. + +Policy decisions: + +```text +LOW risk + reversible + -> AUTO_ALLOWED + +MEDIUM risk + reversible + -> AUTO_ALLOWED_WITH_AUDIT + +HIGH risk + -> USER_DECISION_REQUIRED + +DESTRUCTIVE / irreversible + -> USER_DECISION_REQUIRED +``` + +A delivery with `approvalState=approved` is already authorized by ProjectBrain policy. Clients MUST NOT ask the user for another approval. + +A delivery with `approvalState=pending` MUST NOT start repository writes. + +Auto Mode exposes four ordinary profiles: **Voorzichtig**, **Normaal**, +**Autonoom** and **Aangepast**. The first three select bounded, persisted run +settings; individual retry, lease, brainstorm and run-budget controls remain +under Advanced. Profile selection never bypasses risk or authorization policy. + +`start_feature_delivery` and equivalent write paths must fail closed when policy authorization is absent. + +### 5.1 Auto Mode executor host invariant + +The primary Auto Mode executor surface MUST be scheduled tasks in the ordinary ChatGPT web chat with the ProjectBrain Autonomous Loop plug-in attached. Multiple such tasks MAY run concurrently when each owns a different ProjectBrain project. + +The following are explicitly **not supported as the Auto Mode scheduler or primary executor**: + +- Codex desktop or Codex CLI automations; +- ChatGPT Work tasks; +- a local background agent or local cron job; +- a server-side OpenAI API call; +- an alternative model provider silently substituting for ordinary ChatGPT web chat. + +Codex may remain a development or administration tool, but MUST NOT own, wake, or impersonate the production Auto Mode execution loop. A change that moves scheduling away from ordinary ChatGPT web chat is an architecture change and requires an explicit update to this normative contract, its regression tests, and the production operating guide. + +Each scheduled ChatGPT web run is only a wake-up. It MUST reload durable ProjectBrain state, generate one unique `run_id`, reuse that ID for every claim in the run, and stop at the configured item/time budget or an earlier terminal, busy, blocked, or human-decision state. ProjectBrain MUST enforce both budgets durably on the server; prompt compliance alone is insufficient. + +ProjectBrain MUST refresh the matching working heartbeat automatically whenever the run claims or reports work. This keeps ordinary progress observable without spending separate ChatGPT tool calls on redundant keepalives. The run MUST send an explicit heartbeat with its active project only at startup, shutdown, and immediately before and after an external wait that can exceed five minutes. ProjectBrain MUST fence heartbeat ownership per active project and `run_id`: ordinary ChatGPT web tasks may execute different projects concurrently, one run cannot switch projects, overlapping runs cannot replace a current owner of the same project within that project's configured maximum run window plus a bounded grace period, delayed terminal heartbeats from another run are rejected, and the MCP claim boundary MUST reject a run that does not own an accepted working heartbeat for that project. + +Routine Auto Mode MCP responses MUST be context-bounded. State reads default to a summary of the current phase and bounded recent evidence; claim/report responses MUST NOT echo the full roadmap, imported Markdown or completed-lease history. A full state read is an explicit diagnostic action, never the default scheduled-loop path. + +Portfolio scheduling MUST remain starvation-free. Among claimable projects, the project with the oldest recorded run is returned first and a project that has never run precedes one that has. Backlog size MAY break an equal-age tie but MUST NOT let one large roadmap monopolize the ordinary ChatGPT web executor. Busy and blocked projects remain ordered after claimable work. + +Transient infrastructure, network, validation, runner or provider failures MUST be recorded as retryable technical blockers with bounded exponential cooldown. Repeated technical failure MAY increase cooldown and diagnostic severity, but MUST NOT silently become a human blocker merely because a retry counter was reached. Missing credentials, required approval, destructive-risk decisions and genuine owner/product decisions are human blockers and may only be cleared explicitly. + +Human-blocker classification is server-authoritative. Client/model supplied labels are compatibility hints only and MUST NOT be able to turn an ordinary technical failure into a human chore. A human blocker requires positive evidence of credentials, authorization, destructive or irreversible risk, or a genuine owner/product choice. Unknown operational failures fail toward retryable recovery, not toward human escalation. + +A report for an expired lease MAY be accepted only when its exact token is still the active token and no later executor has reclaimed the work. This prevents completed long-running work from being discarded without allowing a stale executor to overwrite a newer claim. Report persistence MUST be idempotent by lease token and SHOULD automatically retry a bounded optimistic-concurrency collision before surfacing an error. + +Autonomy persistence MUST update only the autonomy state path atomically. Unrelated WorkspaceState changes MUST be preserved. Auto writes use the persisted Auto `updatedAt` as their optimistic compare-and-swap boundary so unrelated project updates no longer create false Auto conflicts while concurrent Auto writers are still fenced. + +An individual run MUST NOT pause, disable, delete, reschedule, or otherwise mutate its own recurring ChatGPT task. A ProjectBrain blocker is durable project state, not scheduler state. The recurring Chat task stays enabled, reports the blocker once when actionable, and checks durable state again on its next scheduled wake-up. + +Explicit new user assignments outrank speculative brainstorm work and older unclaimed roadmap work in the current claimable phase. A clear phase with concrete work items SHOULD execute those items before an optional convergence brainstorm; an empty or genuinely ambiguous phase MAY brainstorm first. + +### Human-decision examples + +Escalation is appropriate for: + +- destructive or irreversible data loss; +- high-impact production changes; +- credentials, secrets or security-policy changes; +- a significant scope change; +- mutually valid product directions with no objective preference; +- ambiguity that cannot be resolved from repository or project context. + +Escalation is not appropriate merely because ProjectBrain needs to: + +- create a branch; +- create or update a low/medium-risk plan; +- choose internal context; +- select execution profiles; +- create a handoff or execution package; +- run tests; +- open a pull request; +- collect execution evidence; +- retry a transient network, runner, CI or provider problem. + +## 6. Single user-facing next-action resolver + +The standard next-action priority is: + +1. real blocker requiring human action; +2. explicit human decision; +3. active work ProjectBrain is actually performing; +4. automatic retry/wait state; +5. relevant project warning; +6. invite the next user intent. + +Build Loop, first-run walkthroughs, Auto Mode controller internals and release-readiness gates are not independent user-facing next-action authorities. + +Release readiness may only dominate when the requested outcome is actually release/deployment related. + +## 7. Repository-as-source invariant + +A linked repository is primary technical source evidence. + +The user MUST NOT be told to add source material or import repository state merely because the separate source library is empty. + +Git sources should be synchronized automatically on project onboarding and refreshed through repository synchronization/webhook flows. + +The user-facing Source Library is therefore **Extra bronnen**: only material that is not already available in the repository. + +## 8. Capability truth + +A reachable connector does not imply full operational readiness. + +User-facing capability claims require positive evidence: + +```text +canAnalyze = project data + AI integration + repository linked +canModify = canAnalyze + confirmed repository write access +canDeliver = canModify + confirmed validation capability +canDeploy = canDeliver + confirmed deployment capability +``` + +Unknown capability must remain unknown; it must not be presented as green by default. + +Technical connector names and URLs belong under Settings or Technical details. + +## 9. Progressive disclosure + +Every normal page should follow these rules: + +- one dominant purpose; +- one primary CTA; +- one dominant user state; +- technical identifiers hidden by default; +- evidence translated to user impact first; +- raw branches, SHAs, runner names, lease tokens, run ids, validation commands and internal artifact ids remain available on demand. + +Portfolio **Home** is a daily control surface, not an NOC. It shows human attention, real work, queue/recovery truth and recent project activity first. Runner capacity, Gitea Actions job lanes and worker internals belong under platform/technical management. + +The project **Overzicht** begins with the confirmed current work state, its next safe action and the single human-attention summary. Recent verified results and a short human-readable history follow. Planning, health detail, graph detail and technical signals are available below that primary orientation, never as competing first-screen dashboards. + +The **Activity** view is live-first: active execution, safe waiting/recovery and the next known step appear before recent completions. The durable chronological record remains available as an expandable timeline; raw actors, branches, job IDs, runners and queue data are technical evidence rather than the default story. + +The **Auto Mode** controller begins with present control truth: desired state, actual work state, recovery or real human decision, and the next bounded action. Roadmap editing, detailed queue contents, retry/run limits and manual import are explicit advanced controls. Opening Auto Mode must not require the user to interpret a technical roadmap to understand what is happening. + +**Runtime** begins with a compact capability summary grounded in health, readiness, repository, analysis and executor probes. Release operations, logs, diagnostics and raw infrastructure data follow through progressive disclosure. Settings groups ordinary controls by product capability and exposes connection permissions before low-level implementation values. + +## 10. Onboarding contract + +The start experience asks what the user already has: + +- existing code; +- an idea; +- source material/documents; +- a new project from scratch. + +Project setup then asks only for: + +- what the project is; +- who it is for when relevant; +- the main desired outcome; +- an optional first useful result. + +Stack, repository structure, documentation depth and execution profiles are inferred whenever possible and remain optional technical settings. + +A selected repository is linked and synchronized as source evidence automatically. + +## 11. Human attention inbox + +`Jouw aandacht` is the only standard human-decision inbox. + +It may contain: + +- explicit high/destructive delivery decisions; +- blocked work requiring a human choice; +- missing credentials or owner authorization that ProjectBrain cannot safely infer; +- durable knowledge proposals that require acceptance; +- exceptional release decisions when release is the current goal. + +Normal safe delivery planning and retryable technical recovery must not create user approval chores. + +A human Auto blocker MUST be actionable from `Jouw aandacht`. The user must not have to discover the technical Auto controller merely to resume work after resolving the real human dependency. + +## 12. Results and activity + +A result should answer, in this order: + +1. What did ProjectBrain accomplish? +2. What changed? +3. What was validated? +4. Is the result merged/verified/released? +5. What can the user do next? + +`merged` is an implementation/delivery state, not by itself a proven result. The standard **Resultaten** view MUST count only `verified` or `released` work as completed results. A merged delivery that still lacks the required verification remains visible as waiting for verification and MUST NOT inflate result statistics. + +Portfolio activity counts have the same truth rule: `executing` and `validating` are active work, `draft`/`approved` are queued, `pull_request_open` is external waiting, and `merged` is awaiting verification. These buckets MUST NOT be collapsed into a misleading “ProjectBrain werkt” total. + +Technical evidence is expandable. + +Activity's expandable history should read as a chronological project story. It must not displace active work, safe recovery or the immediate next step from the initial view. Audit-grade evidence and legacy history views stay available under technical history. + +### 12.1 Cross-domain work graph and scope + +Roadmap items, assignments, bugs, findings, remediations, recovery work, brainstorm suggestions, deliveries and release work MUST project into one durable work graph. Domain tables remain authoritative for domain-specific facts, but scheduling, dependency readiness, blocker inspection, leases, completion reports and audit history use the shared graph boundary. + +Every graph node carries a stable identity, source, project, type, title, canonical and domain lifecycle, scope, priority, dependencies, risk, blocking state, execution eligibility, ownership/lease where applicable, attempts, timestamps, completion evidence and supersession relationships. Dependency mutation is durable and cycle-checked. Derived reconciliation MUST NOT delete explicit human dependency edits. + +Scope is explicit: + +- **Required** — necessary for correctness, security, release or an existing obligation. +- **Committed** — part of the current agreed objective. +- **Stretch** — useful but outside normal autonomous execution. +- **Future** — a suggestion or uncommitted possibility. + +Auto Mode normally executes only Required and Committed work. A brainstorm suggestion MUST remain Future unless the user commits it or ProjectBrain promotes it because it is necessary to fix a defect/correctness issue, close a security or release blocker, or satisfy an existing committed requirement. The promotion reason remains auditable. + +### 12.2 Completion and evidence + +Each project MAY define an explicit Definition of Done containing required or optional requirements such as committed scope, green validation, UX verification, current documentation, release-candidate readiness, deployment, runtime verification and backup/rollback evidence. ProjectBrain MUST distinguish implemented, validated, merged, deployed, runtime-verified and actually complete. + +Auto Mode MUST stop when the required Definition-of-Done requirements are satisfied. It MUST NOT invent improvement work to keep a completed project active. Evidence chains may connect requirement → work → delivery → commit → validation → pull request → merge → deployment → runtime verification. A missing stage remains missing; a completion report or raw commit does not fabricate validation, deployment or runtime evidence. + +### 12.3 Truth, provenance and freshness + +Project Knowledge is the user-facing consolidation of Project DNA, knowledge, memory, decisions, architecture, imported sources, requirements, documentation and generated execution context. Each important entry exposes type, source, authority, freshness, confidence where applicable, last verification, project area, conflicts and provenance. Context packs are generated execution snapshots and are not a manual operating queue. + +Truth reconciliation uses an evidence hierarchy: verified runtime and repository evidence generally outrank verified evidence, approved knowledge, memory and roadmap descriptions. Safe stale-description conflicts MAY be reconciled automatically with provenance. Ambiguous or high-impact conflicts MUST become one deduplicated actionable attention item rather than several competing chores. Freshness is visible for repository sync, knowledge, runtime, analysis, delivery evidence, integrations and executor state; stale information MUST NOT look equally authoritative to fresh information. + +### 12.4 Human attention and observability + +`Jouw aandacht` aggregates decisions, approvals, critical or regressed findings requiring acceptance, knowledge conflicts, unrecoverable automation, capability/authentication issues, manual acceptance/review and high-risk operations. Root-cause keys batch duplicates. Technical retries, ordinary external waiting, queue depth and stale low-level metrics remain operational state until a genuine human action is required. + +ProjectBrain MUST translate internal signals into user-oriented statements, for example “4 tasks are immediately runnable and 3 wait on dependencies” or “Automation has not reported progress for 8 minutes and recovery is being attempted.” Raw IDs, SHAs, leases, workers, API details and SLO measurements remain available under technical details. + +### 12.5 Portfolio, pulse and command control + +Portfolio Home is a compact command center with the sections **Needs you**, **Running now**, **Recovering**, **Recently delivered**, **Stalled**, **At risk**, **Release ready** and **Autonomous coverage**. These sections use canonical project/current-state semantics and are not independent status authorities. The initial viewport shows only non-empty priority lanes; secondary lanes, saved views, pulse and autonomous-coverage detail are discoverable below the daily triage surface rather than forming an empty card wall. + +Saved views are persisted filters, not separate pages. Pulse summaries describe meaningful completed work, active work, new and resolved blockers, delivery/deployment/finding changes and the next likely action over recent, daily or weekly windows. Natural-language commands are parsed into objective, constraints, allowed scope, policy and requested completion state, then applied only through normal authorization, recovery and scheduling controls. An unrecognized command performs no mutation. + +### 12.6 Diagnostics and safe recovery + +ProjectBrain diagnostics MUST distinguish process, transport, authentication, authorization, API reachability, safe functional reads, write readiness, latency, latest successful probe and recent failure evidence. Connector health is not inferred from process/container health. The ProjectBrain path is represented as client → MCP hub → connector → ProjectBrain API → database whenever those dimensions are available; missing external probe capability remains unknown or degraded, not green. + +The stuck detector checks stale heartbeats, expired leases, queues without consumers, runs without meaningful progress, runnable work that is not selected, stale delivery state, merged-but-active work and functional connector failures. Safe lease reclaim and state reconciliation happen automatically and leave audit evidence. Destructive writes are never used merely to prove connector health. + +## 13. Compatibility strategy + +Technical and legacy routes remain available during the migration, but are removed from primary navigation. Where a historic deep link contains technical filters, the application should preserve that intent by routing to the technical view rather than silently discarding parameters. + +Compatibility must not force legacy terminology back into the primary UX. + +Historical stabilization and post-audit documents are implementation history unless this contract or the current documentation index explicitly marks them active. They MUST NOT override the current product or autonomy contract during context resolution. + +## 14. Acceptance criteria + +The intent-first transformation is considered intact only when: + +- a nontechnical user can create or import a project without understanding Git/MCP/CI internals; +- a linked repository is sufficient source evidence; +- a normal project has exactly five primary destinations; +- Auto Mode controller/runtime details do not become a sixth primary destination; +- a normal reversible delivery does not require manual approval; +- high/destructive work cannot start before a real user decision; +- backend authorization state and UI wording agree; +- `Auto enabled` is never confused with a connected executor or active work; +- Projecten, Overzicht, Opdracht geven and Jouw aandacht do not contradict one another about current work; +- retryable technical failures remain self-healing and do not become human chores merely because a retry limit was reached or a client labels them human; +- explicit user assignments outrank speculative autonomous work; +- ordinary next actions are not hijacked by Build Loop, Auto diagnostics or release gates; +- only verified/released delivery work counts as a normal user result; +- portfolio “working” counts exclude queued, externally waiting and merely merged work; +- unrelated project-state writes cannot overwrite the autonomy substate; +- result pages explain concrete changes and validation in plain language; +- technical evidence remains available without dominating the normal flow; +- automated tests enforce the navigation, state, recovery, result and capability contracts. diff --git a/docs/screenshots/mock-portfolio.png b/docs/screenshots/mock-portfolio.png new file mode 100644 index 0000000000000000000000000000000000000000..e1dbca5a4ef7fab161e3001b71f0b3cc74ab9947 GIT binary patch literal 91067 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/mock-project-overview.png b/docs/screenshots/mock-project-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..81efdde0e5e2e51e7441800b654c4a7c49cf1f18 GIT binary patch literal 123854 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%0A3FW5{?808>z_x1d*e_i|; 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

+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..06fdd0d --- /dev/null +++ b/package.json @@ -0,0 +1,303 @@ +{ + "name": "projectbrain", + "version": "0.7.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:desktop": "playwright test --project=chromium-desktop", + "test:e2e:mobile": "playwright test --project=chromium-mobile", + "test:e2e:real": "playwright test --config=playwright.real.config.ts", + "test:e2e:status": "playwright test --config=playwright.status.config.ts", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.node.json --noEmit", + "audit:typecheck-gate": "node scripts/check-typescript-gate.mjs", + "validate:frontend": "pnpm run typecheck && pnpm run lint && pnpm run build", + "validate:backend": "pnpm --filter projectbrain-api run typecheck && pnpm --filter projectbrain-api run build && pnpm --filter projectbrain-api run lint", + "validate:all": "pnpm run test:coverage && pnpm run validate:frontend && pnpm run validate:backend", + "audit:release": "node scripts/check-release-hygiene.mjs", + "smoke:backend:fixtures": "pnpm --filter projectbrain-api run smoke:fixtures", + "audit:runtime": "node scripts/check-runtime-readiness.mjs", + "smoke:backend:real-read-pilot": "pnpm --filter projectbrain-api run smoke:real-read-pilot", + "smoke:backend:vertical-slice": "pnpm --filter projectbrain-api run smoke:vertical-slice", + "smoke:backend:runtime-suite": "pnpm --filter projectbrain-api run smoke:runtime-suite && pnpm --filter projectbrain-api run smoke:system-readiness", + "smoke:backend:database-suite": "pnpm --filter projectbrain-api run smoke:database-suite", + "smoke:backend:p3": "pnpm --filter projectbrain-api run smoke:p3", + "doctor": "node scripts/projectbrain-doctor.mjs", + "docs:validation-matrix": "node scripts/generate-validation-matrix.mjs", + "docs:openapi": "node scripts/generate-openapi.mjs", + "audit:openapi": "node scripts/generate-openapi.mjs --check", + "audit:architecture": "node scripts/check-architecture-budget.mjs", + "release:metadata": "node scripts/generate-release-metadata.mjs", + "audit:release-metadata": "node scripts/verify-release-metadata.mjs", + "audit:docs": "node scripts/check-doc-links.mjs", + "freeze:v1": "node scripts/check-v1-contract-freeze.mjs --write", + "audit:v1-freeze": "node scripts/check-v1-contract-freeze.mjs", + "smoke:backend:p4": "pnpm --filter projectbrain-api run smoke:p4", + "smoke:backend:p5": "pnpm --filter projectbrain-api run smoke:p5", + "smoke:backend:p6": "pnpm --filter projectbrain-api run smoke:p6", + "smoke:backend:p7": "pnpm --filter projectbrain-api run smoke:p7", + "smoke:backend:project-loop": "pnpm --filter projectbrain-api run smoke:project-loop", + "smoke:backend:project-create": "pnpm --filter projectbrain-api run smoke:project-create", + "smoke:backend:p8": "pnpm --filter projectbrain-api run smoke:p8", + "smoke:rc60-review-decision-loop": "node scripts/smoke-rc60-review-decision-loop.mjs", + "smoke:rc63-release-evidence-lane": "node scripts/smoke-rc63-release-evidence-lane.mjs", + "audit:backend-contract": "pnpm --filter projectbrain-api run audit:contract", + "audit:csi": "node scripts/projectbrain-csi-audit.mjs", + "audit:database": "node scripts/check-database-readiness.mjs", + "audit:all": "pnpm audit:experience && pnpm audit:quality && pnpm audit:security && pnpm audit:data && pnpm audit:delivery", + "smoke:backend:system-readiness": "pnpm --filter projectbrain-api run smoke:system-readiness", + "smoke:backend:brainstorms": "pnpm --filter projectbrain-api run smoke:brainstorms", + "smoke:backend:initiatives": "pnpm --filter projectbrain-api run smoke:initiatives", + "audit:rc": "node scripts/check-rc-readiness.mjs", + "audit:docker": "node scripts/check-docker-build-readiness.mjs", + "audit:frontend-runtime": "node scripts/check-frontend-runtime-readiness.mjs", + "audit:visual": "node scripts/check-visual-polish.mjs", + "audit:guided-start": "node scripts/check-guided-start-readiness.mjs", + "audit:source-library": "node scripts/check-source-library-readiness.mjs", + "audit:history-center": "node scripts/check-history-center-readiness.mjs", + "audit:execution-center": "node scripts/check-execution-center-readiness.mjs", + "audit:repo-builder": "node scripts/check-repo-builder-readiness.mjs", + "audit:skills": "node scripts/check-skill-library-readiness.mjs", + "audit:repository-intake": "node scripts/check-repository-intake-readiness.mjs", + "audit:handoffs": "node scripts/check-codex-handoff-readiness.mjs", + "audit:handoff-review": "node scripts/check-handoff-review-readiness.mjs", + "audit:execution-feedback": "node scripts/check-execution-feedback-readiness.mjs", + "audit:review-closure": "node scripts/check-review-closure-readiness.mjs", + "audit:build-loop": "node scripts/check-build-loop-readiness.mjs", + "audit:build-loop-walkthrough": "node scripts/check-build-loop-walkthrough-readiness.mjs", + "audit:session-packs": "node scripts/check-session-pack-readiness.mjs", + "audit:session-pack-export": "node scripts/check-session-pack-export-readiness.mjs", + "audit:browser-bridge": "node scripts/check-browser-bridge-readiness.mjs", + "audit:release-consolidation": "node scripts/check-build-loop-release-consolidation.mjs", + "audit:visual-flow": "node scripts/check-visual-flow-readiness.mjs", + "audit:experience": "pnpm run audit:visual && pnpm run audit:guided-start && pnpm run audit:source-library && pnpm run audit:history-center && pnpm run audit:repo-builder && pnpm run audit:review-closure && pnpm run audit:build-loop && pnpm run audit:visual-flow", + "audit:unraid-template": "node scripts/check-unraid-template-readiness.mjs", + "audit:rc7-flow": "node scripts/check-rc7-product-flow-readiness.mjs", + "audit:rc8-release": "node scripts/check-rc8-release-evidence-readiness.mjs", + "audit:rc9-final": "node scripts/check-rc9-final-release-readiness.mjs", + "audit:rc10-operator": "node scripts/check-rc10-operator-guide-readiness.mjs", + "audit:rc11-post-rc": "node scripts/check-rc11-post-rc-monitoring-readiness.mjs", + "audit:rc12-product-experience": "node scripts/check-rc12-product-experience-readiness.mjs", + "audit:rc13-product-flow": "node scripts/check-rc13-product-flow-hardening-readiness.mjs", + "audit:rc14-project-operability": "node scripts/check-rc14-project-operability-readiness.mjs", + "audit:rc15-evidence-review": "node scripts/check-rc15-evidence-review-trust-readiness.mjs", + "audit:rc16-action-hygiene": "node scripts/check-rc16-action-hygiene-readiness.mjs", + "audit:rc17-daily-orientation": "node scripts/check-rc17-daily-orientation-readiness.mjs", + "audit:rc18-search-navigation": "node scripts/check-rc18-search-navigation-readiness.mjs", + "audit:rc19-prioritized-navigation": "node scripts/check-rc19-prioritized-navigation-readiness.mjs", + "audit:rc20-start-flow-clarity": "node scripts/check-rc20-start-flow-clarity-readiness.mjs", + "audit:rc21-workspace-complexity": "node scripts/check-rc21-workspace-complexity-readiness.mjs", + "audit:rc22-repo-builder-usability": "node scripts/check-rc22-repo-builder-usability-readiness.mjs", + "audit:rc23-history-proof-finder": "node scripts/check-rc23-history-proof-finder-readiness.mjs", + "audit:rc24-persistence-coverage": "node scripts/check-rc24-persistence-coverage-readiness.mjs", + "audit:rc25-build-identity": "node scripts/check-rc25-build-identity-readiness.mjs", + "audit:rc26-prompt-origin": "node scripts/check-rc26-prompt-origin-readiness.mjs", + "audit:rc27-delivery-flow-rail": "node scripts/check-rc27-delivery-flow-rail-readiness.mjs", + "audit:rc28-build-loop-status-signals": "node scripts/check-rc28-build-loop-status-signals-readiness.mjs", + "audit:rc29-project-creation-persistence": "node scripts/check-rc29-project-creation-persistence-readiness.mjs", + "audit:rc30-repo-scanner-handoff": "node scripts/check-rc30-repo-scanner-handoff-readiness.mjs", + "audit:rc31-session-pack-feedback-route": "node scripts/check-rc31-session-pack-feedback-route-readiness.mjs", + "audit:rc32-project-portfolio-sort": "node scripts/check-rc32-project-portfolio-sort-readiness.mjs", + "audit:rc33-settings-runtime": "node scripts/check-rc33-settings-runtime-readiness.mjs", + "audit:rc34-runtime-parity-copy": "node scripts/check-rc34-runtime-parity-copy-readiness.mjs", + "audit:rc35-card-copy-scan": "node scripts/check-rc35-card-copy-scan-readiness.mjs", + "audit:rc36-operator-public-url": "node scripts/check-rc36-operator-public-url-readiness.mjs", + "audit:rc37-mobile-handoff-impact": "node scripts/check-rc37-mobile-handoff-impact-readiness.mjs", + "audit:rc38-mobile-workspace-map": "node scripts/check-rc38-mobile-workspace-map-readiness.mjs", + "audit:rc39-release-evidence-persistence": "node scripts/check-rc39-release-evidence-persistence-readiness.mjs", + "audit:rc40-proof-timeline": "node scripts/check-rc40-proof-timeline-readiness.mjs", + "audit:rc41-project-loop": "node scripts/check-rc41-project-loop-readiness.mjs", + "audit:rc42-navigation-primary-path": "node scripts/check-rc42-navigation-primary-path-readiness.mjs", + "audit:rc43-project-create-real-mode": "node scripts/check-rc43-project-create-real-mode-readiness.mjs", + "audit:rc44-handoff-session-pack-polish": "node scripts/check-rc44-handoff-session-pack-polish-readiness.mjs", + "audit:rc45-unraid-ops-hardening": "node scripts/check-rc45-unraid-ops-hardening-readiness.mjs", + "audit:rc46-final-user-flow": "node scripts/check-rc46-final-user-flow-release-readiness.mjs", + "audit:rc47-next-step": "node scripts/check-rc47-next-step-operating-layer-readiness.mjs", + "audit:rc48-guided-session": "node scripts/check-rc48-guided-work-session-readiness.mjs", + "audit:rc49-guided-session-evidence": "node scripts/check-rc49-guided-session-evidence-readiness.mjs", + "audit:rc50-evidence-closure": "node scripts/check-rc50-evidence-review-closure-readiness.mjs", + "audit:rc51-evidence-routing": "node scripts/check-rc51-evidence-routing-readiness.mjs", + "audit:rc52-guided-follow-up": "node scripts/check-rc52-guided-follow-up-readiness.mjs", + "audit:rc53-review-decision-receipt": "node scripts/check-rc53-review-decision-receipt-readiness.mjs", + "audit:rc54-decision-receipt-history": "node scripts/check-rc54-decision-receipt-history-readiness.mjs", + "audit:rc55-review-action-clarity": "node scripts/check-rc55-review-action-clarity-readiness.mjs", + "audit:rc56-review-detail-compactness": "node scripts/check-rc56-review-detail-compactness-readiness.mjs", + "audit:rc57-mobile-review-action-flow": "node scripts/check-rc57-mobile-review-action-flow-readiness.mjs", + "audit:rc58-review-receipt-follow-through": "node scripts/check-rc58-review-receipt-follow-through-readiness.mjs", + "audit:rc59-decision-receipt-history-detail": "node scripts/check-rc59-decision-receipt-history-detail-readiness.mjs", + "audit:rc60-review-decision-loop-smoke": "node scripts/check-rc60-review-decision-loop-smoke-readiness.mjs", + "audit:rc61-review-loop-recovery": "node scripts/check-rc61-review-loop-recovery-readiness.mjs", + "audit:rc62-release-evidence-lane": "node scripts/check-rc62-release-evidence-lane-readiness.mjs", + "audit:rc63-release-evidence-lane-smoke": "node scripts/check-rc63-release-evidence-lane-smoke-readiness.mjs", + "audit:rc64-actionable-release-evidence-lane": "node scripts/check-rc64-actionable-release-evidence-lane-readiness.mjs", + "audit:rc65-action-copy-browser-smoke": "node scripts/check-rc65-action-copy-browser-smoke-readiness.mjs", + "audit:rc66-release-evidence-route-precision": "node scripts/check-rc66-release-evidence-route-precision-readiness.mjs", + "audit:rc67-release-lane-click-through": "node scripts/check-rc67-release-lane-click-through-readiness.mjs", + "audit:rc68-release-lane-destination-ack": "node scripts/check-rc68-release-lane-destination-ack-readiness.mjs", + "audit:rc69-receipt-review-route-ack": "node scripts/check-rc69-receipt-review-route-ack-readiness.mjs", + "smoke:release-operator": "node scripts/smoke-release-operator.mjs", + "audit:rc70-release-operator-smoke-suite": "node scripts/check-rc70-release-operator-smoke-suite-readiness.mjs", + "audit:rc71-release-operator-smoke-product-surface": "node scripts/check-rc71-release-operator-smoke-product-surface-readiness.mjs", + "audit:rc72-release-operator-proof-evidence": "node scripts/check-rc72-release-operator-proof-evidence-readiness.mjs", + "audit:rc73-release-evidence-capture-assist": "node scripts/check-rc73-release-evidence-capture-assist-readiness.mjs", + "smoke:rc73-release-evidence-capture-assist": "node scripts/smoke-rc73-release-evidence-capture-assist.mjs", + "audit:rc74-release-evidence-real-save": "node scripts/check-rc74-release-evidence-real-save-readiness.mjs", + "audit:rc75-release-evidence-recovery-links": "node scripts/check-rc75-release-evidence-recovery-links-readiness.mjs", + "smoke:rc76-release-evidence-api-recovery": "node scripts/smoke-rc76-release-evidence-api-recovery.mjs", + "audit:rc76-release-evidence-api-recovery": "node scripts/check-rc76-release-evidence-api-recovery-readiness.mjs", + "audit:rc77-release-evidence-hygiene": "node scripts/check-rc77-release-evidence-hygiene-readiness.mjs", + "audit:rc78-release-signoff": "node scripts/check-rc78-release-signoff-readiness.mjs", + "audit:rc79-release-signoff-capture-shortcuts": "node scripts/check-rc79-release-signoff-capture-shortcuts-readiness.mjs", + "audit:rc80-release-evidence-composer": "node scripts/check-rc80-release-evidence-composer-readiness.mjs", + "audit:rc81-release-evidence-review-actions": "node scripts/check-rc81-release-evidence-review-actions-readiness.mjs", + "audit:rc82-final-rc-go-no-go": "node scripts/check-rc82-final-rc-go-no-go-readiness.mjs", + "audit:rc83-release-tag-checklist": "node scripts/check-rc83-release-tag-checklist-readiness.mjs", + "audit:rc84-release-note-archive": "node scripts/check-rc84-release-note-archive-readiness.mjs", + "audit:rc85-release-archive-history-lane": "node scripts/check-rc85-release-archive-history-lane-readiness.mjs", + "audit:rc86-release-archive-persistence": "node scripts/check-rc86-release-archive-persistence-readiness.mjs", + "audit:rc87-release-center": "node scripts/check-rc87-release-center-readiness.mjs", + "smoke:rc88-release-center": "node scripts/smoke-rc88-release-center.mjs", + "audit:rc88-release-center-smoke": "node scripts/check-rc88-release-center-smoke-readiness.mjs", + "audit:rc89-release-center-guidance": "node scripts/check-rc89-release-center-guidance-readiness.mjs", + "audit:rc90-release-center-quality": "node scripts/check-rc90-release-center-quality-readiness.mjs", + "audit:rc91-release-center-improve-action": "node scripts/check-rc91-release-center-improve-action-readiness.mjs", + "audit:rc92-release-readiness-export": "node scripts/check-rc92-release-readiness-export-readiness.mjs", + "audit:rc93-release-readiness-proof-save": "node scripts/check-rc93-release-readiness-proof-save-readiness.mjs", + "audit:rc94-release-readiness-proof-freshness": "node scripts/check-rc94-release-readiness-proof-freshness-readiness.mjs", + "audit:rc95-release-readiness-proof-actions": "node scripts/check-rc95-release-readiness-proof-actions-readiness.mjs", + "audit:rc96-release-operator-checklist": "node scripts/check-rc96-release-operator-checklist-readiness.mjs", + "audit:rc97-release-checklist-deep-links": "node scripts/check-rc97-release-checklist-deep-links-readiness.mjs", + "audit:rc98-release-checklist-target-awareness": "node scripts/check-rc98-release-checklist-target-awareness-readiness.mjs", + "smoke:rc99-release-checklist-clickthrough": "node scripts/smoke-rc99-release-checklist-clickthrough.mjs", + "audit:rc99-release-checklist-clickthrough": "node scripts/check-rc99-release-checklist-clickthrough-readiness.mjs", + "release:proof-report": "node scripts/release-operator-proof-report.mjs", + "audit:rc100-release-operator-proof-report": "node scripts/check-rc100-release-operator-proof-report-readiness.mjs", + "audit:rc101-release-proof-archive": "node scripts/check-rc101-release-proof-archive-readiness.mjs", + "release:proof-index": "node scripts/release-proof-index.mjs", + "audit:rc102-release-proof-index": "node scripts/check-rc102-release-proof-index-readiness.mjs", + "release:proof-freshness": "node scripts/release-proof-freshness.mjs", + "audit:rc103-release-proof-freshness": "node scripts/check-rc103-release-proof-freshness-readiness.mjs", + "release:bundle": "node scripts/release-command-bundle.mjs", + "audit:rc104-release-command-bundle": "node scripts/check-rc104-release-command-bundle-readiness.mjs", + "release:signoff-file": "node scripts/release-signoff-file.mjs", + "audit:rc105-release-signoff-file": "node scripts/check-rc105-release-signoff-file-readiness.mjs", + "release:evidence-list": "node scripts/release-evidence-retention.mjs --list", + "release:evidence-prune": "node scripts/release-evidence-retention.mjs --prune", + "audit:rc106-release-evidence-retention": "node scripts/check-rc106-release-evidence-retention-readiness.mjs", + "release:deployment-proof": "node scripts/release-deployment-proof.mjs", + "audit:rc107-remote-deployment-proof": "node scripts/check-rc107-remote-deployment-proof-readiness.mjs", + "release:dossier": "node scripts/release-evidence-dossier.mjs", + "audit:rc108-release-evidence-dossier": "node scripts/check-rc108-release-evidence-dossier-readiness.mjs", + "release:tag-gate": "node scripts/release-tag-ready-gate.mjs", + "audit:rc109-release-tag-ready-gate": "node scripts/check-rc109-release-tag-ready-gate-readiness.mjs", + "release:rollback-drill": "node scripts/release-rollback-drill.mjs", + "audit:rc110-rollback-drill": "node scripts/check-rc110-rollback-drill-readiness.mjs", + "release:user-notes": "node scripts/release-user-notes.mjs", + "audit:rc111-user-release-notes": "node scripts/check-rc111-user-release-notes-readiness.mjs", + "audit:rc112-operator-status": "node scripts/check-rc112-operator-status-readiness.mjs", + "audit:rc113-release-center-entry": "node scripts/check-rc113-release-center-entry-readiness.mjs", + "audit:rc114-release-center-hierarchy": "node scripts/check-rc114-release-center-hierarchy-readiness.mjs", + "audit:rc115-proof-action-queue": "node scripts/check-rc115-proof-action-queue-readiness.mjs", + "audit:rc116-runtime-proof-capture-loop": "node scripts/check-rc116-runtime-proof-capture-loop-readiness.mjs", + "audit:rc117-proof-completion-feedback": "node scripts/check-rc117-proof-completion-feedback-readiness.mjs", + "audit:rc118-runtime-progressive-disclosure": "node scripts/check-rc118-runtime-progressive-disclosure-readiness.mjs", + "audit:rc119-runtime-task-switcher": "node scripts/check-rc119-runtime-task-switcher-readiness.mjs", + "audit:rc120-release-proof-priority": "node scripts/check-rc120-release-proof-priority-readiness.mjs", + "audit:rc121-runtime-active-lane": "node scripts/check-rc121-runtime-active-lane-readiness.mjs", + "audit:rc122-runtime-decision-band": "node scripts/check-rc122-runtime-decision-band-readiness.mjs", + "audit:quality": "pnpm audit:typecheck-gate && pnpm validate:all && pnpm audit:openapi && pnpm audit:architecture && pnpm audit:docs && pnpm audit:v1-freeze", + "audit:security": "pnpm audit --prod --audit-level high && pnpm audit:release", + "audit:data": "pnpm audit:backend-contract && pnpm audit:database && pnpm audit:runtime", + "audit:delivery": "node --test scripts/deployment-proof-state.test.mjs && pnpm audit:docker && pnpm audit:unraid-template && pnpm build && pnpm release:metadata && pnpm audit:release-metadata" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@projectbrain/contracts": "workspace:*", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-context-menu": "^2.2.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-menubar": "^1.1.1", + "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "@tanstack/react-query": "^5.56.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.3.0", + "fflate": "^0.8.3", + "input-otp": "^1.2.4", + "lucide-react": "^0.462.0", + "next-themes": "^0.4.6", + "react": "^19.2.8", + "react-day-picker": "^9.13.0", + "react-dom": "^19.2.8", + "react-hook-form": "^7.53.0", + "react-is": "^19.2.8", + "react-resizable-panels": "^2.1.3", + "react-router-dom": "^7.18.2", + "recharts": "^3.8.1", + "sonner": "^1.5.0", + "tailwind-merge": "^2.5.2", + "vaul": "^1.1.2", + "zod": "^3.25.0" + }, + "devDependencies": { + "@axe-core/playwright": "^4.12.1", + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.0", + "@tailwindcss/typography": "^0.5.20", + "@types/node": "^24.0.0", + "@types/react": "^19.2.8", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.1.0", + "@vitest/coverage-v8": "4.1.10", + "autoprefixer": "^10.4.20", + "caniuse-lite": "^1.0.30001799", + "cross-env": "^10.1.0", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", + "globals": "^17.11.0", + "postcss": "^8.5.24", + "tailwindcss": "^3.4.11", + "tailwindcss-animate": "1.0.7", + "typescript": "^5.5.3", + "typescript-eslint": "^8.67.0", + "vite": "^8.2.2", + "vitest": "4.1.10" + }, + "packageManager": "pnpm@11.5.2", + "engines": { + "node": ">=24 <25", + "pnpm": "11.5.2" + } +} diff --git a/packages/contracts/index.d.ts b/packages/contracts/index.d.ts new file mode 100644 index 0000000..a8dee2b --- /dev/null +++ b/packages/contracts/index.d.ts @@ -0,0 +1,57 @@ +import type { z } from "zod"; + +export type ValidationIssue = { field?: string; message: string; code?: string }; +export type ApiMeta = { requestId: string; timestamp: string }; +export type ApiResponse = { data: T; meta: ApiMeta }; +export type ApiErrorResponse = { error: { code: string; message: string; validation?: ValidationIssue[] }; meta: ApiMeta }; +export type CanonicalLifecycle = "ready" | "running" | "waiting" | "needs_user" | "recovering" | "failed" | "done"; +export type AutonomyProfile = "conservative" | "normal" | "autonomous" | "custom"; +export type AutonomyProfileSettings = { autoBrainstorm: boolean; maxBrainstormIterations: number; maxConsecutiveFailures: number; leaseMinutes: number; maxWorkItemsPerRun: number; maxRunMinutes: number }; +export type AuthUser = { id: string; username: string; role: "owner" | "member" }; +export type AuthSessionData = { user: AuthUser; csrfToken: string; expiresAt?: string }; +export type EntraPublicConfig = { enabled: boolean; provider: "microsoft-entra" }; +export type EntraLinkStatus = { enabled: boolean; linked: boolean; displayName?: string; preferredUsername?: string }; + +export const requestIdSchema: z.ZodString; +export const timestampSchema: z.ZodString; +export const validationIssueSchema: z.ZodType; +export const apiMetaSchema: z.ZodType; +export const apiErrorEnvelopeSchema: z.ZodType; +export const successEnvelopeSchema: (dataSchema?: T) => z.ZodType>>; +export const apiEnvelopeSchema: z.ZodType | ApiErrorResponse>; +export const canonicalLifecycleValues: readonly CanonicalLifecycle[]; +export const canonicalLifecycleSchema: z.ZodEnum<["ready", "running", "waiting", "needs_user", "recovering", "failed", "done"]>; +export const autonomyProfileSchema: z.ZodEnum<["conservative", "normal", "autonomous", "custom"]>; +export const autonomyProfileSettings: Readonly>; +export const summarizeHumanBlocker: (reason?: string) => string; +export const summarizeTechnicalBlocker: (reason?: string) => string; +export const userRoleSchema: z.ZodEnum<["owner", "member"]>; +export const authUserSchema: z.ZodType; +export const loginCredentialsSchema: z.ZodType<{ username: string; password: string }>; +export const rotatePasswordSchema: z.ZodType<{ currentPassword: string; newPassword: string }>; +export const authSessionDataSchema: z.ZodType; +export const entraPublicConfigSchema: z.ZodType; +export const entraLinkStatusSchema: z.ZodType; +export const idSchema: z.ZodString; +export const mutationResponseSchema: z.ZodType<{ success: boolean; message: string; updatedEntityId?: string; createdEntityId?: string; affectedEntities: string[] }>; +export const captureCreateSchema: z.ZodType<{ type: string; content: string; source?: string }>; +export const operatingLogCreateSchema: z.ZodType<{ type: string; title: string; summary: string; linkedEntityIds?: string[]; source?: string; importance?: string }>; +export const apiPaths: { + auth: { login: string; session: string; logout: string; rotatePassword: string; entraConfig: string; entraStart: string; entraCallback: string; entraStatus: string; entraLinkStart: string; entraIdentity: string }; + captureItems(projectId?: string): string; classifyCapture(projectId?: string, captureItemId?: string): string; sendCaptureToReview(projectId?: string, captureItemId?: string): string; + acceptReview(projectId?: string, reviewItemId?: string): string; materializeTruth(projectId?: string, reviewItemId?: string): string; createProjectUpdate(projectId?: string, truthItemId?: string): string; applyProjectUpdate(projectId?: string, projectUpdateId?: string): string; + handoffBundles(projectId?: string): string; codexTaskFromHandoff(projectId?: string, handoffBundleId?: string): string; importCodexOutput(projectId?: string, codexTaskId?: string): string; + projectActivity(projectId?: string): string; + projectActivityRuntime(projectId?: string): string; + repositoryFindings(projectId?: string): string; repositoryFinding(projectId?: string, findingId?: string): string; + findingIssue(projectId?: string, findingId?: string): string; findingInvestigation(projectId?: string, findingId?: string): string; + findingSuppression(projectId?: string, findingId?: string): string; findingAutomationPolicy(projectId?: string): string; + portfolioControlPlane: string; portfolioRuntime: string; portfolioSourceSync: string; projectSourceSync(projectId?: string): string; portfolioRepositories: string; portfolioReconcile: string; portfolioAnalysisJobs: string; + portfolioAnalysisJob(jobId?: string): string; portfolioAnalysisJobResults(jobId?: string): string; portfolioAnalysisJobCancel(jobId?: string): string; + portfolioRepositoryExclude(repositoryId?: string): string; portfolioRepositoryRestore(repositoryId?: string): string; + workGraph(projectId?: string): string; workGraphReady(projectId?: string): string; workGraphBlockers(projectId?: string): string; workGraphHistory(projectId?: string): string; + workGraphClaim(projectId?: string): string; workGraphReclaim(projectId?: string): string; workGraphRelease(projectId?: string, nodeId?: string): string; workGraphComplete(projectId?: string, nodeId?: string): string; workGraphDependencies(projectId?: string, nodeId?: string): string; + definitionOfDone(projectId?: string): string; autonomySimulation(projectId?: string): string; projectAttention(projectId?: string): string; projectKnowledge(projectId?: string): string; projectKnowledgeConflicts(projectId?: string): string; projectEvidenceGraph(projectId?: string): string; diagnostics: string; diagnosticsHistory: string; projectDiagnostics(projectId?: string): string; projectPulse(projectId?: string): string; portfolioPulse: string; + commandInterpret: string; projectCommandInterpret(projectId?: string): string; portfolioViews: string; portfolioView(viewId?: string): string; portfolioPins: string; portfolioPin(projectId?: string): string; +}; +export const buildOpenApiDocument: () => Record; diff --git a/packages/contracts/index.js b/packages/contracts/index.js new file mode 100644 index 0000000..21331b5 --- /dev/null +++ b/packages/contracts/index.js @@ -0,0 +1,189 @@ +import { z } from "zod"; + +export const requestIdSchema = z.string().min(1); +export const timestampSchema = z.string().datetime({ offset: true }); +export const validationIssueSchema = z.object({ field: z.string().optional(), message: z.string().min(1), code: z.string().optional() }); +export const apiMetaSchema = z.object({ requestId: requestIdSchema, timestamp: timestampSchema }); +export const apiErrorEnvelopeSchema = z.object({ + error: z.object({ code: z.string().min(1), message: z.string().min(1), validation: z.array(validationIssueSchema).optional() }), + meta: apiMetaSchema, +}); +export const successEnvelopeSchema = (dataSchema = z.unknown()) => z.object({ + data: dataSchema.refine((value) => value !== undefined, { message: "A success envelope must contain data" }), + meta: apiMetaSchema, +}); +export const apiEnvelopeSchema = z.union([apiErrorEnvelopeSchema, successEnvelopeSchema()]); + +// Canonical lifecycle values are intentionally language-neutral. Frontends and +// operational surfaces may translate them for people, but transport and +// backend decisions must use the same finite vocabulary. +export const canonicalLifecycleValues = Object.freeze([ + "ready", + "running", + "waiting", + "needs_user", + "recovering", + "failed", + "done", +]); +export const canonicalLifecycleSchema = z.enum(canonicalLifecycleValues); +export const autonomyProfileSchema = z.enum(["conservative", "normal", "autonomous", "custom"]); +export const autonomyProfileSettings = Object.freeze({ + conservative: Object.freeze({ autoBrainstorm: false, maxBrainstormIterations: 2, maxConsecutiveFailures: 2, leaseMinutes: 20, maxWorkItemsPerRun: 3, maxRunMinutes: 20 }), + normal: Object.freeze({ autoBrainstorm: true, maxBrainstormIterations: 5, maxConsecutiveFailures: 3, leaseMinutes: 30, maxWorkItemsPerRun: 8, maxRunMinutes: 45 }), + autonomous: Object.freeze({ autoBrainstorm: true, maxBrainstormIterations: 5, maxConsecutiveFailures: 5, leaseMinutes: 45, maxWorkItemsPerRun: 12, maxRunMinutes: 55 }), + custom: undefined, +}); + +// Standard views must explain a blocker without making raw execution payloads +// (SHAs, workflow names, leases, etc.) the primary product language. Both the +// API portfolio read model and the web client use this same mapping. +const normalizeOperationalCopy = (value) => value.replace(/\s+/g, " ").trim(); +const containsTechnicalOperationalDetail = (value) => /\b(?:[a-f0-9]{7,64}|https?:\/\/|\.gitea\/|workflow|default branch|branch protection|pull request|\bpr\s*#?\d+|executor|lease|run \d+|plan \w{12,})\b/i.test(value); +const firstOperationalSentence = (value) => { + const sentence = normalizeOperationalCopy(value).split(/(?<=[.!?])\s+/)[0] ?? ""; + return sentence.length > 220 ? `${sentence.slice(0, 217).trimEnd()}…` : sentence; +}; +export const summarizeHumanBlocker = (reason) => { + const normalized = reason ? normalizeOperationalCopy(reason) : ""; + if (!normalized) return "ProjectBrain heeft jouw beslissing of toegang nodig om veilig verder te gaan."; + const lower = normalized.toLowerCase(); + if (/governance|default branch|branch protection|workflow|pull request|\bpr\s*#?\d+/.test(lower)) return "ProjectBrain heeft jouw expliciete keuze nodig over een repositorywijziging voordat het veilig kan doorgaan."; + if (/authori[sz]|permission|access|credential|token|owner/.test(lower)) return "ProjectBrain heeft jouw toestemming of toegang nodig om veilig verder te gaan."; + if (/conflict|ambiguous|disagree|inconsistent/.test(lower)) return "ProjectBrain heeft jouw keuze nodig omdat betrouwbare projectinformatie elkaar tegenspreekt."; + return containsTechnicalOperationalDetail(normalized) ? "ProjectBrain heeft jouw expliciete beslissing nodig voordat het deze stap verantwoord kan voortzetten." : firstOperationalSentence(normalized); +}; +export const summarizeTechnicalBlocker = (reason) => { + const normalized = reason ? normalizeOperationalCopy(reason) : ""; + if (!normalized) return "ProjectBrain onderzoekt de technische oorzaak en herstelt automatisch waar dat veilig kan."; + const lower = normalized.toLowerCase(); + if (/auth|credential|token|permission|access/.test(lower)) return "ProjectBrain herstelt een verbindings- of toegangsprobleem automatisch waar dat veilig kan."; + if (/branch|pull request|workflow|repository/.test(lower)) return "ProjectBrain controleert de repositorytoestand opnieuw voordat het verdergaat."; + return containsTechnicalOperationalDetail(normalized) ? "ProjectBrain verwerkt een technische blokkade. Details blijven beschikbaar onder technische diagnose." : firstOperationalSentence(normalized); +}; + +export const userRoleSchema = z.enum(["owner", "member"]); +export const authUserSchema = z.object({ id: z.string().min(1), username: z.string().min(3).max(80), role: userRoleSchema }); +export const loginCredentialsSchema = z.object({ username: z.string().trim().min(3).max(80), password: z.string().min(12).max(256) }); +export const rotatePasswordSchema = z.object({ currentPassword: z.string().min(12).max(256), newPassword: z.string().min(12).max(256) }).refine((value) => value.currentPassword !== value.newPassword, { message: "New password must differ from current password", path: ["newPassword"] }); +export const authSessionDataSchema = z.object({ user: authUserSchema, csrfToken: z.string().min(20), expiresAt: timestampSchema.optional() }); +export const entraPublicConfigSchema = z.object({ enabled: z.boolean(), provider: z.literal("microsoft-entra") }); +export const entraLinkStatusSchema = z.object({ enabled: z.boolean(), linked: z.boolean(), displayName: z.string().optional(), preferredUsername: z.string().optional() }); + +export const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-_]+$/); +export const mutationResponseSchema = z.object({ success: z.boolean(), message: z.string().min(1), updatedEntityId: idSchema.optional(), createdEntityId: idSchema.optional(), affectedEntities: z.array(idSchema) }); +export const captureCreateSchema = z.object({ type: z.string().min(1), content: z.string().trim().min(1), source: z.string().optional() }); +export const operatingLogCreateSchema = z.object({ type: z.string().min(1), title: z.string().trim().min(1), summary: z.string().trim().min(1), linkedEntityIds: z.array(idSchema).optional(), source: z.string().optional(), importance: z.string().optional() }); + +export const apiPaths = Object.freeze({ + auth: { + login: "/api/auth/login", session: "/api/auth/session", logout: "/api/auth/logout", rotatePassword: "/api/auth/rotate-password", + entraConfig: "/api/auth/entra/config", entraStart: "/api/auth/entra/start", entraCallback: "/api/auth/entra/callback", + entraStatus: "/api/auth/entra/status", entraLinkStart: "/api/auth/entra/link/start", entraIdentity: "/api/auth/entra/identity", + }, + captureItems: (projectId = ":projectId") => `/api/projects/${projectId}/capture-items`, + classifyCapture: (projectId = ":projectId", captureItemId = ":captureItemId") => `/api/projects/${projectId}/capture-items/${captureItemId}/classify`, + sendCaptureToReview: (projectId = ":projectId", captureItemId = ":captureItemId") => `/api/projects/${projectId}/capture-items/${captureItemId}/send-to-review`, + acceptReview: (projectId = ":projectId", reviewItemId = ":reviewItemId") => `/api/projects/${projectId}/review-queue/${reviewItemId}/accept`, + materializeTruth: (projectId = ":projectId", reviewItemId = ":reviewItemId") => `/api/projects/${projectId}/review-queue/${reviewItemId}/materialize-truth`, + createProjectUpdate: (projectId = ":projectId", truthItemId = ":truthItemId") => `/api/projects/${projectId}/accepted-truth/${truthItemId}/project-updates`, + applyProjectUpdate: (projectId = ":projectId", projectUpdateId = ":projectUpdateId") => `/api/projects/${projectId}/project-updates/${projectUpdateId}/apply`, + handoffBundles: (projectId = ":projectId") => `/api/projects/${projectId}/handoff-bundles`, + codexTaskFromHandoff: (projectId = ":projectId", handoffBundleId = ":handoffBundleId") => `/api/projects/${projectId}/handoff-bundles/${handoffBundleId}/codex-task`, + importCodexOutput: (projectId = ":projectId", codexTaskId = ":codexTaskId") => `/api/projects/${projectId}/codex-tasks/${codexTaskId}/import-output`, + projectActivity: (projectId = ":projectId") => `/api/projects/${projectId}/activity`, + projectActivityRuntime: (projectId = ":projectId") => `/api/projects/${projectId}/activity/runtime`, + repositoryFindings: (projectId = ":projectId") => `/api/projects/${projectId}/findings`, + repositoryFinding: (projectId = ":projectId", findingId = ":findingId") => `/api/projects/${projectId}/findings/${findingId}`, + findingIssue: (projectId = ":projectId", findingId = ":findingId") => `/api/projects/${projectId}/findings/${findingId}/issue`, + findingInvestigation: (projectId = ":projectId", findingId = ":findingId") => `/api/projects/${projectId}/findings/${findingId}/investigations`, + findingSuppression: (projectId = ":projectId", findingId = ":findingId") => `/api/projects/${projectId}/findings/${findingId}/suppress`, + findingAutomationPolicy: (projectId = ":projectId") => `/api/projects/${projectId}/finding-automation-policy`, + portfolioControlPlane: "/api/portfolio/control-plane", + portfolioRuntime: "/api/portfolio/runtime", + portfolioSourceSync: "/api/portfolio/sources/sync-git", + projectSourceSync: (projectId = ":projectId") => `/api/projects/${projectId}/sources/sync-git`, + portfolioRepositories: "/api/portfolio/repositories", + portfolioReconcile: "/api/portfolio/reconcile", + portfolioAnalysisJobs: "/api/portfolio/analysis-jobs", + portfolioAnalysisJob: (jobId = ":jobId") => `/api/portfolio/analysis-jobs/${jobId}`, + portfolioAnalysisJobResults: (jobId = ":jobId") => `/api/portfolio/analysis-jobs/${jobId}/results`, + portfolioAnalysisJobCancel: (jobId = ":jobId") => `/api/portfolio/analysis-jobs/${jobId}/cancel`, + portfolioRepositoryExclude: (repositoryId = ":repositoryId") => `/api/portfolio/repositories/${repositoryId}/exclude`, + portfolioRepositoryRestore: (repositoryId = ":repositoryId") => `/api/portfolio/repositories/${repositoryId}/restore`, + workGraph: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph`, + workGraphReady: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/ready`, + workGraphBlockers: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/blockers`, + workGraphHistory: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/history`, + workGraphClaim: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/claim`, + workGraphReclaim: (projectId = ":projectId") => `/api/projects/${projectId}/work-graph/reclaim`, + workGraphRelease: (projectId = ":projectId", nodeId = ":nodeId") => `/api/projects/${projectId}/work-graph/${nodeId}/release`, + workGraphComplete: (projectId = ":projectId", nodeId = ":nodeId") => `/api/projects/${projectId}/work-graph/${nodeId}/complete`, + workGraphDependencies: (projectId = ":projectId", nodeId = ":nodeId") => `/api/projects/${projectId}/work-graph/${nodeId}/dependencies`, + definitionOfDone: (projectId = ":projectId") => `/api/projects/${projectId}/definition-of-done`, + autonomySimulation: (projectId = ":projectId") => `/api/projects/${projectId}/autonomy/simulation`, + projectAttention: (projectId = ":projectId") => `/api/projects/${projectId}/attention`, + projectKnowledge: (projectId = ":projectId") => `/api/projects/${projectId}/project-knowledge`, + projectKnowledgeConflicts: (projectId = ":projectId") => `/api/projects/${projectId}/project-knowledge/conflicts`, + projectEvidenceGraph: (projectId = ":projectId") => `/api/projects/${projectId}/evidence-graph`, + diagnostics: "/api/diagnostics", + diagnosticsHistory: "/api/diagnostics/history", + projectDiagnostics: (projectId = ":projectId") => `/api/projects/${projectId}/diagnostics`, + projectPulse: (projectId = ":projectId") => `/api/projects/${projectId}/pulse`, + portfolioPulse: "/api/portfolio/pulse", + commandInterpret: "/api/commands/interpret", + projectCommandInterpret: (projectId = ":projectId") => `/api/projects/${projectId}/commands/interpret`, + portfolioViews: "/api/portfolio/views", + portfolioView: (viewId = ":viewId") => `/api/portfolio/views/${viewId}`, + portfolioPins: "/api/portfolio/pins", + portfolioPin: (projectId = ":projectId") => `/api/portfolio/pins/${projectId}`, +}); + +const openApiPath = (path) => path.replace(/:([A-Za-z][A-Za-z0-9]*)/g, "{$1}"); +const operations = [ + ["post", apiPaths.auth.login, "Authenticate owner or member"], ["get", apiPaths.auth.session, "Read the active session"], ["post", apiPaths.auth.logout, "Revoke the active session"], ["post", apiPaths.auth.rotatePassword, "Rotate owner credentials"], + ["get", apiPaths.auth.entraConfig, "Read public Microsoft Entra availability"], ["get", apiPaths.auth.entraStart, "Start Microsoft Entra login"], ["get", apiPaths.auth.entraCallback, "Complete Microsoft Entra login"], ["get", apiPaths.auth.entraStatus, "Read linked Microsoft Entra identity status"], ["post", apiPaths.auth.entraLinkStart, "Start authenticated Microsoft Entra identity linking"], ["delete", apiPaths.auth.entraIdentity, "Unlink Microsoft Entra identity after password confirmation"], + ["post", apiPaths.captureItems(), "Capture source material"], ["patch", apiPaths.classifyCapture(), "Classify captured source"], ["post", apiPaths.sendCaptureToReview(), "Queue captured source for review"], ["post", apiPaths.acceptReview(), "Accept a review decision"], ["post", apiPaths.materializeTruth(), "Materialize accepted project truth"], ["post", apiPaths.createProjectUpdate(), "Prepare a project update"], ["patch", apiPaths.applyProjectUpdate(), "Apply a project update"], ["post", apiPaths.handoffBundles(), "Create a controlled handoff"], ["post", apiPaths.codexTaskFromHandoff(), "Create a Codex task"], ["post", apiPaths.importCodexOutput(), "Import execution feedback"], + ["get", apiPaths.projectActivity(), "Read durable project, delivery and repository activity"], + ["get", apiPaths.projectActivityRuntime(), "Read compact live project, Gitea runner and analysis-worker activity"], + ["get", apiPaths.portfolioRuntime, "Read platform-wide runner and worker activity"], + ["post", apiPaths.portfolioSourceSync, "Synchronize safe Git sources for the portfolio"], + ["post", apiPaths.projectSourceSync(), "Synchronize safe Git sources for one project"], + ["get", apiPaths.repositoryFindings(), "List durable repository findings"], ["get", apiPaths.repositoryFinding(), "Read one repository finding"], + ["post", apiPaths.findingIssue(), "Create or synchronize a finding issue"], ["post", apiPaths.findingInvestigation(), "Start a bounded finding investigation"], + ["post", apiPaths.findingSuppression(), "Suppress a finding with a reason"], ["get", apiPaths.findingAutomationPolicy(), "Read closed-loop automation policy"], + ["put", apiPaths.findingAutomationPolicy(), "Update closed-loop automation policy"], + ["get", apiPaths.portfolioControlPlane, "Read the central portfolio control plane"], ["get", apiPaths.portfolioRepositories, "List durable repository onboarding state"], + ["post", apiPaths.portfolioReconcile, "Preview or apply idempotent repository onboarding"], ["post", apiPaths.portfolioAnalysisJobs, "Queue a bounded portfolio analysis"], + ["get", apiPaths.portfolioAnalysisJob(), "Read one portfolio analysis job"], ["get", apiPaths.portfolioAnalysisJobResults(), "Read a bounded portfolio analysis result page"], + ["post", apiPaths.portfolioAnalysisJobCancel(), "Cancel a portfolio analysis job"], + ["post", apiPaths.portfolioRepositoryExclude(), "Exclude a repository from automatic onboarding"], ["post", apiPaths.portfolioRepositoryRestore(), "Restore a repository for onboarding"], + ["get", apiPaths.workGraph(), "Read the durable cross-domain project work graph"], + ["get", apiPaths.workGraphReady(), "List ready cross-domain work"], ["get", apiPaths.workGraphBlockers(), "Inspect work graph blockers"], ["get", apiPaths.workGraphHistory(), "Read work graph audit history"], + ["post", apiPaths.workGraphClaim(), "Claim the highest-value ready work atomically"], ["post", apiPaths.workGraphReclaim(), "Reclaim expired work leases"], ["post", apiPaths.workGraphRelease(), "Release a work lease"], ["post", apiPaths.workGraphComplete(), "Record work completion and evidence"], ["put", apiPaths.workGraphDependencies(), "Update durable work dependencies"], + ["get", apiPaths.definitionOfDone(), "Evaluate project definition of done"], ["put", apiPaths.definitionOfDone(), "Save project completion requirements"], ["get", apiPaths.autonomySimulation(), "Simulate the current autonomous plan"], ["get", apiPaths.projectAttention(), "Read the deduplicated project attention inbox"], + ["get", apiPaths.projectKnowledge(), "Read the unified project knowledge view"], ["get", apiPaths.projectKnowledgeConflicts(), "Read project truth conflicts and reconciliation provenance"], + ["get", apiPaths.projectEvidenceGraph(), "Read requirement-to-runtime evidence relationships"], + ["post", apiPaths.diagnostics, "Run safe platform diagnostics"], ["get", apiPaths.diagnosticsHistory, "Read recent diagnostic history"], ["post", apiPaths.projectDiagnostics(), "Run safe project diagnostics"], ["get", apiPaths.projectPulse(), "Read the concise project pulse"], ["get", apiPaths.portfolioPulse, "Read the concise portfolio pulse"], + ["post", apiPaths.commandInterpret, "Interpret a natural-language control command"], ["post", apiPaths.projectCommandInterpret(), "Interpret a project control command"], ["get", apiPaths.portfolioViews, "List saved portfolio views"], ["post", apiPaths.portfolioViews, "Create a saved portfolio view"], ["patch", apiPaths.portfolioView(), "Update a saved portfolio view"], ["delete", apiPaths.portfolioView(), "Delete a saved portfolio view"], ["get", apiPaths.portfolioPins, "List per-user pinned projects"], ["put", apiPaths.portfolioPin(), "Pin or unpin a project for the current user"], +]; + +const openApiPaths = () => operations.reduce((paths, [method, path, summary]) => { + const normalizedPath = openApiPath(path); + paths[normalizedPath] = { + ...(paths[normalizedPath] ?? {}), + [method]: { summary, responses: { 200: { description: "Successful canonical envelope" }, 201: { description: "Created canonical envelope" }, 400: { description: "Validation error envelope" }, 401: { description: "Authentication required" }, 403: { description: "Authorization or CSRF denied" }, 409: { description: "State transition conflict" } } }, + }; + return paths; +}, {}); + +export const buildOpenApiDocument = () => ({ + openapi: "3.1.0", + info: { title: "ProjectBrain API", version: "1.0.0", description: "Authenticated private-network API; generated from @projectbrain/contracts." }, + servers: [{ url: "/" }], + paths: openApiPaths(), + components: { schemas: { + ApiMeta: { type: "object", required: ["requestId", "timestamp"], properties: { requestId: { type: "string", minLength: 1 }, timestamp: { type: "string", format: "date-time" } } }, + ApiError: { type: "object", required: ["error", "meta"], properties: { error: { type: "object", required: ["code", "message"], properties: { code: { type: "string" }, message: { type: "string" } } }, meta: { $ref: "#/components/schemas/ApiMeta" } } }, + } }, +}); diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..e74de20 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,19 @@ +{ + "name": "@projectbrain/contracts", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "dependencies": { + "zod": "^3.25.76" + }, + "engines": { + "node": ">=24 <25", + "pnpm": "11.5.2" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d49bc51 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.PROJECTBRAIN_E2E_PORT ?? 4177); +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: "./e2e", + outputDir: "./test-results/mock", + fullyParallel: false, + workers: 2, + timeout: 60_000, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [["html", { open: "never" }], ["line"]] : "line", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + webServer: { + command: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { name: "chromium-desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "chromium-tablet", use: { ...devices["Desktop Chrome"], viewport: { width: 1024, height: 768 }, hasTouch: true } }, + { name: "chromium-mobile", use: { ...devices["Pixel 7"] } }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..886ec42 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,6452 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + caniuse-lite: ^1.0.30001799 + esbuild: 0.28.2 + hono: ^4.13.5 + qs: ^6.16.0 + fast-uri@>=3.0.0 <4.0.0: 3.1.6 + fast-uri@>=4.0.0 <5.0.0: 4.1.3 + +importers: + + .: + dependencies: + '@hookform/resolvers': + specifier: ^3.9.0 + version: 3.10.0(react-hook-form@7.55.0(react@19.2.8)) + '@projectbrain/contracts': + specifier: workspace:* + version: link:packages/contracts + '@radix-ui/react-accordion': + specifier: ^1.2.0 + version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.1 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-aspect-ratio': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-avatar': + specifier: ^1.1.0 + version: 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-checkbox': + specifier: ^1.1.1 + version: 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collapsible': + specifier: ^1.1.0 + version: 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-context-menu': + specifier: ^2.2.1 + version: 2.2.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': + specifier: ^1.1.2 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.1 + version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-hover-card': + specifier: ^1.1.1 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-label': + specifier: ^2.1.0 + version: 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menubar': + specifier: ^1.1.1 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-navigation-menu': + specifier: ^1.2.0 + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': + specifier: ^1.1.1 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-progress': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-radio-group': + specifier: ^1.2.0 + version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': + specifier: ^1.1.0 + version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-select': + specifier: ^2.1.1 + version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slider': + specifier: ^1.2.0 + version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': + specifier: ^1.1.0 + version: 1.2.0(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-switch': + specifier: ^1.1.0 + version: 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tabs': + specifier: ^1.1.0 + version: 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toast': + specifier: ^1.2.1 + version: 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle-group': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tooltip': + specifier: ^1.1.4 + version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-query': + specifier: ^5.56.2 + version: 5.74.4(react@19.2.8) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.0.0 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + date-fns: + specifier: ^3.6.0 + version: 3.6.0 + embla-carousel-react: + specifier: ^8.3.0 + version: 8.6.0(react@19.2.8) + fflate: + specifier: ^0.8.3 + version: 0.8.3 + input-otp: + specifier: ^1.2.4 + version: 1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + lucide-react: + specifier: ^0.462.0 + version: 0.462.0(react@19.2.8) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-day-picker: + specifier: ^9.13.0 + version: 9.13.0(react@19.2.8) + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + react-hook-form: + specifier: ^7.53.0 + version: 7.55.0(react@19.2.8) + react-is: + specifier: ^19.2.8 + version: 19.2.8 + react-resizable-panels: + specifier: ^2.1.3 + version: 2.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router-dom: + specifier: ^7.18.2 + version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) + sonner: + specifier: ^1.5.0 + version: 1.7.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tailwind-merge: + specifier: ^2.5.2 + version: 2.6.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + zod: + specifier: ^3.25.0 + version: 3.25.76 + devDependencies: + '@axe-core/playwright': + specifier: ^4.12.1 + version: 4.12.1(playwright-core@1.62.0) + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.8.1(jiti@1.21.7)) + '@playwright/test': + specifier: ^1.62.0 + version: 1.62.0 + '@tailwindcss/typography': + specifier: ^0.5.20 + version: 0.5.20(tailwindcss@3.4.17) + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + '@types/react': + specifier: ^19.2.8 + version: 19.2.8 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.8) + '@vitejs/plugin-react': + specifier: ^6.1.0 + version: 6.1.0(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.21(postcss@8.5.24) + caniuse-lite: + specifier: ^1.0.30001799 + version: 1.0.30001799 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + eslint: + specifier: ^10.8.1 + version: 10.8.1(jiti@1.21.7) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.8.1(jiti@1.21.7)) + eslint-plugin-react-refresh: + specifier: ^0.5.4 + version: 0.5.4(eslint@10.8.1(jiti@1.21.7)) + globals: + specifier: ^17.11.0 + version: 17.11.0 + postcss: + specifier: ^8.5.24 + version: 8.5.24 + tailwindcss: + specifier: ^3.4.11 + version: 3.4.17 + tailwindcss-animate: + specifier: 1.0.7 + version: 1.0.7(tailwindcss@3.4.17) + typescript: + specifier: ^5.5.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.67.0 + version: 8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3) + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1) + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1)) + + backend: + dependencies: + '@fastify/cookie': + specifier: ^11.1.2 + version: 11.1.2 + '@fastify/formbody': + specifier: ^8.0.2 + version: 8.0.2 + '@fastify/helmet': + specifier: ^13.1.0 + version: 13.1.0 + '@fastify/rate-limit': + specifier: ^11.1.0 + version: 11.1.0 + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@3.25.76) + '@prisma/client': + specifier: ^5.22.0 + version: 5.22.0(prisma@5.22.0) + '@projectbrain/contracts': + specifier: workspace:* + version: link:../packages/contracts + fastify: + specifier: ^5.12.4 + version: 5.12.4 + jose: + specifier: ^6.2.9 + version: 6.2.9 + prisma: + specifier: 5.22.0 + version: 5.22.0 + tsx: + specifier: ^4.23.12 + version: 4.23.12 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) + typescript: + specifier: ^5.7.3 + version: 5.8.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1)) + + packages/contracts: + dependencies: + zod: + specifier: ^3.25.76 + version: 3.25.76 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} + peerDependencies: + playwright-core: '>= 1.0.0' + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cookie@11.1.2': + resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/helmet@13.1.0': + resolution: {integrity: sha512-SvVOU0IrzYJW1BvSkfq9G1WUdW3dnaRUvg6m0BtgGMBmML62No0VmSu087jecH58SFbicbREgZTPJ89mAguupA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/rate-limit@11.1.0': + resolution: {integrity: sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA==} + + '@floating-ui/core@1.6.9': + resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} + + '@floating-ui/dom@1.6.13': + resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==} + + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.9': + resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4.13.5 + + '@hookform/resolvers@3.10.0': + resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} + peerDependencies: + react-hook-form: ^7.0.0 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + + '@humanwhocodes/retry@0.4.2': + resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + + '@radix-ui/react-accordion@1.2.4': + resolution: {integrity: sha512-SGCxlSBaMvEzDROzyZjsVNzu9XY5E28B3k8jOENyrz6csOv/pG1eHyYfLJai1n9tRjwG61coXDhfpgtxKxUv5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.7': + resolution: {integrity: sha512-7Gx1gcoltd0VxKoR8mc+TAVbzvChJyZryZsTam0UhoL92z0L+W8ovxvcgvd+nkz24y7Qc51JQKBAGe4+825tYw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.3': + resolution: {integrity: sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.3': + resolution: {integrity: sha512-yIrYZUc2e/JtRkDpuJCmaR6kj/jzekDfQLcPFdEWzSOygCPy8poR4YcszaHP5A7mh25ncofHEpeTwfhxEuBv8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.4': + resolution: {integrity: sha512-+kBesLBzwqyDiYCtYFK+6Ktf+N7+Y6QOTUueLGLIbLZ/YeyFW6bsBGDsN+5HxHpM55C90u5fxsg0ErxzXTcwKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.1.5': + resolution: {integrity: sha512-B0gYIVxl77KYDR25AY9EGe/G//ef85RVBIxQvK+m5pxAC7XihAc/8leMHhDvjvhDu02SBSb6BuytlWr/G7F3+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.4': + resolution: {integrity: sha512-u7LCw1EYInQtBNLGjm9nZ89S/4GcvX1UR5XbekEgnQae2Hkpq39ycJ1OhdeN1/JDfVNG91kWaWoest127TaEKQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.3': + resolution: {integrity: sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.7': + resolution: {integrity: sha512-EwO3tyyqwGaLPg0P64jmIKJnBywD0yjiL1eRuMPyhUXPkWWpa5JPDS+oyeIWHy2JbhF+NUlfUPVq6vE7OqgZww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.7': + resolution: {integrity: sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.6': + resolution: {integrity: sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.7': + resolution: {integrity: sha512-7/1LiuNZuCQE3IzdicGoHdQOHkS2Q08+7p8w6TXZ6ZjgAULaCI85ZY15yPl4o4FVgoKLRT43/rsfNVN8osClQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.2': + resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.3': + resolution: {integrity: sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.7': + resolution: {integrity: sha512-HwM03kP8psrv21J1+9T/hhxi0f5rARVbqIZl9+IAq13l4j4fX+oGIuxisukZZmebO7J35w9gpoILvtG8bbph0w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.3': + resolution: {integrity: sha512-zwSQ1NzSKG95yA0tvBMgv6XPHoqapJCcg9nsUBaQQ66iRBhZNhlpaQG2ERYYX4O4stkYFK5rxj5NsWfO9CS+Hg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.7': + resolution: {integrity: sha512-tBODsrk68rOi1/iQzbM54toFF+gSw/y+eQgttFflqlGekuSebNqvFNHjJgjqPhiMb4Fw9A0zNFly1QT6ZFdQ+Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.7': + resolution: {integrity: sha512-YB2zFhGdZ5SWEgRS+PgrF7EkwpsjEHntIFB/LRbT49LJdnIeK/xQQyuwLiRcOCgTDN+ALlPXQ08f0P0+TfR41g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.6': + resolution: {integrity: sha512-HJqyzqG74Lj7KV58rk73i/B1nnopVyCfUmKgeGWWrZZiCuMNcY0KKugTrmqMbIeMliUnkBUDKCy9J6Mzl6xeWw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.7': + resolution: {integrity: sha512-I38OYWDmJF2kbO74LX8UsFydSHWOJuQ7LxPnTefjxxvdvPLempvAnmsyX9UsBlywcbSGpRH7oMLfkUf+ij4nrw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.3': + resolution: {integrity: sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.5': + resolution: {integrity: sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.3': + resolution: {integrity: sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.0.3': + resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.3': + resolution: {integrity: sha512-F56aZPGTPb4qJQ/vDjnAq63oTu/DRoIG/Asb5XKOWj8rpefNLtUllR969j5QDN2sRrTk9VXIqQDRj5VvAuquaw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.2.4': + resolution: {integrity: sha512-oLz7ATfKgVTUbpr5OBu6Q7hQcnV22uPT306bmG0QwgnKqBStR98RfWfJGCfW/MmhL4ISmrmmBPBW+c77SDwV9g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.3': + resolution: {integrity: sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.4': + resolution: {integrity: sha512-G9rdWTQjOR4sk76HwSdROhPU0jZWpfozn9skU1v4N0/g9k7TmswrJn8W8WMU+aYktnLLpk5LX6fofj2bGe5NFQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.1.7': + resolution: {integrity: sha512-exzGIRtc7S8EIM2KjFg+7lJZsH7O7tpaBaJbBNVDnOZNhtoQ2iV+iSNfi2Wth0m6h3trJkMVvzAehB3c6xj/3Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.3': + resolution: {integrity: sha512-2omrWKJvxR0U/tkIXezcc1nFMwtLU0+b/rDK40gnzJqTLWQ/TD/D5IYVefp9sC3QWfeQbpSbEA6op9MQKyaALQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.2.4': + resolution: {integrity: sha512-Vr/OgNejNJPAghIhjS7Mf/2F/EXGDT0qgtiHf2BHz71+KqgN+jndFLKq5xAB9JOGejGzejfJLIvT04Do+yzhcg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.0': + resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.1.4': + resolution: {integrity: sha512-zGP6W8plLeogoeGMiTHJ/uvf+TE1C2chVsEwfP8YlvpQKJHktG+iCkUtCLGPAuDV8/qDSmIRPm4NggaTxFMVBQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.4': + resolution: {integrity: sha512-fuHMHWSf5SRhXke+DbHXj2wVMo+ghVH30vhX3XVacdXqDl+J4XWafMIGOOER861QpBx1jxgwKXL2dQnfrsd8MQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.7': + resolution: {integrity: sha512-0IWTbAUKvzdpOaWDMZisXZvScXzF0phaQjWspK8RUMEUxjLbli+886mB/kXTIC3F+t5vQ0n0vYn+dsX8s+WdfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.3': + resolution: {integrity: sha512-khTzdGIxy8WurYUEUrapvj5aOev/tUA8TDEFi1D0Dn3yX+KR5AqjX0b7E5sL9ngRRpxDN2RRJdn5siasu5jtcg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.3': + resolution: {integrity: sha512-Za5HHd9nvsiZ2t3EI/dVd4Bm/JydK+D22uHKk46fPtvuPxVCJBUo5mQybN+g5sZe35y7I6GDTTfdkVv5SnxlFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.0': + resolution: {integrity: sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.1.1': + resolution: {integrity: sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.1.3': + resolution: {integrity: sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/typography@0.5.20': + resolution: {integrity: sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==} + peerDependencies: + tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' + + '@tanstack/query-core@5.74.4': + resolution: {integrity: sha512-YuG0A0+3i9b2Gfo9fkmNnkUWh5+5cFhWBN0pJAHkHilTx6A0nv8kepkk4T4GRt4e5ahbtFj2eTtkiPcVU1xO4A==} + + '@tanstack/react-query@5.74.4': + resolution: {integrity: sha512-mAbxw60d4ffQ4qmRYfkO1xzRBPUEf/72Dgo3qqea0J66nIKuDTLEqQt0ku++SDFlMGMnB6uKDnEG1xD/TDse4Q==} + peerDependencies: + react: ^18 || ^19 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.1': + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.8': + resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.1.0': + resolution: {integrity: sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} + engines: {node: '>=10'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.138: + resolution: {integrity: sha512-FWlQc52z1dXqm+9cCJ2uyFgJkESd+16j6dBEjsgDNuHjBpuIzL8/lRc0uvh1k8RNI6waGo6tcy2DvwkTBJOLDg==} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-toolkit@1.47.1: + resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.4: + resolution: {integrity: sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.8.1: + resolution: {integrity: sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + + fast-uri@4.1.3: + resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.12.4: + resolution: {integrity: sha512-zATD676fP6tBMsn+jQ7Q5/cO/Qo+149TXH0dUqUKel+1/hcZxwIbnflqtPa9EjlrvpY8eCZcL3M1UYrMc31Y7g==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} + engines: {node: '>=20'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + helmet@8.3.0: + resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} + engines: {node: '>=18.0.0'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + engines: {node: '>=16.9.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + input-otp@1.4.2: + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.462.0: + resolution: {integrity: sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-day-picker@9.13.0: + resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-hook-form@7.55.0: + resolution: {integrity: sha512-XRnjsH3GVMQz1moZTW53MxfoWN7aDpUg/GpVNc4A3eXRVNdGXfbzJ4vM4aLQ8g6XCUh1nIbx70aaNCl7kxnjog==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.6.3: + resolution: {integrity: sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-resizable-panels@2.1.7: + resolution: {integrity: sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + react-router-dom@7.18.2: + resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.2: + resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + sonner@1.7.4: + resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@2.6.0: + resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.17: + resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: 0.28.2 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.7.1: + resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} + engines: {node: '>= 14'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@axe-core/playwright@4.12.1(playwright-core@1.62.0)': + dependencies: + axe-core: 4.12.1 + playwright-core: 1.62.0 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@date-fns/tz@1.4.1': {} + + '@epic-web/invariant@1.0.0': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.1(jiti@1.21.7))': + dependencies: + eslint: 10.8.1(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.8.1(jiti@1.21.7))': + optionalDependencies: + eslint: 10.8.1(jiti@1.21.7) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.6 + + '@fastify/cookie@11.1.2': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/helmet@13.1.0': + dependencies: + fastify-plugin: 6.0.0 + helmet: 8.3.0 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/rate-limit@11.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 6.0.0 + toad-cache: 3.7.1 + + '@floating-ui/core@1.6.9': + dependencies: + '@floating-ui/utils': 0.2.9 + + '@floating-ui/dom@1.6.13': + dependencies: + '@floating-ui/core': 1.6.9 + '@floating-ui/utils': 0.2.9 + + '@floating-ui/react-dom@2.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.6.13 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.9': {} + + '@hono/node-server@2.1.1(hono@4.13.7)': + dependencies: + hono: 4.13.7 + + '@hookform/resolvers@3.10.0(react-hook-form@7.55.0(react@19.2.8))': + dependencies: + react-hook-form: 7.55.0(react@19.2.8) + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.1': {} + + '@humanwhocodes/retry@0.4.2': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lukeed/ms@2.0.2': {} + + '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.7) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.6.2(express@5.2.1) + hono: 4.13.7 + jose: 6.2.9 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@oxc-project/types@0.146.0': {} + + '@pinojs/redact@0.4.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.2': {} + + '@radix-ui/react-accordion@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collapsible': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-alert-dialog@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-arrow@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-aspect-ratio@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-avatar@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-checkbox@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-collapsible@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-collection@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.8)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-context-menu@2.2.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-menu': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.8)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-dialog@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + aria-hidden: 1.2.4 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.6.3(@types/react@19.2.8)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-dismissable-layer@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-dropdown-menu@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-menu': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.8)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-focus-scope@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-hover-card@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-label@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-menu@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + aria-hidden: 1.2.4 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.6.3(@types/react@19.2.8)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-menubar@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-menu': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-navigation-menu@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-popover@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + aria-hidden: 1.2.4 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.6.3(@types/react@19.2.8)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-popper@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/rect': 1.1.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-portal@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-presence@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-progress@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-radio-group@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-roving-focus@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-scroll-area@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-select@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + aria-hidden: 1.2.4 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.6.3(@types/react@19.2.8)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-separator@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-slider@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-slot@1.2.0(@types/react@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-switch@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-tabs@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-toast@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-toggle-group@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-toggle@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-tooltip@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-visually-hidden@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/rect@1.1.1': {} + + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.8)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.8)(react@19.2.8)(redux@5.0.1) + + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/typography@0.5.20(tailwindcss@3.4.17)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.17 + + '@tanstack/query-core@5.74.4': {} + + '@tanstack/react-query@5.74.4(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.74.4 + react: 19.2.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.1': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.8)': + dependencies: + '@types/react': 19.2.8 + + '@types/react@19.2.8': + dependencies: + csstype: 3.2.3 + + '@types/use-sync-external-store@0.0.6': {} + + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3))(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 10.8.1(jiti@1.21.7) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + eslint: 10.8.1(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.67.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.8.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3) + debug: 4.4.3 + eslint: 10.8.1(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.8.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.8.3) + eslint: 10.8.1(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.1.0(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1) + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + aria-hidden@1.2.4: + dependencies: + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + atomic-sleep@1.0.0: {} + + autoprefixer@10.4.21(postcss@8.5.24): + dependencies: + browserslist: 4.24.4 + caniuse-lite: 1.0.30001799 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.24 + postcss-value-parser: 4.2.0 + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axe-core@4.12.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + binary-extensions@2.3.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.138 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.4) + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001799: {} + + chai@6.2.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cookie@2.0.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + date-fns-jalali@4.1.0-0: {} + + date-fns@3.6.0: {} + + date-fns@4.1.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + deep-is@0.1.4: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.138: {} + + embla-carousel-react@8.6.0(react@19.2.8): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.2.8 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-toolkit@1.47.1: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.8.1(jiti@1.21.7)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.8.1(jiti@1.21.7) + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.4(eslint@10.8.1(jiti@1.21.7)): + dependencies: + eslint: 10.8.1(jiti@1.21.7) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.8.1(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.6 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.2 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + expect-type@1.4.0: {} + + express-rate-limit@8.6.2(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.3 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.6: {} + + fast-uri@4.1.3: {} + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.12.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.7.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.4 + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fflate@0.8.3: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-my-way@9.7.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forwarded@0.2.0: {} + + fraction.js@4.3.7: {} + + fresh@2.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@17.11.0: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + helmet@8.3.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hono@4.13.7: {} + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + immer@10.2.0: {} + + immer@11.1.8: {} + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + input-otp@1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + internmap@2.0.3: {} + + ip-address@10.5.0: {} + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.4.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.7: {} + + jose@6.2.9: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.462.0(react@19.2.8): + dependencies: + react: 19.2.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.16: {} + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + node-releases@2.0.19: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + obug@2.1.4: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.5: {} + + pify@2.3.0: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pirates@4.0.7: {} + + pkce-challenge@5.0.1: {} + + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss-import@15.1.0(postcss@8.5.24): + dependencies: + postcss: 8.5.24 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.10 + + postcss-js@4.0.1(postcss@8.5.24): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.24 + + postcss-load-config@4.0.2(postcss@8.5.24): + dependencies: + lilconfig: 3.1.3 + yaml: 2.7.1 + optionalDependencies: + postcss: 8.5.24 + + postcss-nested@6.2.0(postcss@8.5.24): + dependencies: + postcss: 8.5.24 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.24: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + process-warning@5.1.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + react-day-picker@9.13.0(react@19.2.8): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.8 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-hook-form@7.55.0(react@19.2.8): + dependencies: + react: 19.2.8 + + react-is@19.2.8: {} + + react-redux@9.3.0(@types/react@19.2.8)(react@19.2.8)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + redux: 5.0.1 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.8)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.8)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.8 + + react-remove-scroll@2.6.3(@types/react@19.2.8)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.8)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.8)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.8)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.8)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.8 + + react-resizable-panels@2.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + react-router-dom@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react-style-singleton@2.2.3(@types/react@19.2.8)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.8 + + react@19.2.8: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + recharts@3.8.1(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.8)(react@19.2.8)(redux@5.0.1))(react@19.2.8) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.47.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-is: 19.2.8 + react-redux: 9.3.0(@types/react@19.2.8)(react@19.2.8)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.8) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + require-from-string@2.0.2: {} + + reselect@5.1.1: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + secure-json-parse@4.1.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + sonner@1.7.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@2.6.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.17): + dependencies: + tailwindcss: 3.4.17 + + tailwindcss@3.4.17: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.24 + postcss-import: 15.1.0(postcss@8.5.24) + postcss-js: 4.0.1(postcss@8.5.24) + postcss-load-config: 4.0.2(postcss@8.5.24) + postcss-nested: 6.2.0(postcss@8.5.24) + postcss-selector-parser: 6.1.2 + resolve: 1.22.10 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toad-cache@3.7.1: {} + + toad-cache@3.7.4: {} + + toidentifier@1.0.1: {} + + ts-api-utils@2.5.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript-eslint@8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3))(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@1.21.7))(typescript@5.8.3) + eslint: 10.8.1(jiti@1.21.7) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.8.3: {} + + undici-types@7.18.2: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.8)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.8 + + use-sidecar@1.1.3(@types/react@19.2.8)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.8 + + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + + util-deprecate@1.0.2: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 1.21.7 + tsx: 4.23.12 + yaml: 2.7.1 + + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@1.21.7)(tsx@4.23.12)(yaml@2.7.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + yallist@3.1.1: {} + + yaml@2.7.1: {} + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-validation-error@4.0.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..9389233 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,30 @@ +packages: + - . + - backend + - packages/* + +fetchWarnTimeout: 120000 + +# pnpm 11 non-interactive Docker/CI build-script approvals. +# Keep this in the workspace file; pnpm 11 no longer reliably reads +# legacy package.json#pnpm.onlyBuiltDependencies for strict dependency builds. +allowBuilds: + '@prisma/client': true + '@prisma/engines': true + '@swc/core': true + esbuild: true + prisma: true + +overrides: + caniuse-lite: ^1.0.30001799 + esbuild: 0.28.2 + hono: ^4.13.5 + qs: ^6.16.0 + 'fast-uri@>=3.0.0 <4.0.0': 3.1.6 + 'fast-uri@>=4.0.0 <5.0.0': 4.1.3 + +peerDependencyRules: + allowedVersions: + '@emnapi/core': '>=1.11.0' + '@emnapi/runtime': '>=1.11.0' + tailwindcss: 3.4.17 diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..42ad1ea185041cd73d4bc7ddbaa6d3857ef23bac GIT binary patch literal 56930 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 P00000NkvXXu0mjfsFhxi literal 0 HcmV?d00001 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4379f712233922b6d12597cf35c3264a5535c3d6 GIT binary patch literal 147037 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

=_p-H+#GCPlsb+Wr0zBQIc?0EY>olms~+ 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%0D@o58sqWl+ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PB + diff --git a/public/icon-180.png b/public/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..42ad1ea185041cd73d4bc7ddbaa6d3857ef23bac GIT binary patch literal 56930 zcmV)iK%&2iP)M_D=Woc2_-p>N%%QpF{A+3IQfl|K?BX4WA3<(Do@JRl2Ga z9l&`j00{)d1dbRG=>FjA`crO-aFugXLcl#B$R!{J9OM!hKW(-6t|Bkbd&9vL7(jvj zDggdRT@m@?&O4dj2x4P++XzuWFE9)T;r+ppL#;WuFCe%pruf;+zAVwP@iM5dSIv_oM~f0HFdQ z6r8D*;P_Y};wO&E({;G6;Mjk1p8rz*mjJu|WCcdIzlA_p0!rI~GjPV#W@1JEcmLy0 z`S%jez`;PMNGJtotekVc&{}!!3;+HB;tiDbe=PqCK>Po(Il5JdK=jK9=)C|3=S%@W z{{-@%@}CRm08|hv`ZYHDl~MkLt?X>x|Hf$i{{i$hiV+yusXqkzC80EhGe!09`xD52 z%Ku0>z!{;Uz!_^I3=cO}o?earPYUus1$4{tJSev7vjAQPAp&QrLI@H6hv)iJ{*-IN zIakRwe$AFQmK*0ZQxA&&0jU2cfTjRAFhB^3alJUG9UG|ET_5Twu$bT2m*TEd?UPlCR99Lx-1mgeT9DmB6^4}8i+`Je%D-3e9w;rLGv(E zeKP(8^Plo}6)qD&k{DBVHHP{|<8(6n&s=){37}iR-0K-CYTo>lX8bAtFlpx0OjW*W zEID!y1`H6f|8Fn8{0D&c6Y!w;UcCtw4m3N#h5z{YKjlyPgQdk#RDJg}6d(QSKUz`z zTL2}%LSjkS7a&r04j>8OD*JQ6}|6<{F+7X6M-|Bc0v$rMU*ZK@W9)D$)|7Zasn(QNIfnEGxvE)gmli; zKUV?&rd;bb4g!8-C-Rn^9Ki2){r>yJe+2mpcyoU3f0E{s(l{EvOla}uh5D-fI+k|isa_#>CAb-Lefd6d~RS-00ruv=*@VFnl z`XhCUcWHAh^yF z8eVS*us9>)lWQrJRD;>oPhtI_Be46aL(ajoVIQ(*i{NFB)}rptfBqk zpiYs_pn^7;tG@rM+L;qVc#Gy$RfTW>qA0=`5Lg!&9#tBXoKcvZ*{L)>yGC=V@3*a%}3ia=3IbiZ+8gi!L0u{v8{wTWtBifmO6F(;-jH!gF41^HqjV5bo zR9t>?yDnLs`;E*?Y7^b4GLX{jDogJ9W6@;=+0nIS#WD8Ax=5G99-_!TNmW%a=BFS3 zCB!%YavhwaB&j;1sljXssR@mVD~n1{V_XXN~5C5k$HwPgA=ME3V6i43nS^$K!Sczfj!$*P$+qkNzAo#b}&IH6i zZ81hw)$htQ1_ai}rDtB~*k{Pau7d}bXmn679=#NK@#vnklAJ4PjWv}CZl@zaRa9_r z2!a5OMkDJ@=2}C5rCx6|*@D8OtF=14M`tj5A`_GAm?Bd`Xfx$tgo+ZYuPnFN8fpVn zRT1i|%A-6kr_tHe80L1_Lwz2vUR70a?mxd)ulEFnMHWRTXXLl(-an^f-=5_f9n>@X z&%~TP_*-Vtm2(|zjdd}caZrJRQpx~7$Nxw6@83l@6e>!hs`{R-&)xMFx^$6l1oUc3 zr8}4^E6rh{|CU0UZ7Y>A!c+#0PV0$DO261;(3lhb#tzMA01j{6m2!6f_RbZBd2L*F zdjMk$fFH@X2lnZYPAYyb_C|s?^$0ZKx|N z4g4#8$pK4fxFak!!OEFR;!;wqIx`f#5sKRmu{^IPpg8+tXmQTv*vg{Zr24Az6sO%5 zswgT5AckO$4 z^o)m2hQ>&){TuhDp4qppOIdz)2e-pv009gJqcbcv;YxDb&X+p%8IqUMA-+MR;KjKO z0omtIM3r2<6kk_e7GrN{i10Y=!9JfSKvh)283zYPi&1x@Z~&C}y%~U?pG%U)sWqA! z%z?qxA(7GLu_>*}(mM7iPi_<4sFA?(FV_TJJi0$2>&(&i)n!GkoiyQ9eS?Ykp3ll1C$HLbHmT>-_g3fAUmzTsv_0nb_Obn3UCfeDFjh~Mz3$w zYV=Kdb3nabZ?IEQQbMC*%YT0*oDhMshU&@)uiIr%6}3JbP$o*UG}Y4sMsrqADt=K%vtcZ04Yl;)s~|{MfWMg^3w$ zt74Kftf4V*Yb3D78fX~<@wim6ydcN&x2`QTI?fRkYVt4!5)=k6V<1hn(3TYx1r+66 z3@^*Ql2BDrm}YILO>{e*!7cw=tJAr|V-ha6?>XQ^_u=F6qLL)Xku9eZkL=jkqd4ng zFSpBO5Jdrz@ky8ajk{yd=$Qku_H8_$xO4SqBPxpv+H=kz2tqTT|91g4S1A<*#+7}w zS=$B^bm=06IU>$cDrR!7GW;8?3=%>ZYc}>Q!NFAnZ=Jqz;{Bs89{DvZ?$_^Dj4aR3 z>8PkG0)m2S+jQ-{cg$Un9*u|*Tzl5GjUgsJKgU8Er0i z>NP04P0x&KQ35Z%Y7NNGIv-Y+do`xItT5iu)EMq|+QWPvcc7xk1^{R=Hd}-@=j{^& z3PK2|Kz&-Bv0iI1HkvF!HKCEQrLk$P%92}ktVwPY(WudaT{&GAdj8Pv)WXZ>+tyT+ zwsAV_A)Iqa8WEB4$)`K^8FFmsSZ zRHD`E9Uc1)+i}C|MK@>b%t=_KF8yZ20qBmR0nd}EO6(}T)2tg2-wV^)J>-88^*=O{6+(ArH^{UGJ zfF}e3rO@j1O_q?bg4nc-%Wb<4%IesseWfUYo!WaY_Vm7Oor%&{u52+~5@5nh29vhcCXu{MTBc~0@TKm!Z&WE=CJl0`r z3KIpP*|zu>_lWr2M--|$j)Z62GL4AhD$bOD=@8Ktt;`umV(T`??|os}dPM>G{_PjX z9@@4g?efWkU8+h7J9%8LAXViM1Okml<1_^Z6$OV!=Y_{77AK@-mM3SnZ-_}W zIxDNdt#(y4=ma4m7`li^|Mk_@0$Eu#Xlk+}J}J?cktxV-Cj_4lXsGt-OLMaVi?T1o zR2CP+*H@LL+8gVWeO|9cRXGSD&}g-;@aWhJ8J&Bb>^XcwR#GdS?Z7W5k`8a*)VDPE zNiHPX6V!HRmc3B!R2y8zj8d*f)qMJ^Qa}5H`EvxS}XjZ!|RxyM5N0iL*yt z`25u`2AtUQ>o||gtutFJ^*x7;-8gN|v}31s=SFY+{*y6f1v%XnmBAbkP@CSd+pckU zJ$5iANpkG`X@C0h-CO!s7U%W!d1Va&fkvlsg+xT3k4tNPp+oP1S2H?g)*8)FO7lFX zqU`e_6}eXuiVE@~>Z_|GTW2H$4({L0(yeO;(V)`>o6IHvfYa$jMMWh)bLIj$c;u}0 z;+0&FPOEi?goQU60sFkTCR~!epqsw>s{6&``-V5w*C!GH;W4pUgC^ej%jlW?vNnGD zTf2RmewFnWm8NPmlG>(6zb$vo zeR@Ytxy`uhomVE6=I8WeDnnp!NJ;xX1Gi0|bJyYGT#tGCs+GNSE}R-+4Oj)V}TAN^`S%JMGq3#yIpwgEgsDyM04$yZb=*!7)`oF5lAW_|DB^ z>Z_|-13-98%+>MtKDnmH(2VkL-}t)kiQU^KIUNpDU`R;KEwdi}Vc@Mj^FNyR)|ku3 z4~_%?O6k9%w&sKggmX2QL}X4lN2t(&|BJnqLI{|uFe*rbuiq`VueoR5lp`NJzjFAQ z{oBX;dZr9IW z4=c&NI!KlkK@$*#>^aZ$<_eP-p!j^WuH3QwW_SBo7dwBW{iUpK$Jvx zKwxmbDrmDNjO>>_d*-;zkWd{w4p6&i4JR1iqGq_wD5C{R&s_!#GK9O)cshmk&~_@W@zkt)*VYZ zwrlgi@`9WJKCi471d6EmhA{K=kndp53`(pX!ULMcIFM&_Yeb6;7f z)e7n-i=LlaoOPuap#-fucHjH>+Y2@v*l;vu;};*^<#9VLq9`!V|Mi>$oO2h6XfrXN zP$8Lf_Ag@)5`Za+N&|vJ%4a>Z=*x)sSo`w%PtGbYENG+AYUHkihX4H7tM~6*{lV7G zdpEA0YPZ=#^hUj-L$5(U-#h<-J?9SPM{fCk<+$>~yuPZ+U@#f$QrdLfF?QBtdy-n~ zY}?oD&p5g3*FjaK1$||oq6Gj+Be_DtBhJJoXPj%>z0cJyeLB@NDzFWR!gr*P*BN!eCN9zhlY$8)fk-&yUPLL(y_eoi^un+ z7iC}V*ic>3$?f(w(+$c)qhe09?=yJMxLFgAkbRe|KZo!`s(SvsOh|kGwK__p0}|blJ1vhv`m-!x9`GRzBnD zMPEk6$J#%f_vBsGrA3*7DBKuR^)DkNyzQ-S0`jj?(}WX%D~d|v(lXA>`)KLc`!^j; z-SqWGvz#`&B`hjBZ`N}Qzle&DbbR!}{HaAbS9=pekld>6uGtF~uXVd*@rMtWj4Qly zVWgrk7|o{Y_PqwIoB7P_{ne$aVe{7?_shL_dbq=8j|B(xdTo7VeDa|Vefl5iJ8F1- zU?_a2_U1%hK6$wH=_5zlj~&@J`Gx0ZCApjm!cBl)B2f9YxFi1sH^YMb1uO&tcodFm zJ76@6Sn$#c{J3^A3y+AXH3kIaMkgd+YS&}nr7nXymTLs?{o9WvoY}v#XIVjRKbON9 z%Q>Jk=o*sJ+w2}PdB(nOgX5}Jy|=OJ@!x*A-P+igLI@C@n0##fU5~Bn&^w{}>9(n)1?~KA$(#f zF!uIsgT{3!{PCl8T~F-YHrd+Hm@J9}X>B|I^5CNR>$A@lhyJwkt-ER~%G*eisB|AX zYV~8U-M#z$xu1_Xxp(_iMNyH_vFm|(pDg(CyElI7wSV*4JHa`nl>B$%2mdLAM7Ep! zPY>v3EdkCs?=^D#+WTIbzJKw(i)Q3qJl|WR)q00an)=0^PmMdd^x-!rUpaMrG(eEv zuH&!smoHegf9vIhb)S83UsFSUk|YXpddIHo9$NJ5#;OvJ@w>MdPc1LZ8>%u6y+K!( zn9*k2#Je8dk<`{?+xq>URwwst8&OkH)<>3A9U*`yQg={T#94h{_>m>g-!ry-o2WEV zRuE_)^u{$5#sIz6KmLsl2F`(CvmeQBAHT+{0Y9i69?o1uq#hYZe=WpIS@qnDOC&$|Djs!vo!Xl$i zkGuDgZ~NbpUb=k#7sDeT*=u|KVN`$42j@zVnf z9@$Y<0sZ>VKOC5M>Fj8i!xawzTAfrE79Dr6eXo8822U83CsVi$c2{PfG{c)*W!gyd z@4Jt2K(8U#d%=eOQ9r z?!LorvnLA_h)PJ_JL9PrR#`%go=;wSX?jUs)=18QgtUx(kG=NFciHE%!&ZOv=A+hz zrdUf*VAa&e=6({Jp4RyOvyVMmQ&Eu?92QzJ`-P<|y>6%U`K$Au^m)A|LHOqfHHqvn z<)0JKycx{A8O*aDSlN5TpuE=~dT35vZEb91bac*BZ-4M%*4gusYgWATh}-E742z1o z{LsR;K62QbG+)2*@*@qkb!j@C#@=VdxRnnsoP6k`g=_ks*u8C*+wBR_XhlccuD!l( z0rIN%Uz<>vb!DO=Gc6T3g2E#XbnZ9wmx;5-UT|0;?O3z1O~IwJJ+m%c?EApIQ^KBl zdQ+7GPB#2bQ&R%i+zhfP;j=Hd z;I%hDLvT<42!$;qJmPpt+YW~ZPn>!_HB+?z^8MkoBinx&Q(ay%R8hE2pg>f7;*Rn6 zJiNBw@VLrP7O(Dma^G)vJDu(*NfNx7-Fp8p=Z#0UZ2kUd#AjpF!-tpzs$Ht#nbl;1!3$n6$8H{@SeJ?D14*))U<@q^2 zpVuU`XzG7b0z#rXO#SBsGy&($n)#Ml_xr*8@WXQ&YU^TS5|hro^3Ag4Yd+f4ZO?`^ zvsIM|Y3({}TlD3;Z$4kLq4&Y9>+Y9*iY_cB`t2>zmFTDO90DS)H{5d|a=LSvv&xdLf)p6QCBcQqejSH&YGV|U~{a}7%PD5Q?Y)b1k zhhG2TrO)1d=F3qh_wTv`0J;wv_SMsG-@R?Y%w@B3FPCrkEJ;^ z0joY(_F!#QRZmKR)}6brdg0S2)~;Ca?3w>~7xiY_WX{^ZM_FZ*EYx4YVITl>uu zvQN>awd=6^m9L-w_RS|h8F%WyzDat6&OUp=lJ@}M^Vb&4k-Z)hrSzZDP$W?u@BHTi zGy(S)<^&%zWA>+gMh?n-{n1C~G}P9`rnJgDv~0~wpT2tkhm$XyI677o1$o4jneUF9 zHSFTc_s)H?vaGC&Uaz&@GIRD@x7^t~YvH|%9xBPt8%zmDMyGCHF8uh3pVqA0-{!ZU zRzG65JCj6#_!81ut$BRe{2!WXq51CJCAXI3WKWV6ZlZ)CC?s@$`#$|POnr3HS+@<+ zPhb4h)=^bFc3p80<0iG8~s z@%a>ENO<_UM_zg7y}HVB<9F}A`kbxFmJk>eSoFl}A1te`C^3Kc_N&i39j@T`)YKDi zu6^aBH=kTN?)1TZlXQBWefEOa-UEQoUt92$?DH8ZCI56TJrdPv`acz*3BVMU(LN)` zthsgev}23!f9&bTy877U)~ydMTm9mvi|_w%@}-l<#!4E|H*Us#Z+7Y5x9E+h9$jE> zvL~7?=KN`M7A~o-uF(Gc@jK7Eoz8emprvr~eGj}hepdJVXQ#gWNLgY2D1aa=Dr)zg zbLOs0Z4+#L=b5F`N^-JqlT~IED7>*rNo&X7{m{DJgCpuzE#KYt^nu-DYbq=H*=&x0 z;Gh6(`0+zTg%}a6SMWPPyP5%Q{VxEmBlz`|07M4=HK3cEiu&+Vubs%s&TB=f6r?f^ z3RMjZ4ms4eTkp+xK0f(WZ56cNzWdggqU>Y ztRQzX;{+k$VJ9A4{PsH@r%hV<+6!|V>l@o@b(*HpQ)j(BVoLw4g?B&qTy;f7H@!jE zIQ{Xt%L0N!-76M9_mb1@3W-liIq~M&#ePs9IxtCR(Ayqbxa^&ZqWqwrKVAMPA*_XV z{L_G%M0c9;PXlN|z!Zhiwmo|7c504?eEXz0&c-u5FJ~N(_3VseJ78S?}yW zd_i6H(RAJ6ub3h{j)0WwB@RE#x z|A2BhJRBX8L~M95;F_V z8jWDyztTeo1VS>)rShQvqx#Q8pj=vJpFn| zM3m!`#dBYBI$R;~DJdu4ShHyP;``p2cKOWd5tg99>IEx4dSmyxE$w!%Uvn1~h@W=- zlc0!1cb@rAaVafej!8~AyX=RTKY8)4rFZ3BzSJu`Ix_e5Z{Aw=+0t)@p4hwl4p9<) z<7eLcMo3h&?fdtZEvT<+u(a>edH3=Uo-Z~D=%R0Y!n5mtUgUd+-#F zoXS2p=$>a^ksC^*eqQPA7~Mk4;H;XIsfXFDY8!qFc@`6 z&qyyHHhfTB&t6^SunRAgBgjmevn2WN zk5WH)YnnJddCY{{`d&8*0Y3+?whQnoOs=%CD?TP3&nvAtU*lp~dSi&={J|ZqE}uGb zYkhUqD3uWng{nlv#jn5pzDIsY%80Oi^vXLE^Dke#Ph~`BH0tsPji3C%>}LjDn0NO( zv#y>yf0ryPh>TBKKkt)QSFU|;RsS;wcR$4$^hvEUHovxN?&nJ$_+-+B6GvxJfyl#d zo%Vj~&fQ8sS^WG9(@7=tm?VgQ4 z-%kb6J8t?tZ+Gb4yL9O@557@X+YoTuts~1`d+{C+f>@>klz~&Ipo9~~2^gnEtojbR>ydontGxOM@FXw$Vcj`-z zR+bdE^Mm>y@1rKsUGDk^1Dg1wAE+Qduwe1Bn(}hXy3ap)Ob{i{JT)?WKYaM^ zT+X;SYRdFCdk-Iy^Ttz8E~>1kjGjGfg8J+eQ>iS2Xo)0kWdG=l7OZxX| zt9aaqp%jv;g1THD9u%Uxd?Bm4;)R#q?U9{RXi3dTJMqykufF=~L!V7NfBfj(l18%6 zc>KB7w0fQV+0vI^k$tjZz%Apy7%_R`*;gNZbfLp$59>XAlfK?AZ^|mf0K5}I5KHZx-bVzgPbzpT(aAlSF$}SblrCH^)w#ufueKMFv0u2u! z`qr@s?T}=Q7~B@aC4%4pNrhPl!fxormIW5ZIjR~tIOm86q!0!2SF41S08TIGO+JA% z*jeq;&GO3Ja@A_{5LqLECWH}F_f#RKOR6R*A`l4Se zzWQ)RLv`f?RMdiV_1uHcJpJLA9&z^uTXa1&5~wQs^L0{xvLU8&N;w4t5om05v#-A2 z!ax6Zvk(&>bMn()m%j4yL!aJy;rQ{pG&;%l#Ov?P%Q<^0=9llkdJX`lJ^IX2tx+d` z_2!ZV;Ec?CYQfvG*DbDI{`OP;`})U*Ad{GG_x@u5&6|0%%4mE_+Nnif%=`TLJ70RD zqNKEStM=`8zp!G?cTY`venD+@ZCYBJ%(|mEgaC>PRM`nciK3zr6*jkGt!!W=7fN{UnL<`s(a2p20}nECZI=WR-lMfH zF(ry7QKm+CymCDx-F`zTc75^19^EgiRy~lK7(MvirB5O;J_J6u3bTfBy@r5Nkd~Q? za}Jdg=J0U1RFUcpz_y)d)VVKxB!oppo?Nl@wO5{<_QnH+**RkZ0!(GEef{42AC|8k zaBR=6d-VoG{mUypd}-6FwLK1R-#*hE7*Mn5^Y@l~|Nd8l&mKBB?i!!|2kB`N({1)Y z2GIVMKxlM2`%9lJU$pM?Z~L9xw|_!Ncu2upYukL6L)%iP^mx>WZ>G&SQ6SUL5gXUC`E<^BE)W!gR`;=r0kr+n)33R zvpJR4P2au$SbB6Y7Fin^4K*@BClaFmMQ{E`;iI$}%r#CyHJZVXoT}Kw%@OC~!iC-x zNN~HMt+gt3Ipu2p=|Wy~rGz&)ysDzp2tsl=%-zz&*mem*da#ivfHP=}($S_SmKz_a zJQ-xs7^y5H#6+(x`dqsfa-Hf36p$5;YCDH9K*E+CXVs_Xy(_eC-)`HxKRo-<1LGGi zs4A`K6%iA8?t^u&EO~zBvIh%tatFu6#hicZ$3^eVzia7(1=%^hl3S&pcwxo!U(cO3 z_qm4J`b1Iq$N8s8O!xc#QIvzJDyO5T-uY=_T4vSCB`?hbz#e|_)t3viuS9NH{oNB9 zt;Y83vJd7S+4*bczF)UIMyXKq+@b{^jqM)uya=w3FsZy{0&n52n~r+%%`PuW>r{jW zP~6s6i&;apP+4;hMdAljtpnW41-!S__u;n(yxFazX+T0ax3r5FW7;I>ViKYeqB|(~C7}y#}0&k4|N$_O9=6c>n%E(@a^C17L}w82`S-i<_6OfUr`KjhMnzriKcJ6m(7;{+iSd>Q!V%bHWpZJS zQgor1<((_w)#Z)c4FVo&CffG#B&>UeloB0?)PxYBtw9T75ELG4B&228y(NF7K>fcN zZ}z(2`h!-{$Y2PNu=Jgu)X%=%B=i|P==+Bk-@D=2+n;%}smYeyrc;NFPrv@i+NY*I zyQHbHDY0wcK0n|2_+7hRyyuBmoepQ{m}xUsCZ@HnUisRK^TD~$4ClW)s7Y-1`~Uuc zCSa<$H5N^}yqC|oyBIRM58 zJPP3O62b)vK>!NN?7Jt+J{*F7&SOY(#e_!JIN@TVg4 zDIAQoEb0I%pI7rnT=<^6{b<-cc0Ubj#G4ug9jQ)_(Td zqSqK>ntPs|yST2pDq!O`Up=kY>zn4Uc>k3RpMBT=%%Owh1(7iP16+9|w#Ng1Z$NX- z0ZEeFbKm=L(QiMk?Rt3EuF2t15jk)E@XG58XD@xQxS*&*B=HjCxzfYrsZ@udKC zOPAnj_Wddhyv8XIr;mBPgyvUQIfSDp&T5YyIvJT?SgiHR>aPRFOj!Eb3qwmEdg#kr z3bL-;>+#B+I3b`+ZZw$9zoxWmyYb#9r(f7t2y3!4dsw@8^P;rm=y;6;s;Z!1_#L2q zieEEw0F@1Dp+W@3<70*A3L0J*Ix4iH!42`F#T(n?T|P6;*4Q}4?NMT7*@w`O;L`pB zdgV`;INBBysf%(s5#?|zwu&Z_ccDlvJa|Pde}9(d;lxNGF+fz9M)0RQZJ}U=)ha&Q z=E0eK1$qrIyt4*(57R*q{419&vsQzXajucbx~-?r&wu%YRv|%w^20ALdGhSh{b~C) z|1wvrm73VUM?t&cqO&YV^Arq5kq zYqCaD`a5UN-$h7lj|cy*fF^`6MPamS|Na|>Po8%Ct!Ey8Nl{ht;TIMy%)N9jcKgrk z9tp6R%HLW2=Cg0lUvc}@a~CIsMueXGV$0$eXN-FB>G_XN88~WiTc(FhwIYSmtew{# z_Ttfx9SAfM+!$;x3P8P`FdFs#+5f!;REIMxI-A~+qbU+)po>{P|)8$jg?`vvm9PgDmw31NNqfhsP_uO-5 zT~w&p#^q4jDV%tG3aL*FMklk52%$!HjaFzxf}C;%cfMgqNsS7l&c94=cXLeat;I(V zn?Qx@>8w@8S))&+T*T?g_r7zbIKN;j@8k9^^kJNrC-=EBL->+Lr8gC4gpDIman zAv`kdqk{6rdq4PL-;i$)l(cO!nA=4}ik%`N#ZF04LR&i**EG7|I#a?cDFHcKs1z_2 zzLBE_R|!-wTdnRHc~^74oqyk>7l#g;`?0O5$#{9kk_Ya5`VkB4T4NsSJcr%Kn}hqs;|s{%#E* z=sW6`4QCD(#uVk`_B5M~4bvaHciZPnSB`hNydjCHDSL0ZqsQe<-+%Q0=S1v2aL|_k zaQeW(dkCd=quHDd0;tTNtDX`+|Lf68M6j8{r}(upA=ix{kpctTYp%KO2msZumUwNQ zRj<(+Du<4naO?L!J^x<&v5Q~Yx8;{#d_LYquM>6-9y5OM)$Om%Db3GGN*VO*ugCW8 z-R^XGXL4{srx(9VZQFYMiywWIoVWL#;djk_7wwQGvQ!3PY zJ<9m27cQ+y>Obe;{;gXFui5zG(rJ%9(JeS6=m(un-|6(p)9*~b{n-(3u0F9OG+6pU zZvZ69RiN2GmI#V#RGceN5MU(m$$qWP82q!Ej$r3m+21EEZcR#vK)?WMJht^eCMRuLr;ZAhUL`P z)F-X~d`Gv5ci*>O5CnyDLjIokYO!_y@a9j|1rih*T0C|}zYDv5T02Hn3GLdi|EAh% zw;}KHiPZgqFgt~YM4C@qOf9{BXW{M^DX6oqvYCAu#$HF3%>hd<0% z^V`y=1u7};z3{@bNdulb^ufF*3XbnTvZt`1;&YqT`@Ev!0fEv{1Q=y98pa$xbfQ~+ zVGV7Ys7;Cq;-)&g>N{JgW>E@Bbz7&~8tP(S|M`_A7Y@DOu}$}`!!=sEK~;HLRaNb> z;r-{F*!RoUL07gde!OeHzN57o;VKohQ%?N+*_QnWFScn;k8o`wYQD$62!Og_#v=aZ z_`jUJI0q3Z*v?NPk(U!lpU6! zfa2UKNVooY2=^0c*-5}eq#Z&YT7@Y=YP)KiRuv=lq~do=2|oc{R6K?n#z?_nc< zJ-)XfuA;oWZE$E%@f{D3JG^G)@+qpU)1ymaRDt)e7&rae06la;S-8BkkY z-^~=D&ztx5ytrgSzbCKcUcS233%#j#QkrE}q<@SfOw(OGxz~upYwUVwaM)_Th9Grl-TprqBbs9H( z{_*_#<_|v-856bF;q=D6w_wGzIj=tUy}_ufE-NnSw`2Xq^g*|b|DI5S-J3St(PwBz zQEW=mach$!V#7B(yNsSTeY+qCYV+9hzX#6#J{p=3#yBBCp`k^iruMqDbKTEl7z61& zV&ukC2a99MOUk+$P5QdK=Sd^kXC?V>INwfURd#Q21y`{ z;Z(QL3rAV&YnO9Q5EUEw+|})`-uuSfcW+z!*{6rSGDZrNpYG73$B^7zOYf+yu8T?; zJondA2aoLc_}IMwAsR{eI3_7}%-dgo5K*}Qoj&iZU;09Nr|t*6ZjZS*H^0NT%inqO zr%yh9eAV)g9;-jG>zRcwe;DfXsf`_zq>LZ~*OoWHd8OD}7#gZ)PdecCc^(N8v5_VZ3sZnZQ!7tC-)Y{Rh3n=3keGq4f_=a)Vl})pe}gjOG|BS)5D^G z+HL~|ja~l3Q$J>mc;VaX%9>?_KnxBxEhyOcW^+Sk@JecW&0 zvxoD}oNJZ4`}IlD3DI{^Lh5W*&ts`Wp5HcZ%A~95y@m{Lvbw7vQ7)^T5%_trN9GvV zUW4zS3qa3S67*Ui&_rs24`?lE%ml+^;I>dF24#<%GnS`(L?c-(Gv zhHv<8XP4o(PTD0<$^r6s!P*~AIs6F~2{D?C^}}xMe`(L=O~V)?=+vk0j$?a@<0{K5 zI|W!w<+Gj`eQ^7_HMcTOY3oj%*VmN!jKz8RBQ+Xoe_-yMuishl?F5HQj*E?t+23Q@ zh;QHgXh*G1Pf4wl5|@udY@Ta`W{#}1Snkc*M7(-}PVP~e*oRVed+WBD{qpy` zIXEmTsAyMRhDkAV~UR`TloGa!U-kyO!S8Z{(;plBj;i zIL#<6D)}*O=)6rs$BsKTf5lr}B#CcVn9%Kq<;xH5+_onoEm{AjAc5LFl+osUrUY<% zIr_Jiuu;Vj4mf-QvD;Ph#53>p>)ETFH6bPCM}-N- zuitrV_ESryZ8Znz3hV0{J8k^#Y-az_V^&iFoIJ4awn3xQ3nF8p&e&|Os12X*?mBer z_}?g{ID6>eh|Dh0)nQSQS&TOq4*i#>9E5Pj2{Jl#JXl`n45%n6>tHk+>c`zN?9A>h zn}?~4k~Uqs{d#P7XK(2W~c!LY9+DVDdAY#?eN8}Y> z|0;$C1zcLO>9x`4j~+|hxax;3j7SMOE&V=!?;Cw8iwnah40!Ie$4BlaVENH;(GzM; z0K7m$zpohOqm-l!nfL3Z(-%(oWcm;|7X`{b3=Xjj|6s$rmdcYKciFpV=|gGVx^A)r z$K=@U)+B2~O{&e>kX%_^o1U1OQE}u<)|Q4Q@A;%iO={~H66{e_b|ObE0EIm{DW)%F zd`I%2xhM0lT*}D#ZON?GUE1{`L_Ml7X~cIQt~mYWlJ`dB?p!j?5};e6GGcbum9HOj z*M}k)&ig189UiDH4>F~GPb z`F+p4^}u&Jy;xRNSv_d;s*CA;M~+zu5bW8q@!nw*x?~521Yfk-+;MAG?(W`y#F#CV z5}Z1GcvRQHNu{BY5m%a;98m|ipHJ;RaPVHB*^c@bHwrlep`s*thfTco_}(qQboVJ- zY}KK|p3)+>xvZ$Phta5Mn0o)X13Ncw9HnxBw(ZjO=j`*9p;cv-{dGE8f6tuh+kW`? z3y%VUm z6Eu27!bE|3W$v~6P*7UyYLF!1>W{l#yXEy~zUh7b_=%rE#n6BtJL?wRY$FLby**L`1NR`0jyVs zkSYoSIbTAm3(MuYuANeC?b{_Ece%ZtYHF=nSaXu zLybd9K=Fq)LOJIYQE{=Cf+GZ1?v=}Zv=UWDOqz6L_vTH#WCc=cX6DZFB2Pd?X?btG zUTmEH$fP~HHmo1bAkvJs9oE)XLtk24e2Z2h4UaB-$l3Dm8*TFCMu(TX8KpiV5+4NaWt7$1Sj0xM|+XR-HQZCQ$RdKHQTwWWnz3-#oS@CNW`}*<>V}w;jH9*@xSX z8}z7a^a-Ta&Y4g3+ZY1z`{P??E`W2FkF!cEK(vCGl~YCTc;NLwy(QSzwo|8-9HOw{ zo39^vZpp-*<^cWW#zuRW4PRbp-FxV;?+76{bNKN133qn75)^E?VzoL_e_MAkqf6iZ zzXB9TckdZ8Y-0B-0cL$&Re5FGiXyK$t!=xb;C@W}k(m2qH8cnzAn4em$MM5EPNzBT z-r&gixHFN_f;0chl>r)oD5EA%-T&K$O??!VXp_^^w^x>`#hey5!awKQ=W&Q&w1Vt0<_R=?_2j^^*^NF-4;_q#DiU?fTv^FOQqG*m~FF?~(O8 z&b@Q$a_tNAmwfLjt@D&-Rnv3xew4Q#%vLJ%%01&F`11J; z&o9Xsx$p;{D&9dL=MNk^s>f~j%s3R@_0hd#T0re&>Var&}_Ldbj0m% z6z+dxcG13NJu)*R7wRNL^~%u1iUP=yELLW9qQv87Bd3iY-ac>FtG9=^uWLX>9cRQblZ8P{E{RQ4--Uc zQ$$khq-b;jHV;&*o3nZ+Q0Eef3nCRX0+%^9ys~`LXH~V1&`n={_V_ceP2Hl?lCq`- zd+#4Uzu3CRfc{^B2-vf2%Z%xd^g0)4FT_uCuV>O7E?;PPboA-yB*C7Sb!DhPxiWgnv|YchT{}Q!R2!d~y0x|f`qJW( zTO^U&?wK>|msKBra=XF>V{B65_ZEv*F3icF4JLc;dh)5aKAy9BI%7ibMyuo4&?z0? zoiqP~9$R*v)Soz$+msxMxN)6HibkUJa1P7&DX?iJj5P-Y#_9CZYiBpido5$+!qqa9 zZX;YiwqpH~?&T#Vp(|d0`y2$rK+Zh7M~)enIsJ)8*2ngn`$bu4`Dsw?Sa9XINaXS5 zhu#j)+q2|ZqLDn0KKR1Ixc<-Yj_LbM@rR$kTUX!MaJ8y-?InrzHiwmtKDVooFZk08HujO!QGdN<@_KERXtAY*P-3#>`Y5f%PrWuq%Gl| zueqRab>`&B6ZhQl)-8FvUK=b?~2yM1Zx;*TF#9$=Ent7{rYZ(egb zxqZ(b%Q+FSZ_C!Z?tgC3Nux=d-Pq{pvf-P{8SQ)YT*rxkBfEExnJ|6O8J%9Ludb}_ zR#m2&Qd(#3mK8|5f7#N#&+y@=M1kHlNLKy5#V%h_F+M=^AT6PWA{YaAoY2JGO6L z{lj2|NxGPXgkKy^h-Jm469vlLGah;Hr_bMAc{_u~6dD<}zI8jD_3Fhd4{#>4aZ{(g z`sxe6juIu!*dUAXOj_G^ugMC;uBl>*hBBH{LF)4=$JN6GGz&r8>@3&Y9fYR1QKl;51Uy>ibqyHO}0% z@3=W843^F*h*4DV<9TXPeG}ZHNA@oY3kx55aPOhq%v)Z0r1ZejyA3*WABe!}>YU%U z8~56?6-VEhDiQfdMs(fMN4&h_!?knP&c6SS_k)5=x)ZtHyqA9T?0fJt@AATWey+}; z6gU;k|9Z{g{g1!&4S90$4{JANY4138`l{9IcHDDlXH9fsM*63q5_WCcGW*Gw#_iW> zXlawx-fR7LxhZWrc3A@mIJ9f$_-V6;9oK4UeQkAh&x%qQlhQJFC@Ph9Zrapyz=)AY z1%a@rGI~O3Ln_MDg4{>z=QcKKsc`1yWQ{aNyeM_qNVIbK=+nMN!au z@SxXLt=-{1cOg$|ohU_T#?oMimn$c7)EtjT!PKd})XZ_OzvS_eDNsEH-|Tv2bdTvD zjI6Gx{*eljsL_g#7wvuZg@gg~-?h2r^^}TXM0L$8-TPYGV3RHgwyWID|Qb z2oYTMF{Zc`uF7h2%}0L;D?*zaApkfBV@#1%fiU&Jq0=D_CxmWknzR5Nk@BjTHMdeO z4i40s#^2I^oyX&?SGe}Qxc&=%zVyqB--iSRjRjY|PPe)wVeq{9702G4D!{i-g|^$Q zkqbZX+cP!R?qUt6^H@bvq-gAtjOefsLHv315mTQriyAIj1D`x{@{04s$xE|ZB?Ri* zcWn1AgGOBa*;jL(cxn2tlE}-fR>y!}e#%K~-KoPDoQOEMZQHH0=G<~Xqd|RrO_Q9iJBej*Y_SUX5>MEa+ZJP>Y(&?L3JUa!B=foXZ*QirO}xkI~`OR#mR#Q z+jZ&PD_ax@0&uc@=6$m2k-$WY4ykK17%Ghx)2*Pi&cO-*I(8cHQmC*jGW-LHHY z*MGs!Tr@pNR9}ffAbkpVzp^xG$igLMO^$3r#9P5saK)p5GfoJl1XLu12n4hl!GHBb zi2p@WL93N$Sy_$#*r`j}v{)e`Ge!+l7-fgE)NH#$<&!4%RywuI>@9ojM~KEeDS7Bi zhi1$eay&4|I1x^8^H6Z^};(_3eL3L=rczigZQ z$P1HpX#`$hS5rH*Bp;^O_{6OW6LmW_ZtgpH%;-G=VZ7jK*0466LTiJ90J- zi{jGSv^~T?asK3qwq5%4&XNQmKRdfyOoG{F2?{ReoPdC^KLUAw$df{veb2#RVFel; z)T)ZAwmPlk?AW7S$)(fhx`7b1>C)xY@k58xWmS+OV`Gol8lkDFtm&=Q(8iII`d>JE zM2#F;Io<6V0M)fHyv-a(+MV{X5F)$-SW7{!P-C@3`e=TYN> zYKn4l9s!qqx6gjyl_3v*KS|P>+Z2^n?PCmiY0*OKs2~^`o!ob(P|ns!z_7u+EA$5a zeLZ^jw&q;P|CtI}s@33$f;~&#kLfq}ASBZyCOh`Eiwo(XmvmL}gI_w!sWcB9B2&Do z|DLJn$LasDpFjWt{I>g8Fd@+NX{|{YDWg|Pc|(;IIS!jT;Gu`6v@P26Y9Fe)zNM0} z%LmIhoEtfA(21Zx!+6eBcSED&qub{G+)f`JIc4y`uK9up&+#m|kP-0sFqq;(NQR`v z>H0?3XQD2^^2m&Lp8WFLM?NOpQ`J!4IOXsjIVe6QZ3P4!{q?7H(;t2Dj%@-_8tQ86 zM-}D37#SP;i^4U!EvwfI8a`p%HcFUUkd-~UQ@8LMb6~(FhtrdEVt-+5N@nJM2FlMK zKh~-HfB}~T3KZnzb&pRl+5&)Y#|8|Ksw&FOp8=+_(UYfsG4HX};}mG) z0s>6y9vdH0S)8B$xFA3Yj!JvE&VfT_EyDX|iU|O4G8ZLfHLA0J-;Rx|*Dbs}|JfJ5 zqMCpRQQ%ARcP{=Qw*S1Ji4@S6`JB7+cP<*7UD?#P&g(rxsL)Z7-7)|UN`?PT|1c1u z)oO4xyD;Fwr7|fqK@*!ANi8-Hmrv&T3XBG!p`ftzgP6W^=j88LI17sFQ>rm1tod%+ zj??Sr?l&6r_fbi!IeGHj*ALu#I~z5kdv#v9(s;Fk)PVur)5JKVj+zf0E2-!{dh9y_ ze2t9_wmWz1_XS4B#V_Ynr(3;p#eL7eIb$P*uc5B4VSH6NbYW4^8yV5**M0NjkP#Ed z{z9R!qP)CYx(p1fHU$_iJ3VsZ;r)fNsTmo&IYfTy@Ud>a2M;+l+lj^=&s^sbQ&~09G;tNF; zM?iHRDB81lLBf!i-y<5!B(6C3m+XCIXsc0+Z}V~C7s9!PDY8lkl|Wh|tN(Y6Qi1z? zD*0{qvA_Tm^gUCBBuYTe6r#MMUTx^rEz_2qlJrhk&u11E?OOZ*mmOaS+JG*p!(QH; zvwi+5Nl-pjm?kaj?AaeQB7)8r%Gn+Ts#(u8?USSwo89GE`Pu4w5|ZMCkg%|KiD)o> z^YO>`KlRe=^%Op9Lqp?jHKou5hlj7{RBu@Q*{b2g#*ErPxWbAH@+b5k7FB658ZLS~ zYRbM}3*!@0Gkya^e)8bap1p<)Izy>SDoe|{C#4#jjAmoL)9y?zE^t`l5))6WT)>6n zXHqgdw7W_PK~Zjg>&%Yn6DrCSJO9zC6Mw^MK&s;EM(maMCoUX2F_91f>FwHm_vW&LofIZy=(WPp#rL(zD=8>= zkT8XJ>C@+vS&ywAUsY@Gc;xtH8Wn=@PRTTifwE({yuj&#qkWr{h!BgRv}@-+x3`<{ z<^%@m3GQPF+!-x4j8s zqt!Z$!q;@q6Jw5EI(hnb5CQ4!+OK@^fCG)So1s7gx>W2 z;vf)3fm`h^VdtI`=BQ8v_DrYoj4_D1KBR+k9@3i9&Ww(H!sRFt4J)Yqq& zEbs*c1s8bSa$rTN(;OZhUFd&{j{a-7?KmNbh>a~QEp4!Pys9}cBqU!D!5SJG(li?C zY}G!ty0oac9U+`#wr_X!>X~z`I1zblLj0LC$4jDQl?0hB0q25(;H#^x>nl*jJ#b9l zth}teJ|F~{?c43xymnVdPIShA;Go~UZcv-eF@$iB_0gxs9KU$x;vJv@5;9tSJZ16T zE;5ta8;!anw>>*(Wllk3U1EeLpOX!BsD>C@Y!q#I*QQNh6%|sc5}+bxB-AY}_VLEkAN_|G)twh8zTB zQe9p-plk1-I;~F1ce%W+v(MXu!lI)NGom3Ej-O0V%WQQKgrGPlzkTQK9g78l%k|YY zZ7sp@1_T7=yPe+fiZZu3A~q^VVU*-u&WcG$O(`UV!0+t1xqW%l2g$z>L=c~nSXz{w zAI(4!9vN9!R^kls`B;G25?H7a!K_WzR7oVB^frk#rA4J32;n5XU7M^cXD_zqL_~B# z+_AGqOCnX3h8Rtzi_wwrG&VH!p^!ZT#|*q$kek;Zgd(+7=C-w8?d$|mA7D0Hwkrh0 z>2wY!%x!)4wMmDro;^E*PziAquwo=5l? zXUB7ue47Kx&_Ug80Vb`?AO;uJ6n}Sh%L~7F>X!DB{#%2h9FD=Y7QXI6TVB;lOGgtqT~Ul1P=6}5o~I(}sLz99og zj@$?CBehjEgL)1MuhnXVe2?3kdG)dq5E2@8loKsEee`&%R_)uK0s#v03p=*&+Nne! zs#;%L+dd!wKBLK4?DqH~D$8V3NLXl|3V{@6dxJmX^g35X2`WloVuVM>8%UEIcB&B;R52`IHc&*<7g6LbY41=@R9h zR_#-(D$B~-5dtJzSrw zvNpMOk!dl0Y!0PVPbr0YL!TU`r7)~@F=50qt)603If7! zY9HS0GzAU(%Ph-&%SUmoeL5VjR-$>bazd-%t(w@uRq(S z9h`P+(?BqfXWZmb=ZgyRhk#Hdq@-_Hw&<5`L^1_w4En8lfXlKx*5z~*LzKSO2ng@j zT1*0f(*?AwtVXr<=#uHIJoxgZywb{7C5=v`0^x!v5=w=e*++k~F%Y8BYLH)8ZaQqbnyBc@Um zNS?>1bU1ek=76B!{Sb8G;XQjZ;!=`MfDn|Gl(p~Lw`UHZFl$p&W^$U=rqydpydG~{ zZ58x^At6^(BGLS-*^yCkF}a*jRF;>=B&DX56AIK-)g(s62H6CW`W!ZUlpuf^Or~0n ze*w202ZhdHXp|(VcAGOo6sa7O5Y|***APPp5FHm=U0GHZ!$A=m9$s8jkRQe%5wpco zSXT~%&nri1b=tDfV0djdTWbPU>DZ%dX;pc38vrCeHR-~sqbJ)!&>=7+$^iV1qK9~x8Bhs@GFPY+mIPg zTSW&7`g#ZRp3G;L1rd0EWBx}juWy{k?c@%ZS7@}l;PS{Il!CSd7XLwwQp$abO1AGk z6=c*w+bdm2wYiwG>6DsZTC3J|Z6B|FaOOz){;31V!?Q+YY(84?W2gz8%4(Iub46-n zwUv8%qzY-^oP;M}y&=Hz1Cfm6;FdjO`i$)ND_7mTsj+_aoN4jp0%5scS?zrA49sSW zWiJu+;-0O0TgN8Gp9B$5oL|_jbJuRU;4-&1+1g}gYHb>gSmAPclk4iCH3bCbGDu?H z)!eXEJlcy08!&O*xjv$VHnM3PQHdL%wDt)KSMy9eUYy9V&C9ind6 zOGxhDN*uE4QT;uS4HLR0#|ojBt`_U2Oj+Ki|EPY4RG*VOtk$vb&uCpvxLPR7yxY2y zu$au|14J~6zir;rDl9tc6d<6ixTs6}o?WxSeB5evbja*vX%Yoe>v6d=tX7CdvniVs zjW*{}NmyWLP(CLj%8JTjk{pSoCq`)>>z*O>575&9y}+P> z0!fu2sWQ>3s%jJ&H|wY=GePKd=K$@@q!6SYIdLVRtgL$C7Y}tS0+$OEmG%6o6b8M) zxKEIb;{I*>lS9Hnj}aoEqNJ=tht8d^fXi^$oNapd4y_X?Z}fV-sQ_Rw8n1GQ#=PuC zizU#KPl$-p!lJ0CsOVxsfV$fH*r?dpdP+E44rfGYxW*+(qTT29g#duTWU5qEXlv`; zhJZjz4F^SaWo1xsXh$f8Y%a>WTo=T-XwvF*MT!jK z^~v#+@Wyss^j4?c(TXz#sjV}LE2}HpfKY@)gq=CD??^I{jL;d3N0;vM1T!wA5rJPB z6$QIUgaHLb<#yHMP&y{ko*A)1dkx{#qE&gQK}CS55R0V1NEJw`0u5p4**OUxy*d-K zrw)R{?Sr4Q-E_(UpHC%5gAOD5q(tZi#7F|Dpr|ZNukbJvN1&d-tfS2hLWq%*P=msC zTxCpFIrAan!Kruyj+jjU^EtjYLRm#J2p5ptQdr#uBtXJDn7cJa1Lve zH7+VT$U!K)9+xLVql2o^YU^d65@feS((Cn2j0>&>7V=ydvusv782l|_h>rXn~9iqGdu2A4fOx+m2+ z?5w{^)lC`dY# z7niq9Zk>JsOo7wkXxpuS>l)5|UawD1OHG7V6vYxnVX>uU(CPI0VhCE4=9UJT11)8o zP+04mLc*iNt3d#l%M%(E8)YSgD_)N`L}!E|iK1OrRExt3jYg+y;6y-mMZMW<39R7| zP*qkL8Ws^+3rbL1Qx_f)6WQQ5NeH>Al;e-k(1Iv1RaON+fJUS9Dk`HK0?}x-4xbFF zGH#GG8mrC##phK*C?(#gIK9K+a77cSh>DGFXsBx&9=p;J^IAxKhLt2B5N*_Qw=d%O1SP~&zuGr&}Y$3+)ZSJovHDk2~-;KDr* zejYohcRRzQvjzyVkBgCJz@#O#C1a<`{3#F=6;QdqF%B5D1d(PAyG_O2xA(yK;ay;N zcp(VFP3bd2DV%N}9>048`gc!+*QFpbfcSHX+*rSbUBAW~v#hoxH4ilrA~FRLVMdJZ z5%qd>RA{Xt``~grTXpH%vzn=1+2>QzdZfWEP+Gwlk0`H$q|xg0h@hoc&Xxol%$5QW z0VSoyF%gjwB>>RS(2x+H7*hwX!0B+t#l&cxloGesBS%OYFpXAMtE$}Wc0<(b4GkP3 zJZ`r}Z#38e3RzY}jb7^mC{%@sR1p4<^kCEasU&a?2!v1&Dsh!Dkq`<|5PcrEOGAhVK@gpG z2Sk7vMM1DKe`&Zd3g&I!0WR4q$AKwmot9eTb~sZAArLi&{LF;F$2SM+-_Y9t4w>J7X<7uOM z!a)dkaFxaC0WOJB4F|E5l|U;=QX`=fY<8Dkqm`V52vEGTUZc_Z2%+Gdi&PLeArzcJ zP*o5@2_uBSKX%}R5coVEQ4mF$5DKqXmPAod2nAdYmr-ZXdj$f>9*ca>K!lNQ;>#A!*I4FWcL(6K)t1S!~7z4~@)+T6WpBk#y>ngN* zs4k~FjtblvA8)bQthOXTMQnUTZGCNB0sw@Dg_M>Tm&bsL&>0Nnx!EN_glZvaq>7?C zXc&i?3R0baU|A>!_gNxhe5#^^P{MJwu(tO77jBDBiqk=Y$_*OwyZR$~EkRDXiZ6fj z;L~kxTq;(eZw5`vF#jP8(waS1{6D3Xu&giDKvkQLF`(P>@?;)*18_AOdR3DbG>uaGi2bhZ)(V()tDlj-Sq?CiAx}q{XA}Xp1 zAZTiA3J;Hruo4Q`t&XsuP`yjiXr2DjhBq(jPW)ZZ-6Y|R5hD2W0yD-b5d;Q8z|oxi z+HVY8-zEYB01gNMi#$Fo;LZLSr>znGVMo= zPiEwACI6gT}Z&xnh%p>#0K|jO+No|Uu-l0QId)k6^Z3VUY#WA$~+1~Ras?_(P*fx zt@FTPcLv5KCKQ6?K~WT=*%IUh0H&%2V*nIFh{6HUKXjrB;-id#5GtrGL(2Ue3Lv!k z{wW9%7-x(C1fT-r4F1S0zo+A-$sYn}zfk<{+`qfQ8$WNpAq4;>*Dw$2Pe{TTnja1V z211GGe>fOdMWK0{GcF3ErbX8_cebE_siFfQR1lck?Z$}CG4WhQ2QCwcdQgt*^;xzY z_u<9wTnI7|1e=?I&VV<5b|E^1;;x~(Yo&}ufq*cMn8;v!_}c6;k5BbUqCjj853xI3 zlv4V;!u5mzHMLg3D=UQDaPO25$g)E7i>rmfJ(2@Offx)_n6zA0fd`j6u=|`3 z5rGsY{q?N-XY%|R`Zv84fVeOUS@nO112Ad;y8`s=l!6~N9bpw{Q5t|CX*nlc1Occ> znW{1olz>W-D*L=52!WppDt>TM%2X9CzS)*ikmNdu$+d0Vps%iV0R3CrxSqc0CW%OH z=Hu}fa{b&C{&n`2fI)I?AGtPNT>o6X_KoYG-&pvLfIuJu7q0K)*Uv?NcN_pvLWrWM zN?f=ho(M1z?oWrwcuT4<;QPHE=p;a*zt4+jF%YcY=fxcZwAbn#0)R+47X=WXA~aRo zg>q4VsMne`5s_vs`x^zY;^V`#B87k(_Avm005Vd{6igImMFDh1a_wPgdDy=E&4az? zWkiQi_~hT8H^@v--Ndl$CpSKM%mlf)t{iXH?A!~)LP051 zXjX&Gq5=pr5uGT4UgNqH0AtXIltcuOfO`jPlEN&4f0DZPAo@YQ?W7la4X%}rxKU&U zFzX49T$NE#uOc$YU&VkB{|rC~Lx4#{M5tNZcN!p6P(T<|*-Ho^3J3vH7$uZkqlo_6 z7Q{~+1hFM0-F1pcZx}Cn!>mhM6v_=g`sVQ_%}VFm=YI<$H$naoKvO|vjC1M-q{KK= zslS{HWt_7Xa5B~cNJ?7*=m|*kh&NLdpVuc4K~O*l6xk;VqNo4_KA&vV=?y-BK~<gv$Kg<50s&kej(P`2bO^!qM*^S(oKgaV zURMPGDpR8X?uv}o`V@tQf^!6iSRDCzMJ52y>U1ur%Wd#04MFjFyn=t5Dog=E%e#R7 z8@p}_fpQQE&KYTWI5_8&3LL6oS(OkJks42)K?~0>U|1lY==K0|z+a%^wrOsmBLz zlSA={0@wbf24>CP4U6&jUW~t#7Na)M;IA+KyYXmt{Ye_BUSj}fjE4iT_DX|GQPo(^ z7+SSYZP>r}XcE8?8yj1eo1NPpL_|ma%wi}^|>A&sK+Zu zOQKjG90ZTk>4>LXMM!vv?aYbuQ3OC}L}*=YRecyhVYHYV8|xZO90K$veUsCwYMH`x zH=FW`gwX%`sQV;!>b%UUv4Ipm^tGJ2{NmCy~O`glM({N1ezEX2*1vt z{hN{`K&KNFtpF~o0OJH=vr*SVd4_b<;NT?%0Y>~4hn7U3!OqdEwS?GE0bcpWoX;E} z0(Urpx&~)?>k=3U5%p9c#s0xUASG~y1Vd&jGZ8T8^;)0Gn1&KSk|eL5F|H#wdc1Ce+;j~M5Kuv6sw(@>%|Tk+HLA)b03d|=RD~IQ9tfgFWAn;9xVFY; z(Cc+|jW#dpDr+qPfdN%0v!TAaJ}MzOw&3i!d^m0P#MHEuZD70L^C2-g9d0VnYDHBN zD$AkKXtYHNXKjuhD-1Q7jM*Nmiqf*mL`!H;p4Hv}Yl9=spvO9wj1e{$L8#HsnJMw} z=m$pYF{Gmghc3%d8PKb>h|GA=U!SG@ zl(%>wfaZk}LSQoLF1fb3##DQy&%; z8&yXr(9qBn9TO8~6$pdN<%+bJ{UL?2EQcrxs8+A5}Ks&e!k5`V4j)R+0(jY5Jw7U^hqtzCJ>hy<>)`aMd`dlLDke{8O7!n?q zMF2F_H+AaVtz)6$bs)f`8(0Bsur;AnCRA|w7{AfJaeFyZqXpbGOow|%=#UvN!tK4T zZYcr03gA%%)YN!vKSyq0<>BU2&F`2oHEX-+T0iiRT-lVR;RYU>G!R{>eN(jxFV@4;FfRn()c;PQAhCU6DADqAfeIDQE%gDbohVAhYR@XDKpw6h`-Co zPYK}i0U)3!&KIK5MJ|`*7G)Aq2ZPCY`OvO{Fe2y-l2)5vSq_b=s0mb{)rnDX%U(Ga zTtRGHOoP*Aj{zYF2nwpNuWbwi5urC4YHKQM12_>iTAi*|@qsFe8Z1eoH82QXkJ}qY ziHgYRP-|0@Es9bgJSwWLrm{8ypa_i!sjjN14rUO5o18ED4~gaQcsvHZLGL95-0St3 z3nE*he(>17^icJkZoylaZ;Y37bRdq;6a9|ZcQCHm%6BQFx4bEVfJzlRb!C;1}(P~N=6T+@!!)!1bvWTcB`8h?gA>m<{Kq%^~8#;9Al9|U8H@t3N z&sWBW)^IMD5<)xQxvWuM^~;&`5E5wQ7jr6+SKI(bC_M7-j5_!fj<`?( ztLB>V%`;{^IYEzyZ_(j{hfLVIC;)xiNN{`k?_9c!15TN8yOYzsN3VE>3?F!B5O~wm?obfm*FTSJZ%eeBN}zRY!U&i`D6LCxI(SNlh$ksBMS` zC_=)5%gRfu!a32vY&KR@)H+Qn1dUd&t5IZ7*{1}FqG&f7A$z^P00NHSK#SAm_L$%g zjc~i&Zv7vDy_*^j4dM@`v0I&Cp`k{nNI7@AJYkXss$QqBmwifbQzLXHv$2{JNvJ5V z4GIXdR1+$op~e~>5gSoS34^uKo)n)H(@43GdEB1Fpm6wfI$g1%A}0H)Y&IGVIYiXa z3+FG!g@goU5kgT`TG6p>+l(AAFPwH)?+FvaYYCUD6-Di`x&(TiPQRB(W<#R;e5aM` zPwo8h+jG$CL2I1?ad^4f;*AohKQXu{{JyM(%Li6r1=s2Xe7)`*w(h%v0Fw@k@#}r? zpZxrXgTVcMa*tQR_nS{c5P$~P^)-qXP5&55+gR?5;svcA;zEF1a;Tbv+3oh>8q5fBw|@bSfHPFzK_- zo|7$%3!z%Qu0X2?bGbdOD3`s-=^>3)n=_e$Av!jyqPn^4$i5Lxr zn)+Jk6`2Lgb)gs z(-WlAL(%HA4PK8o)aim`FdJ$Z5ryi?x`3ddz%oz~6(tohQPB~l08m%ckerg5R71GT z9WHlrcr;uZtyJ!l)%eOX=#6IMRR{+0^7)+D;LzZU-+CHGqZg+o8Eix zp(G(d2)%a%q$z@e1x4%~up^2EK~PXSN(bpZgpvyB3F*C;O}6jMob&r5;Ptj|xmU0H z{rT+YkdS0&XJ?*$&-0dZUd%ylWnElIbZ8L}9t^$ugh|sqnh>$I+jya0V>hsJrSdYC zYLq|hxEb!`=5!h{98^{{OgMgW+2!3wZ}h&qITZm=-o~;_YcHZbW&kTs387RJMG6+4 zg5CnO&49r~8H^bQ>8;RI(`IgFS@sv3E=3BUR?6Vwt#UY*Rta(i18w@hPNLoa-gIyF zFj**=tbHW^TQcj6go+G{Dr$R~Z;#!k&W`pM*I%~Si@=6s1_~Y>tLiWsjmcCn(fA?p z#Vt+k0{~(0aCgbMeJdvfb4o_7QWjK{S=6G4JY{lOg_8?dO-55FP^%am?%USgp$h^; z!PCdBzOJ^(2M`MmE>6`A^$iXLOBl6ARc|o@XR`^8GP$fnE(5_}GCMPvPzOh?QLi^T z{KDM(H6%yh;Vc%5BcXs4N@W`_GP3qoz1q%R(?o!UhNe~*S5LQk0D!jkE*~Es_jU{d z81zOzcXu#y9NTWS@;)X5uqutZgkYJpD8I_x*~PgKFiWc{8zQ5kLh^ywK&R`CpETaP z0a06r#mW!VcLJ+WtFI!7Mzd>6dZe3&>sf?3sH?1NHuwmylxLw>10%D*|-&gDRb7C|1XAkBt9lrqjzG4K>Wu(M} zID|2De(?3>hF-lH4I0w#*pG+Lj%H+9My=7D`R=mKQ4rY}mVx4_V-y{|z4{aoj5K~| zd|`caTR#8{9$xNQ`MITjz$&mxqbjbi1*O$Se5EpJt-B8x4F*#XqC5!*^lR&A@AL-* z;O^<%&{WsrK@kgDM~AwWCY?s445v`aTg?_=O-8eaL@MdgYQduKHMso5nO{f67 zj}M>A8|8=|Vk%ED;EN<0f&|87tC+z)3KY#syO3gM3 z%{`O~EQ=WgEoW{utja6vl&O_W8f8+TbSbrq=8+Bpl}z8+dy)BDD*lO<^W3;R1m+?XM&KKyats~gksj$^mYDiy$# z0Zgp7JyN8VK}~ft=@^$BX>fP3v+_Je|DAsRpFJ>804iZwNJ)xl?KDH>-3Fn^CL(Gg z3~KJh&}jrP+CZ@IR5aTt=q(gPX&vk?r>BFK=Sj3gg3J34RCRTC z_YOg#nI;U1%d0MH?2ix&9-gkbcZ;h%fRRb;?9|0wI*^)7=3ohnJKbDldR zm_mDNx4)N{osMI{r0+EaFdPuIovKQpjJmqE+riDd4%$7N zF58@JJPg5Lt9ks1u(GD+_L)F<6crnJ;o`Z}!APP;P7d~0i}D0Jt4)ZON>Eko08K|{ zM|TpUR)~%bDK06g><5UYUfynZN(*W|D99vQ2W_E10h^5G0I7uO^7K>ax;uMtgWfkIl6hc6cQw%r3DqCA>n~V00h-HwDpUP^KD>|VASgj@c;mJT6Knq<(jn1 z<$+$l?$-gcP+C$oa>Ur7*;Fv{I-PD(WE2==a>)%~Rc@O<&5sTU@Y)ZEfs(@V$B(TY zb_ImqYJqa&SMIL&wRhHTVk8)MHcOA$B5<41 zOWL&#O4;mQlYoD`l<(+d2a@5DIE=*v-zX!sRZU{&_>>5}ucxEg$`gd}zjwU|0I)1W zr;Y31LMfmt`Ft*m5X!F=+d7O)Q+czwa?MxQaaL~YuI^6ZyQ7yXZM%LfMn&aaFQ30v zGxMnrE()hp3%j$AEdFH0qX$=l)WK0MXTOgR28-TkdW_h5Een=R-*^1LnfX|zWgQ%} zKcuFD)@mbz8Dy)U`*6_R#)jspKm`;L9em~L<@5w#HQ?avkXCV5(3mYkq=Z8?5wT8f zZ7m%`5am%+bZ}`|X>}CE9QE{Z%PY*S_Y^=Pw|CSQTg|}s_8NkvQrzw3t=6@+b_Qci zARx%Mp|-lwx6j}D_YJ|X>=dFtQG@_=-3DJN2clY~Y_QnCp`l){b#`&6Ai&Y$qN?Dq z@PHBo6g4!q#Y9JYHnLbW>ALj;FauO;ug(@guF1Yt@%;^v%^nuOZP`XR+b&uO4jnc5-pviY00` z_5Af$uYWmZAF*{;3Lu|7^7(Uvd;@$|Qc9qw+qm)QyCZ8QEPV}2)pi|bhPhT+M<42YseH2R_T@-T3q2M4e>vV=C)TX!2m^tpi;e)5= zV2PG>aB?_wtevenO?6LPn%u6Z(mh+<+xQ&TVB9zagD5JSX$KRArOh&POGl6+JMpN48c+k8N9qy zy0(_Cz+ZuPeb@4ri~;@>3q$}GEAQCcV$`^LIG0i2Xjy5kUvOAJ2}Xb#>RO^g!#$cg zMlg4G_QU}I*xPIF2q4vFWmWt62YBB^n1$kk@*zWp_0Pq`EOfMYjR^{6d(~=LhKLmI z2M?A+g@pJWMhpuj`K1dUUzwPOiLudSBje7VGda7sI)2AU)!gOtHuIxz z?Ch008@xR|o!?*>2~&Ny_KUPHCZ%%7x{Xmd1xGK~yS;ed{LiRWcZ^fIBt*>Jy|et_ z;?G;#+SVL7dne3Z1M+dPOtgeW_*OYIl-BdjLlXkb{@zYTD^F10y2Ahc^&$k2QZ6O4 zCk<#dn1Ea@Ao;x}$`1@euF(M!pN!mgkDUDK{id{MmJV37|8+0lpr_RmYjN}GM<0uS zWdAE_d$$?H+Ic-^*SyVNuG_bsmDxKemE6W9V>Deodh@e{HwxpX4F7c9yB8;6sn$)Y zl>C@5MAFmQse71;2HWHrV-8wkWv_Hw1^-bu>4%4P=mjBEo~p%ggFQDPpLPpJzc$ zWvjCwGIFiGT@@vO^t}eJzHYV%{o>sFH7qm$B7`z*X;H0zV6b;NLI_n?HN-?kdp1b0 zXzA+gjb>ON4i5JDJms```SsrZL0%bv7$`2NN=zISRe-2jY;EftVh|gq*xy5MVM;GOR#?{4fFXl9oACF&N@Y;*x_W|GAZZOh^ zzCWgOadC0{h~bo6>c#6Ho&R|35lju2Kx!W!IdAuixx44BB<8Mi;It3LEc*V@!u<>8 zik8kCBy)Tya{j)pO{bTw%`a(uHLs*o8ST#n#`qv7gN5?fOT;1x3*z{c$aVk(f2mLS z-&toXParYQzcVbrj^tI_DsETNR+WTuBV)NpO2DbSx_xKD=1V z-0?EyO~R;=LpHAe;QDCbH0~<7|- zfS~B;;JYQoHDMHS)XUqwq@tqT#cCz?sIPt({o)q-mkrz9AAP*8u4#x2kMM2aSkc9p4Bg#2)8cLu zuujg}uaQ)vJbC=mt5-fB_bn28^98ILH|p85^JmPP^d#kt2BR6hJL9d?*vKfq2Pto{ zSZwUZq(_g8T()q)NYUC;i=>XrViz6Qmc3*4$IedLmv~-u9}&%k$QZJslb7H^t&K|H`EhNPjk&442IS+4ph_Ui;9~0hUad%aX7YNUFcUcc$EMe{KWlu)L_!*j84ch@B@DKD_@z7@lkrpd{ zk_x>YFRgyy?Wb3qT1q8aUxgh1_~8Qgipq+{RaCT+l+j6>*T0)Nk}ygyjY@K9*f6`c z=9Z4xAew}cqY}@aJ$+*=Fe-_=r}OFhT0z}wv<~DLzCAg`ucD%&ZUh1f5mCWeSs4Wp zB9>!!H|Ol$PLTKL&0%smZu9k5cQrJ$#b88GWNh%=yQNj31hEJZ{37AbzYE*_N=_j{ zh(dQ)Z=i$&(b37S)FLq2lENm>pitiozzh@?SI6~>3oXUeN}HP7lRdp9y;`mEwndP* zU%F5g8X6gJ8n6UzXBSN!Hzg*A1xsg7uVqBH4y5kxE{7?rk{vyCb>{5ZV-JI9GPif= z7h6TZE-u<{kwl~U^y43&TmSj^ZxOGnGxK=%f{*VE>Ng7wL`z&!&PH(bW%MJ zj8sxy)w<`^*ORL3wUT9smB{oKdTig(ydlFv>=Q&=PYy_(mPX9`;qsR47jLPQ%q1Tu zh>h`M9C|I3zglD~LKwv9&q^oN{YPQG?LsphS|v zp-iko&KP=n`OW^*cW%XUmk&hV+~VrU48n-vYnpUdvDD5(%343j-7#n1mW`+OA*t3) zCMBQ$`1a7$JJ}^)AyzJNbJ4!jSOc=IZu9e0Fqo#y9J6lROPA(RRvoI4GiR31bFa92 zx8@m22_y`P`*!o@oFM{Icx%*>v_XT_tql#Wvw&K}VZ#SrIDa;MB*iL;r?>mrx*D6N zyVo*M%89Lmhx$}hl+~snL?J9P@OD;CaVP;9BQOWTZ zEYUh``r^>G)Xyh;D`6}|EB_~3_)cFw-b1ao&@kh|SrgFIoy zV1@knu^Zz@PfR=w#LPD|bT0JpkQyBwl|NEWt=+Zr(%eN4P27S+V^4dhX=zQRS?%rZ z_6g>c?D;dd-aWcz@M#%qJ4ZSDu*Bsj)?|D$?M*E9-lmM*m_f^rzkU1LS&t*3Cml)c zhsHf}bZ_y#dG9ME{Og#LOLazia^;#MGw*&sX98He-%zSG-X~KlAFr(KF+~S({z)N> zhe<#}Y5`x&+d!B!GO~@8$Pi+r7$F8h-vS|E{Cl6{k>yVsVL*S?GhPlpbT{bbwL+e55W9wC>Ad9&w+6y)8hcnkm_ZeZBK z!#~{ZZzED~twxd)KTz9TUEMT=AyzVYXxz25)Z9T7%Q+uk_p2QpqOz;Y6sMFyTYQ3N zby0Ek5R53q#)jW1%&!a-f#J|ETr&Ds78-tGQT~?!2mytLhW36MJFqx8+Z7uv$R#JM z(Jw4K@CHRJsz7jSCDk%Jdr-0?y3eioVXd8AAp z_tK?@pL%k}iy-JbjAr!Y)YUh~O@DCQBSbLt8ZFGLqhGu@=i06ZCIa7^MOp2G!E?Uf zpYhG~4>WS}C<5RtRJkW&!J$n>`)0ov7T^$TGMO{?pUREK0Mg`eCPF1cTwXnDE3D;f z`-QvF=up=zv)NpOF{YSPAV#WTSeYHmNbOigs$sE2fic5@*=lPF^3~=Ji}$xxw}|Z* z@<}s#{8$-n79`$wcCy^?D0O>PRpSIiZ6s+(^o<*7c>_fx<2=1x(_344RY#X0 zMlM4g5mAm!rKPp;_i8tO@pf+a$FFFiF+zw^lUa1TbEnBKG}JE(5CeI+6@!Kh3n`Ma zf}y3gXE1LA#?#X|RRp>G;@O;`Lx#m&!qi5psvD>Gi`BF`ImoZtsKRyM&YTfr$0zIo zViu~ZTbKKIYkFN>)IU&Gqy6&B^H09<#;ETYYN#`s@zfVKAB=hU*u`U;Cmjd~ za2`qoQ!PlHpA3EQaCWE8EN?hAH?gX|YiU_ccaNVd6A&MSTrE5m(@JTf$x4{#A5G5x zVD9{&_=H$jt-UgqLHuk6tP2=qna>c*e63PCFe=*5(=)=&_w^@7XNrW#ZJ7>p z8^?|7Ken}}Tb*B6dkkTz%E6v{eZy-5GR|K}-+?%#+Sy6=(UVVx6zAqwt_Mbn0t3C? zy<4i+wzlh^1kq%gHGAy(m)54wuz@mMDI@6*Es4xdPcL2xn1$H>A>W-l)fA*RLw|)t zsCZy*NY0&{(wPWjh>i_Db@ftBnAyg9+uO;D!b3G}Wfcu$G4M1oDKb4hy*O3`8RPBi zp55LisyaJ*BM>40ziO{<^vlKp|7K7?7z*+#2PO^d;x{pE}p%8yyw64lo1x`8DrMd%&mC#Yy=kuu9F56KP-Vd^7Qq zR>f{ctlH_+v0J;}e=F&RtE1v&#Bf}5t6}GoS1yk~wR8G-B=lxtiNnZ|4;F80*NQD%!cdEtC@Ba2~VV+Xx^C10$Qun3QcLZK=t zcJ*-Q-aqx>@O6n#oOwMvr=}81?I(dve>Ei~y5EEI$6pDaeeeQdwU1E2*f4Hvzp-6C zMs;px`6mRo3 z>g87d9=({TkdT_mQ$ud&WS34wn1Se+;InBr>Vr)d1$AITtrCL~5= zW@Q#fi@z4WMPQKIW?spR69AGB8=MSjhD~7ePzR_&!yO$Mi@=(=qJv+e|^*V zHBw|eM>+eb1&{u?^U{Vp`TySJzYlpEqz>V=A9E?0|0wt$z^My7ZbTMJ>qqGjL zpV&C(@6AMcTW{ftx_L!mk408sWgU;G?Ho77MS4u96wH?k`GQ^x@Uomp`j za~elEYu%B)W%3J2kDvZTKz0+bs4v;OecH0oFI--3;bqeqVyOD~ql<;eB;>{38Qjyag-e%Im)dzKd)vb0D&QcW4_$Ct9(GO2?i@9eEHmYp_!>y za~A@ZqL2{pA9J$1y}EVQ1O+EHj~^eDo1J-g0zwSL!~|W+%53r1o2a+Fy}UdyNYh?k z-Y^6M{EA%b8^5Mg2=^RWViS?-dRj?b{NR`y7zwnbw02BHlwFIHgFN44W4%tDuIxV` zKH@Y23VC^z4<-%rtg=%vxn@D;vv+Udu<;WH?M2keS66o|ba0lMeY_m@QAVXaaNz2y zwQnXKle4xwGm-ay{?%)bJpbB+wG1`Y8!YUsDR11I_xy8HRwBXJWUz4$je7plGv~HV zd5j@FnF7*`9{AYFJ=Z=Pz0t{@dkHX0n%d2K20wjqRrWWN7i-nzSqi}DEs`xkbB~Z)J-!U~tDX~7zFia!JoL6i=ls%kbKpYvv#n~&7q_PEd=T`9*kwK1@$;n<{_I5VR zh?A+fUaZO}qfII)Vn@X=(e@fZyN}Q+~-h3~6SZk+s4HYexlo10~?%8uUvZc-R9Eeua#L0tR z`Q+1rq1}2qi>0Qvr=A(JdHqMJ5A&e%)vDRcGavLTxSex%86XB?<3e^{z1-@r>$MG* zvbMG<)8ejQxtueXVmS&9^*&utsB`b=HV;(DXzR!^!FieK#bXF!Au2j3HTO=7uijww zMu^g1Q*HqIRh`104}jw0+To!gnpQ`xyv$(2UTIhB!Ux2Mp928M%`KlcW{g*{oth~& z*;v1mr%Mw?kBk2ffglzX);tm#rfKtVQ=br!(qYHfR~~!%rJ;MJ*j(LXg3%v+S~z0L z)Iskc(PC+6)~`srT<1S@P{b>g2z*V0?#){nbzaFsqE=ARW^V1YzV^^tx27H5K4lI= zO&L70n>1kA*?s#rjNBFI@4NsAu^27vJN+L!b>OXcMtz?=EG!<0##_K?$7f`e6~{k* z{=Es)lTPM+Hzmx?5gsb5>q<|{ZczHTFm8iGvA2y-aM7U0%vB9?RcV!n+~5+8)z!+p4bjDikTL0LzIy^3G; z(PvZqcYihZ^B14G_~@2RKbDG&{UeClDt-OzCuV##?ybZp&#i0ixYqvI! ze|gZ8=RPsmBu^t^sar6A#H@mXO3%{r)-M1sbddpj&)8B(i=9SaQklitp^r8oU(^Q=I+)`%d{(Jn*tLCg?$D9ke^@sl7E0> zcVK|iXB5k%S5gZym{x!KXnOt8 zQ@85&|8TldApAvyAh4ufEG(`Qd$bCM8x_sP0s)DOi*{A6nxhuM(}At?ud(qHf-72}(?iJcaonO0tb>ac7lAI-s%J-cQ z&wu#+`$NCgD#gQ;QF(6u>ddaEo{CHJa<^NJFoUY=yS`ZYR>qW*-%Ok>f!@oMRmbkz zbLmn`W0%{x?Gwhx8S{6*D7@;MjYr}iKfitav_a=SSU0i1k~6=J8Ma+yv`?jHR^>-6 z`timqADszoYBv^pI59rSVT{+^7OGDx7Rxyd;&b!kte-wVocrR1k&;iJA0heR>14@* z2_b^P2>P8@ZMStM(Kb91{JfnR|CUZm?%FReN#Y(pd2-(mH>+)+S&cA~OORpntZ4~x z8#j*LnDq3e*K>1fZUU?B&l3IlXP=%pEFj3EVfxZD7kE%jVnjpn;sv9pmsd2pW!x^` z3ye}@rx15uTA#9M&J*W0+dwr?hD}+!zZkuG;R{!uw+f0R8DlEiuwm5tmtIO;X%Q9C z8X2GY(klsvc6@j7IRPo`y*)ICVxk>da&v2zAs~=6ICA@i^9>Q5I)1QHDmKiS9e4Tk zxvV)NmC8cHyiWJ(MOkgVZn#V$>c>xvxqjtJUJ3!0LkRt<&fu?Eob-3EGuh%P-8DK2L-quAV@Ah zcKqgw<;$WjX%sZaEGmLOUVnY*%de$;DaGb;gH1Yg%4oNJ zb0B9X0^k#Zn8~(1{)4hv zBO_U_K_P6EAcFAa-di(lME$*pWRnoa#Y&m&l~vDAirBJg)SAm_CDWEHIa6R3R4)KR z9j^BHne1=I&pmJ`ea!i@xv7LvM{>w`_PcH4C#R_d!7W2YcH)4mw>X7b7_ zZ<_@9SeBYg#wveIb?gbBxegDfZ^2c`T z(MTz_G(Gj?&|TZM-B@HL3J(`YS?YvIK83e#7B2zBqJD7!2l5JZ9&MfGK{D3XJ!x|6 zwKHe3Ch$}$3kmVOU@(BRrmj7?ui6`-{{+|m6O!ZppcFB1<7V;1VaZ;lS`|~>*+U{P zUaXB7J-Yus1O#s9RxJn()wFth*qyUcxpv>4v_~FUI^dW>%2)K5Q2gdC`QsjXX!J)6 zn0uO9%nQH&p)7v-%%s(bT8#Crrsey;FC92%=8#pGSoEDbSn|s1%sF3e8MjQvTGA~< zIsAbqFKqkpy`=rY0S-@70KT=0ypz20>e}lYN4^!{YquPL==EmFtNkB4f5zR-vE|&h z@yQxF{|;b8Eu!*;?OV?jFMjIs+o2)e*>|>07&~HAWR$Btyj0z6xOg?I&N?uNiJLiy zo8zd$u1b!ARw{yq6Og?`Kw7DQl&pZ%tVrb?mc;lo@vlvntnzndJTBa-u{E}vbM4jQ zJMjYp67sf>A3S_y%#m$hr#`lL$(aJZMfMQ|>?UJP+g^EPeBy)&LoUWYeqnc0hp-6{ zmOE+DJ6T)CE`IryTk}e)I(7mhRl7LikIsKFa&_{`EARH0xrbP=)I7R$Z7#lEdli{#u*&4=;l5+ zeA~8fZcjB>xgf0?6-=KRoO$VD&O-!An11~N4mQ=BwbgaHQ8G^GoiH&b?ZUaNQ37FQ z2m!fgLVtCh{iiJSU#xVODIz<&-7g^_^b{ZjS=r^YVxk=z+*}mtR#E1(Z-36T`SX(w zNLgD~ONV95#mjXOb&aE^S?AGMOd);{rYhgi%gT&8v=%cE-qX_YD9HlYP7H zU0xKK8>GBH)r%vd9Yfxg)LPV{ty;kY+ zfsbE0G%O`5d+yAn(JYu!38RWnyHT1p^V!rl7CxA~=b6XH_g6`*A7hM7Hc&r#^VZ$l z{U1Aj;Z9EF#PELpr&qo_amo02SCM1Dp4f<>l?c>PNkJh2;1~oL_jT2haR@YWKrTuV z7*QlF2G&Upfr-)12mE$T9I{|e^6skYrhWsKUp#pH@Xa!lsC<(GvX>&$&Pfvn4E%iE zh_&0lNuT%a&a+uO(oDw0RFX6>c=Fq?580XY^wmwx-Qs%y)aK`H_rg~n4*h1tQMowxcP6*9B zcOiQg0Vx|2;&HLhI(H83>+SWQvCaVhISqYpV2TifQm+-w8aB+U#Myx>(wkZDqsNL; zrq3As1BZE2b#2$wsw$mxVtnAY2r+Q;df5{(v5t)aKH5Vf$TSBI-g?h0yu9Y|r(!O+J4^RdEY}=Ak-Z@((PS92r6rP*RK08XZjZ(jan{e&FMT+ z$9%r!;vqY=QvcY}G1Fw&d`m#8!8>CC_1i4im3j@#WTYncp9} z^}s{(lfQZY-Ejk4?CCQsCfOoz2?m?uqjM*37phxZGCf_T{isc(ZW_vRY~QY?BJJC4 z>3+v5k=*N61pxL6z)ng^Z8i!~UdmP6YiZX%-+ZpD+rVuVK{keEaG6%guYT;&@v-l| zJN&KOf`$Q)KXX2<%f!Bm5aygz!nz~hjGZ8egj@0A#j|EXF^i$*su3xXvu4d4aCQFU zm-ksj2eo!s<5g19Hho(3)ssJ_PZbFxM}5tN(0__!zfwK)KN|=mL`aW82sm@L zYT%eL0}eBY7m7-or^}>_5E1HjTmY%$+=V+!AD9`K;p!ksGmCPsEn9Cce`-bievLxR zH`=5T@2|W0#A~k%`AR9Z7Mg6beyiWQ{_@8k4*9}f1?Oy_^4+ri@|TY+j=mZa=+H;% zy56Q7o~qZnAup^z_xQhL4QB z_QG>xQ`9o+W&kAZGD_atxb@ua_ddM-a%5~^M(Wq2C(W4AKVGo)zC0@4t9axfH#f79 zh^}ft_uH%8->~uL9jl}q{Io5uNC1>lz$%siCjtA1r^XEg1ld|S|6yW+Z_1HvV^hYD zA8`1{(OU~%c=1AdOQ*1v0OKcT%?IX88To5W#kHH2fTaRdxJN<`0CXUOoB3A&RUAz zSv&Hz4?oCS(4m7V95S~qT{`lEFE-~*?$FUO5^QaGWXZ5Ew|;$RhR(?L*T}?*WlIMf z+4sY(1r{Q6^l(+C4Ib=PaqVX5JOBm;CIlV5dspY))S^$sNTkrWAKHJ6V<`HOv;Slp z?OzL&QeZ$T!QHE#9kuqr!P}E7YK&vt9huB$Ru2B^qmQn=qBBBhc%XLAaM-Z0?9E#*f5}s+o4Yf!`^`57ef`oasp}0E z=^zDX&0PO}$}^j`7K|+^YkwUOD|K;@Y~BCipidXQp0TW{&HNl-7PKn7^W*mif3W4- z%6_?db!$be4k5N4lar%rzTQVa zT;TpGW?+mO2}H&r=02hCw*Rp6_1y(CXW>8=Jac0*TfQcwM`{pYwZP zy%uxg;ny-BYi`rOiWoU)mE?!7HVl2|_4jh8S5|ke0fbo(SNYdl-c9`Kkr!^f)v3ob zB-m2@^wT4rI)An>si3&yO@xSf^4RbfC`IVhh2oDXrR@08k*{F}k<(`jKR^iR1JmPQ zYi;dzO1)Y2l7yq(E1wzp#))HjV@oP~XE@tK(W+I+Uw-oOwN+g`dl<}-ei+s zyi)oG0&1N#v;T|jZ3c(b>*Z?@qP8(3!e5ceCDv1?^F9$NRt5)XKYeCJ?9o?Wzqa0B zlP1a;OW|XWq&#;guiEc=di7_($Q5ciIr;ADA#d*4SJ^KoukHiNs9jlVD2X5F|K^f~ zzJ;%?$y(CZ-n$Y@?WMr?G`hNKHZ6T{z;TfSx_fudHHR&F)I`5fC%d!IXL5Ug9GgvklgpZ24}2*6;W)QVX2V_H+^2RrXL zI=Fa&U^4+CmP$tl^$(Lq#~e-=DCyboRne%Ln)YWzMiohU18CKfoy(RF{;ayppgno^ z_S+UwmP~nrH88;G?G3NR9DVH7jAz?At;>KC2n}+0d)3o1r(S&h`un|Rc9aC0Yo1s( zVnt?FRcLxv?R%6WS^sGFcLxp(D*A4B+GjR_b&UwJ`+Dk>=*ypNyz;3y>pj0XvQu8t|!;v$pHd(X6*H0={YPpd>=*>a&FfyaBx%_qrzPeQ6%BcoX>w|P`q13u%E+0ilmBDCv#sO zHrTT?G{9je1*!b<)zY^dv~uIf;i0c%pw^1&-c?_GnK$Eu_lCWwmf6xRL>0AV`_*l} zK8~$3CdWR^A#0t%DxLhot5<(;by0P#d}72bC2P4PfFk@>M$MrY-pF|Q;fLaXSh!&D zC_B0BTSBR8TQ`3Hz0WS)`f6+XGyVDp+&Z*n#Pr#da=K9PLz!0z0W?!_Wk; z@jJGT7&CRsz(c1mRL`-;l&ER#rci*0S=y>KCD^(^- zusMI)gqYbAM#ZGfdphmwb{$@ZsbCm1&}+r~c`+BCes=} z;CSDcM~?C@`Sgn$ZyBtTQNSB){(cVczxR6Vp*0^BO|Gcyc#d**E@0DjdwSY`x@tx2 z;oJFU`%`BM9_i^Z&H;&on%a6n!qO~PC-o^iU!Q}kr#e2NR>KOJAczVnVnhNUdhPm^C%_4PGG3(I4|Z;i59zKY z5BL9J-G;PvMw>KBs}!@|cs1#r&pyvt+R$d3rr`Lx=T{DUZU4diDdjcBxhk1Z`_gmC zZ|~fjJENk;G}%#2%bt67$fsXyN`Jhq(;AC`|4pnj004?vdhs_RGynh)f+RT5&+hz# zvtxez?91z~8Z6i&I>i2mDU-u5f3`90Z8OifMh4mKnl&Tx{3jdJ-ZNP_UpFV_>n zH^2X3`m0?AHc8HzE8cuQdF7$wcZc37ZhZj&bKdTX?QgH?zx|chvsbou3Uh&2!Qane z{Rgi_fB*73`3q|6yPrd>5)=|^>a;1b?|FKt^xu7#^>~NQv=B?|7$o%6d%D|iUAiRZ zL`OSFj-SY%(9+g3hbK%lC(}SzXDd8AJH#@3l($R<0w|;i1hLNvOe^AH!Ma{h$o{%F z3WNK4wu~Prf$h(#f9lzS0HD*1L1!Y?XFg3YZ*I3nNmb531T$c2u6A^=J3MA|dcFg{FUXB0kn>|%S z?iAFoC0MD}$ZTnEy*l{K9lMK16_vKXOc5*b^-%6w{d(Mv=U=(Cw#&#RD_Bd_sue?D zIee^WY-y$LQ5K86i{>W1R#n^PnR=u0WsJc#Z}xz-jZHe2t7(^XGg&P$Y1cH;Pm52PH&A3K@< zI;Fr39pv{>c&JCscYANW&l8D#u#bG#@@3J7-dlI;^=<=~sFL!<3m;5)yS%*FJ?u6; zx1!&!^voXDbC*k>)*JbWh*JabJ)QP;io=QV!H1`ev#UFKp~t(Ruzo~Yb;ro1lLGoJ z8t)^Kf&eNR0+IB!PHyNVFyW0};6?gZ7FxmrwDnL}HC+y`&Q<)(LIZS|F|_CiJo9N5 zm@RyvgM;E~XqeZ37(ZQT=(1Ydvs-JBJzUABh+D0sNacD?mR|F1t>mp8wr!Sp0h09@OXuC)osE;-5f>p*{(`rY5BzeYqVyVorV{bp>6eeB9_OlV3eFos))?nEz2}U7gvPUD1 zzW-6i>)i$}QG(4a^Jm4s(9zLrckb%l)dDD7R8m{UV~Z2lmfmf1yOdh~rVUj7EEwt& z`uV*-Z?@;1kH08*u&zP395_2UOZBxrUXI%q%n3VJRNU*FdZT<^m(Da3NgNcE*Mo$k z*)ERiQz=P7X+8sG<IaDW>u6pdz!Ea<_)JA6H)V~CPa6+8N z#-s%Qg0H^4{=P+!xCZ&ie_Zrn^sxSRi689!zGzBWjbXZ! z6+7lmk9(&}XI7oQShAJ^#|8(ezgxB>;^+tKvR>;lGJ_=8(mG~%$U9zM_8oinXRbC0 zif|STtw{+!?+#D)F5SHC_KHp&od#3@CwuPT!wdRv>FTh`j-M`E*3~OaL#!NFs;_l* z)ovd*I`W*p$1J^ivvO2Nr+%W%D#jyD1t1teBqTr7M^pR$^5_W~8D@VTcnqdJ82shQ&AXb=P0}rtp?Vibq+jd>kuxz!-Rt zAWOBglloXve8AbHBxT$7T@^#hE7~8k5?L%z3QC!nmYn4G)$kK9tVjA ztEHA(b7sYTq&M)gA5Ry)X%^%W92i^1jR<+$&E39x?}4nCH@#t0T@>wPL{L&oY7BW)GT00+@7W;NhO^5fboQ72tSSHv{wd2vn5l27Yl>1myCz*_i#WEn)W8I8N ze(9fYz4KIC7nuyi20q^Et)sJ0K-1 zf>LrX6G;0uBPUQzwUC_-b3m8+xlG$X+EBy|^-d{f<3F)5~hi)8!Jrec6(v z4{zM6iObGyTGXeTe;X(8Z-HW$U;Dif8h`?!h{gS5-S(s;`{!)kp82A|!g~0+ahIN4 z7QO4!jki~{>L6aqT07@X?f-UrSFhd0s}(N^AW?ZZv!`BqHukG8w-nE*tkXXP2!Xe| zV(+t0M(y0WuWD35Ny{^oQOcE^_0IUwp&tkP+jV@mKW}ksyKx?{YT%H$Ix^UC%d!Q5 z*FW1|=k?4;`xoMaRq+Os2vUyP6jIKFF<1e>3IGzDKva}aAQUjiGKf$>3?f({lm!5M zUpLDnf0=;=kpdyeCgJeC2FraQ06;_l8!rIIVzDojhkF2^0|467ihI~E4p%4U)d~s) zYo%I^>|%6;>%nOgyb4d8ZHmmwZG6hib8$cguvg*p)2BpkQK_W1J>Tc7?AD_(Ky2Xa zt=h5bsi?i*d|N)DsJ#7YKr+tN9-n)DMck(Cd+tstt<*h+s7U+AxNjOU)IVp-&dk^J zW-eGQ=c}H2EMeW53zb9jin|tZjA(dZYRm?`(X2Rq{_ZNEBEH|e?mxzp{{kp(dD`!X z(D(ZlBTR-5_4~@*L)(7faKZDuh&7>s>cfi{gr4|x)19ZY%KHFga!a0gpi;2N+AR|NsOg}&v!y%5Hib5YUmy{+r!gH5~_z=D#2aq8j~FeDwPOihKO}?}^ZG zKR(T1WS%)W{1b{XJ$ADASzct-p#jQ!q|SS)O$P}QWbK+ZDdIh)l(il>Uih+ElzPiK zOXbYT(d#e;dh~SBDuYcL!Gfu~UzGdjv!-~b@7P~Hv8=l5QOal(642L&1UY^8$ije2 zXD+mdX5=(1GMOP+XU6dEd>6}uBiuz^B#1#6F#f5h<=;-NL=;8JFw9>OTO@>`fMtY; zKh<>r0$>obF@H~J1OU=|U+>=!o)E%gj6oy>7>p4B;LLLUt;OpL{S*pKfJj78aC|{z zgxjtMr+Q`{In~hrPC@Gu6EBSh!3dsi>U~QVM(oST>GZ#Lv+6mssPLB}Q*+8t|4*aB zo$9{bb7z&#z$CI@){hw$us$TnxpDj6>{smeZhR5|XmXP8 z*0@-=ifucxUNT##EBX!N&HoH2?(rMHGeQFZP)ZStSOytpPLB8#0N}`}l4p65Q-}I1 z4liC1a^lOc3zsyu@hKb@j6)N=KTqiAcK4e-xhuPjY<~{S`hGEPo2HKU&iHy~@uG%i z^GrZ2IM~6(8Iz*60{|R5U9`AUCyWCu1372TkBe~IIb*74&dqdPz_AO}(_f$MIsAcS z7gi7nu=jc*6GFghwOPo$wVsqxB$LUM3SXT>eG&%|LI7io3`R3M<&`UKHkoJT zfN1AKQzm%iU%1*8oRL$%u-6DFfG}v}V#bIe{@;d%*tP6ASTw(>*)$gzDL88AwT1Iy zzbd`k<#{Eo>N%?@_mH#Z+Bs8W*PAU?$%%7!Uos1vn}bR$d~9*Qjn{89#oWwk=p*wW zpRIARZWZ6|%6ZOcCZ34?{>Tgffa0FG`8y;u0sulO;tcB=GQUS!q& zp3<|+9tq#Seb?PtmG$PSi1H*V%<0<)XZl~*w&(7g>U!fW01O_k(&LZK58rhorz7}! zdfk&2QQ^Z7y*?z+@!N;z1f02gqch}IPQ(0O6B-17LC)EV!U7$RMke{4c_bX3b?B2E zdO`?;F~(-I#WLf;W!vr+7j`J6gd&7+R9Kk$l{a1<5gQvfkPu?S7$ZUm#TavKZS5^r zvMSrgO&m1PWB>pbKmmkm<=6;)WS{`R7>q`vcTm!&x|V`Y=#$h`BV{Ewva%LMMaK;w zgjg`fSQH6m7|eb2@d2J3VdED&3uwd<7w>@)nYU{nFqlaaU@0g#Ye`g?)Baggy)zD< zXim&4Y+7caDld-ebup1{U(cTGb>rLbE2frLbuOV;fn4p;wWSN9zDdhyjkuLv|1?jf zDmQybe|mZJmLHB(jVdVVTnGqga+3GfxCpn3?R#^cH(9^~QQN(U#_x|hsNWOmzrVBk z36xO8VwMHNjEP~N62Nf$RLQe8DpPwn!i~olM{YlHre?_P!j4A(bJ*Qka`BOQ;XAJ1 zYL2{>-Sh;Hlv+7!sv40Tv@t47)3E<=$=rq}!z{|Gk({-Z4~%i!K5>j&?uBdZVYl;I z9_%r~Acm8HzPGb+(~3~js91X+kq`j^AdE3K8qL;0!^VGF-l4R!_m1|Ug2iSsnambl zgZ^&XH`Dxmy?pPJ`raGBD^|U=d-_99#rpcV%DN0j*2SgIy%yxD6TE{22@!Qu-jTA*f7zTq#2*DVuzWMs={V#5-5h6xDvB+ZT z1%-q!jS6$#H+7t6R>tifziT&Zm-Lu%Jfb2vY1vCNCP(hH*+ll(nW86j1~wjvCLuD^ z>AMGK`CZ(#zhZh>b^SseQ ze@5otC)nSSp8p3x7=cIxkyRZ%eRtK2iQ%6*{~(wanS=Yo7`Nr z?_Wx*exj)ZjsEQGynsm~gTH)afy>K>e=J$j*(*&vbFuu>{36wng%5;(Us`Fndi8qs z!#xHz{(4U5hq=XVx&5Nt_P_je^qQ+Tx_e-!5Sau{Ozb=9yy!7DSoWElMdmqU`!t5`)y>HbIo`A z^PV$W!2504(q`GKmN? zO3=4V9q+b7t8nYFm_d0dyGt1r%q;M-BC*tCj>_xNyBA z?4vIW-_RMzP|CZxB1%a@Klh!ZM!0A1*ju`=v7IDgY7t|?o%cL2+55_#!_||@s&um{ zmVtwsW=$L!uv?+ztS8P@&TZ+ijsz?Ljaz#K@i zxm_aTj%;5U6d2^fjU)t200d)d?Vj zIqnC2&LO_9qnADT!p;L{(=(3Vx!!QQ4D^pg!AlNum<9lPxVL|m&Boh~9zS(m?i?ao zq+Z@_9R|NJFNOW^7!6?=eBarm0HDmeU67NIn6x{1&|Ej?!OzbzS#bbD804(ABG6ZR zdhAH=8)cQfZa1?UXLa_FVSpIWD#bfvQv7$jJ1Ke&ohqB#)NUCIz<{SKclnVyA^Wap zc0^pyZg|v2WEw}6SUUfK$Zb_MJ#H6Ls~;Ccr24%&fd2@n-&0uvLYVnI3jDtZr2rUF z#6qC2`t*X?{wI!{s2y2Y)-{_V8FF)AZp@n*vZty}?{eu{?P7yX=FbqlH8jw1-vg6; zE@a&94o<(*{6MdnjRq=$or2uy7wvIySdx9ojjXPa{KAfLx?Xc~mqp2~o}+<W&i-xe??9;VmLo4*!r3v{{GRYJFyfwYMH!wGs1S(HuSn&Os#*!$V*-2 zjHPW*y!-B93690zAFZBTQQJF>FiNbFSgQucxb7c5#HsM?<(Bw@(#{zsUg{521hsKGf+(;oqq%tb=8Z9;kg%I%XIPYFH2guA#`AZ|E(~8SZ2xx7{g8F8O;8S3XeJWEz1$0HEY|l{EQpfe@hnEAA8_mW!Z*^$$YmpK=d` zA_M3iN;sC0ozLy5ditwMe~q0BM{K=dr(*6z1Up21xn$)O=mKwKH9u&uO8psXpgM4dRk+vHJM@#v`Vo!X@J|2xNuGFr5h~+3d?kpOuWn+Q697k z$O{i~I5#ZOv7oI(RAuG0Cf+IO9{$QK7w`GQoh^SW4dAao^aucm&BhBxlgU_J)7bLk zsq+Qzez>Vfu=G$zpKv>m5pO>xbqaPuq6KMLb zwxioNgfc3SAVYUZfZgfQ$)4An+s)e4n@v+&I)x+v2As4sfAlb~!zzWua{OG)+>S0= ze*ggxCBN$!^FITnlweMd32(lJ2CmFsf;q)@(PsEVgk}CY`f92Xh(f5p_T;p2o)_~= z^u9NAjKRzXQNSQ$%&lJT>MIGc&Y2RW zw0>%^__&_~Gl~!}+>dftELQ6qYu6uaZffq8N-o0>*omoiUosro_QxH@OUakoQZR zJT*ayK6xTec7LY0x4ud8(kpLl>hhdkiWLFz#pSId%{GZY#Vp7f zb911N=F;e49ybg|QF`%uA6R&|dwg>@GvxbIwL{!oWN8U8&X>jvb^S0aPZyM1(lxxJizZYw!Gkr8bq^8{ zbK;lzO8p$<{Lj52kq~ZB5?220k!v%9hJ}sgtpHjLEr71o}xXP8jBQt-Q|U_SMd+72SFv0#N`&t@kQV{mO)pF%0dC z=iB15j7V~R?`nES|B?#Z=x!s9NxN-`$mTWyJ#(!4m5Z5r z=MZmhE&xOz)K~2W(3heng4x!plLmF_2@VQyEOm2IW+>!f0{}2vfbHlssLfU?5kVXRkG;=*_G@MJ&iUrZd2oy)t5O=uMMF zl$^QTIIgbQkSq{Zff2wTCF}P;0R==DPR@unb3s5$VGRoafDk$dfEEH!^oLa(e~Wt| zNKAPGfSBeEVesKIbqQ|Hvdp9bZkJLLoYvni?g=a`>lxb7Mf#W4q1kt<>t@@l(Or87 z)E8N5^;cFXaNkoAdxT~QVD94NhO^sInybTVm*VA z;Oro4xqmt=25h6$Eah11y|bgxH=_`lR3g20@#rIfIb^g6>QNqQnOebuy%H1XOT7aj z1PCDl##nPZujF?2&2x2;lix|$exU9lo4_Cp2<%m~BrH&VJ-(k;QFk}5I&-CQTz!)< zS)iN(5daLfK4%mC@f^Q@3JMgnGAh_imk%DCBeDPhfJmxnHQErvO8#)c^#2~X?{jP; zfDqfa(j$#~;Zm=<46lj0X0aaA<9p7<#i%CkCWA7zAEq z`g}0}04nlSCi8dWPy73ks>Vj_XEw7QR$g?pnOL`83j=Mv3{Zsof-g1ZpH70o06HUu z+hGVo+_ZW@B$$Ptb_qjDrBV$5NGT^G0ILT8DvAUEC`JeY#+bnvYg*gd8&^E@>S>8Y z&JuNKKdD^S>0`%d^o#J!@%NUs7nYlR4xX%^*WO|2Cju+^Nhc#fe{`+<_n?4)VoC%A zK+gjJu(M|$U=D7Gt{?E+-OH>*HBPYV$saR(_g?`)Uu>1a{qo1|UK({hyE7`MptITA zO`aJWZhvP|ifd|9tIg?rW_y&gog@VS93>oczZf4&r4ob~79m1ORG2b9G1@0ptzdXw z07j?d6?!v~w{;kud6BYKtDx*PP$z_dQc4kv0d*KeTaddX765!C9R6De{9dF1|CC_4 zxBLnL0JT`H#>~vT^cBy&d7t_d#XkVSoE!@_LzTL_{U!iKY+J_nIuj{yD{3B!L0eJTXtFE`s_ z<=o5aY||=g>ZjOgBxM0!%G*i(T{1!fIE%^3$JW(1A_NFAeZ@(FV9^fYdL=MLiNqd$D08Fd_!Wn$xnZ#*4q@WdymzR@9MEd ziHMUR3V=Zbp!e!8{_heQ000ODf;k1Ig6)UYog*v*mSuDIEdUHymMzB`O(|leUVU{P zf1HApe+zw~O4_#@y959bAZino8dVk5S7fKIADRk(iAZ1oKuL?g?dKRZb6|I8r@M_O zP87>AMclnb9QPPXML^W{cP~Y;6m(6+^;LUDeF`A^+a%4sC*R*D9e4ofn;}>yg-2+j zi#lX{h->(mP!Z$-78{i#g!^8HQgY7+`eVnb{#QU05aub*hi88lS_P|VUleHe0m>Yl zgT(k3Dqm;icJK10ZUG_0{?@1a8{odC#fVb^Z)g*2?fF*$^(}zK2=P+X+n6{kp&>Cj zu7~hIOlFYu=xxeQomFeL5Jgvy*_9B)+N^@oXrYdOIV1o8P|V0MCw4Zddd{7vAYmCy z1cVU9KnbM;5y05Wvr;q9DGgT6*(@qOY)IvSBpN41Ztu#<9i&9yfe02rh*AJ_U%LE% zLt_8{NS%jDQ%-pfK3L4pE?8y+muZ5^1l&;%G7nT9bu1n5=@M%|`49MKXFX(@u#1RIox{ zXcgVRH~;_M9{_?F2^FcWU(UL~0$5EktVQIf3zD+>3Y)rigb@Ei7$mD1HhoXD`BBsx8Mi^+H3W2`XWfXw)l`&%kJi{UrP>P_hEEMj2PehT( zh)5*>6ewab0Pc&7Kws@BfCxYd^z9)1(+xm?F#O+%tNyD%Kq(bixgE=!bqBIP4xD>m zVF2`1hXDXU>e}HVP>Q6l)@`;~dY@r9nG+Rk|7Qa5{|XQw0OoJs|9yUm6sSni-#rE0 zi&-O15V76gKNkT|1PI?t)B&aUWb*wV+&>NgFzEkS2=(7Y-$aKng9vXCCn-p*X-cR6MC753mk^pn)M5A?S` zxQ~?8luccWg!otLzm%$6pW+Q|JRr>d{~PuA6Tb}*P$CMP(vcO+-Ip^y@ESGy0AUW! z0Rn*jj*S15&;S&rtiH+^n$?JL@;=7NG!uDKr+_f?|8XeqPyA$O!$sw8wS4qy-0|YB)S|IYqJWlSwQc8t;8Q?$1pW{~?7$~J8 zBT+F#6xu0ooR#}QNc-&KNc7Lh4ELV-U*lc|X6UO;&LS(hh&e@!Xz95pKmNp@_;o-D z5g3VrrIfUb*6uNx>jLtAo<|zD z1Am6}{=_c=3Y1WRQ#!DOHy7Hhx_Q|j_!QnR%lMxh;eSWOd-5YCWi@L}S+$jwD3^#f zJrE-FC13vZ75~J)0t9_EGYA8#aKNHff5B*IUzq<%P}~1RX8i4;{&!eSG2F|}40@}3 zDP|;}V}?@-mfk;r%g z&Ou_r%dN4TT)7cTmC2%|7YGpq1c-tEkIKyd#J>hgsfZ9_Sh+nAo2itynOA1M=X4(E zy_zrd_i^U`4*y~$9ij*4ATeb%Wi!{g=G|DcemHOKeF-qv%_$sMzzm`k1OP;TYX{@c z@w*$8QV;F*GcsP_O&h48E+O+hr}HVRDGLbEe}&9&?-l+%tfm;&0tG-cado#J z!z!LdR9u23avJfj z6c7QRnBn9YOC%sz%@zdcC<5}y^|zEc0QW0A|8Xk&e~Vw1Qma?v*^z6|eXlh6U4y$w z*+l?!4#vy?%yB?P0K%JriUOqo1p3xomv?e*7WWdQdqas*IVi~ecm?|+M5he4|;o*fD3=i!zi@7e}YDvU$`qbY&@ znC0vc!vZA$l<+`_06>4S_w^_Kqd-*$6re?cPuNF40F$~F{Z&wW$cNMi1Zyl1I+Oy9rIZ9AK<)@31^q)b zF!~ce0U-cFc!Y2rFt`>7DnbZuVM?>Fy(KIE`Jbk&rfezz`6Vs#-{SuP4aO~uZ3d=9 P00000NkvXXu0mjfsFhxi literal 0 HcmV?d00001 diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..480c375328cf303958c6b40f6818edfbc24878c4 GIT binary patch literal 63317 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

{B)8C}iW29L=o)&j+oK}HfS*A#@ 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

    EX#k`40Bo+KR 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

    4WRQ= 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

    TJ8CZz^6}L2Z4uL5TFQB#K8CZbdcN` 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

    A6i~uuFq1wjl+PCq)K4W@e>9*av*U${? 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 ak4kw;qq3!>ZGH>Ms4OwdU(7Qjfei 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 \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..d701922 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "ProjectBrain", + "short_name": "ProjectBrain", + "description": "AI-native project operating system for persistent project memory, review queues, context packs, and build handoff workflows.", + "start_url": "/projects", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#1d4ed8", + "icons": [ + { + "src": "/icon-192.png?v=60", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png?v=60", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/scripts/check-architecture-budget.mjs b/scripts/check-architecture-budget.mjs new file mode 100644 index 0000000..0427130 --- /dev/null +++ b/scripts/check-architecture-budget.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import { readFile, readdir, stat } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { resolve, relative, extname, dirname } from "node:path"; +import process from "node:process"; + +const root = resolve(import.meta.dirname, ".."); +const config = JSON.parse(await readFile(resolve(root, "config/architecture-budgets.json"), "utf8")); +const normalized = (path) => relative(root, path).replaceAll("\\", "/"); +const sourceRoots = [resolve(root, "src"), resolve(root, "backend/src")]; + +const walk = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map((entry) => entry.isDirectory() ? walk(resolve(directory, entry.name)) : [resolve(directory, entry.name)])); + return nested.flat(); +}; + +const failures = []; +const sources = (await Promise.all(sourceRoots.map(walk))).flat().filter((path) => [".ts", ".tsx"].includes(extname(path))); +const sourceSet = new Set(sources); +const contentByPath = new Map(); +for (const path of sources) { + const name = normalized(path); + const content = await readFile(path, "utf8"); + contentByPath.set(path, content); + const lines = content.split(/\r?\n/).length; + const exception = config.temporaryFileExceptions[name]; + const maximum = exception?.maxLines ?? config.maximumNewSourceLines; + if (lines > maximum) failures.push(`${name}: ${lines} lines exceeds ${maximum}${exception ? " temporary ceiling" : " general ceiling"}`); +} + +const resolveImport = (from, specifier) => { + if (!specifier.startsWith(".") && !specifier.startsWith("@/")) return undefined; + const base = specifier.startsWith("@/") ? resolve(root, "src", specifier.slice(2)) : resolve(dirname(from), specifier); + return [base, `${base}.ts`, `${base}.tsx`, resolve(base, "index.ts"), resolve(base, "index.tsx")].find((candidate) => sourceSet.has(candidate)); +}; +const graph = new Map(sources.map((path) => { + const imports = [...contentByPath.get(path).matchAll(/(?:from\s+|import\s*\(\s*|import\s+)["']([^"']+)["']/g)] + .map((match) => resolveImport(path, match[1])) + .filter(Boolean); + return [path, [...new Set(imports)]]; +})); +const visiting = new Set(); +const visited = new Set(); +const stack = []; +const cycles = new Set(); +const visit = (path) => { + if (visiting.has(path)) { + const start = stack.indexOf(path); + cycles.add([...stack.slice(start), path].map(normalized).join(" -> ")); + return; + } + if (visited.has(path)) return; + visiting.add(path); stack.push(path); + for (const dependency of graph.get(path) ?? []) visit(dependency); + stack.pop(); visiting.delete(path); visited.add(path); +}; +for (const path of sources) visit(path); +for (const cycle of cycles) failures.push(`circular import: ${cycle}`); + +const hashes = new Map(); +for (const [path, content] of contentByPath) { + if (content.split(/\r?\n/).length < 20) continue; + const hash = createHash("sha256").update(content.replaceAll(/\s+/g, " ").trim()).digest("hex"); + const duplicate = hashes.get(hash); + if (duplicate) failures.push(`exact duplicate modules: ${normalized(duplicate)} and ${normalized(path)}`); + else hashes.set(hash, path); +} + +const forbiddenRoots = [resolve(root, "src/shared/components/ui"), resolve(root, "src/shared/components/layout/AppLayout.tsx")]; +for (const path of forbiddenRoots) { + try { await stat(path); failures.push(`${normalized(path)}: deprecated duplicate UI boundary still exists`); } catch (error) { if (error.code !== "ENOENT") throw error; } +} + +const distAssets = resolve(root, "dist/assets"); +try { + const assets = (await readdir(distAssets)).filter((name) => name.endsWith(".js")); + for (const asset of assets) { + const bytes = (await stat(resolve(distAssets, asset))).size; + if (/^index-/.test(asset) && bytes > config.bundle.maxEntryBytes) failures.push(`${asset}: ${bytes} bytes exceeds entry budget ${config.bundle.maxEntryBytes}`); + if (!/^vendor-|^index-|^rolldown-runtime-/.test(asset)) { + const exception = Object.entries(config.bundle.routeExceptions ?? {}).find(([prefix]) => asset.startsWith(prefix))?.[1]; + const maximum = exception?.maxBytes ?? config.bundle.maxRouteBytes; + if (bytes > maximum) failures.push(`${asset}: ${bytes} bytes exceeds route budget ${maximum}${exception ? " temporary ceiling" : ""}`); + } + } +} catch (error) { + if (error.code !== "ENOENT") throw error; + failures.push("dist/assets is missing; run pnpm build before the architecture audit"); +} + +if (failures.length) { + console.error("Architecture budget failed:\n- " + failures.join("\n- ")); + process.exit(1); +} +console.log(`Architecture budget passed for ${sources.length} source files; ${Object.keys(config.temporaryFileExceptions).length} frozen exceptions, no import cycles/exact duplicate modules and production bundle ceilings verified.`); diff --git a/scripts/check-browser-bridge-readiness.mjs b/scripts/check-browser-bridge-readiness.mjs new file mode 100644 index 0000000..b17377a --- /dev/null +++ b/scripts/check-browser-bridge-readiness.mjs @@ -0,0 +1,56 @@ +import fs from "node:fs"; + +const read = (path) => fs.existsSync(path) ? fs.readFileSync(path, "utf8") : ""; +const failures = []; + +const expectIncludes = (file, needle, message) => { + const text = read(file); + if (!text.includes(needle)) failures.push(`${file}: ${message}`); +}; + +const expectExists = (file, message) => { + if (!fs.existsSync(file)) failures.push(`${file}: ${message}`); +}; + +expectIncludes("src/shared/lib/model.ts", "BrowserBridgeSession", "BrowserBridgeSession type must exist."); +expectIncludes("src/shared/lib/model.ts", "BrowserBridgePayload", "BrowserBridgePayload type must exist."); +expectIncludes("src/shared/lib/model.ts", "BrowserBridgeStatus", "BrowserBridgeStatus type must exist."); +expectIncludes("src/shared/lib/model.ts", "BrowserBridgeTarget", "BrowserBridgeTarget type must exist."); +expectIncludes("src/shared/lib/model.ts", "BrowserBridgeTransferMode", "BrowserBridgeTransferMode type must exist."); +expectIncludes("src/shared/lib/browserBridge.ts", "createBrowserBridgePayload", "Browser bridge payload helper must exist."); +expectIncludes("src/shared/lib/browserBridge.ts", "formatBrowserBridgePrompt", "Browser bridge prompt formatter must exist."); +expectIncludes("src/shared/lib/browserBridge.ts", "parseBrowserBridgeResponse", "Browser bridge response parser must exist."); +expectIncludes("src/shared/lib/browserBridge.ts", "No API cost", "No API cost copy must be present."); +expectIncludes("src/shared/lib/browserBridge.ts", "Uses your browser session", "Browser session control copy must be present."); +expectIncludes("src/shared/contexts/ProjectContext.tsx", "createBrowserBridgeSession", "ProjectContext must create bridge sessions."); +expectIncludes("src/shared/contexts/ProjectContext.tsx", "pasteBrowserBridgeResponse", "ProjectContext must paste bridge responses."); +expectIncludes("src/shared/contexts/ProjectContext.tsx", "createExecutionFeedbackFromBridge", "ProjectContext must create feedback from bridge."); +expectIncludes("src/app/routes.tsx", "/projects/:projectId/browser-bridge", "Browser Bridge route must be registered."); +expectExists("src/features/browser-bridge/pages/BrowserBridge.tsx", "Browser Bridge page must exist."); +expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "No session pack is ready yet", "Browser Bridge must explain the empty session-pack state."); +expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "createExecutionFeedbackFromBridge(project.id, selectedSession.id, responseText)", "Browser Bridge must support one-step response save and feedback creation."); +expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "Clipboard is unavailable", "Browser Bridge must handle unavailable clipboard APIs."); +expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "readBridgePayload", "Browser Bridge must validate stored payload JSON before instruction export."); +expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "Feedback draft already exists", "Browser Bridge must route completed sessions back to feedback review."); +expectIncludes("src/features/session-packs/pages/SessionPacks.tsx", "Prepare browser bridge", "Session Packs must expose bridge preparation."); +expectIncludes("src/features/execution-feedback/pages/ExecutionFeedback.tsx", "Import from browser bridge", "Execution Feedback must import bridge responses."); +expectIncludes("src/features/build-loop/pages/BuildLoopDashboard.tsx", "Run manually in ChatGPT", "Build Loop must expose manual browser execution."); +expectIncludes("src/features/execution/pages/ExecutionCenter.tsx", "No API cost browser bridge", "Execution Center must expose bridge path."); +expectIncludes("src/features/handoffs/pages/CodexHandoffs.tsx", "Run via Browser Bridge", "Handoffs must expose browser bridge handoff path."); +expectIncludes("src/features/history/pages/ProjectHistory.tsx", "browserBridge", "History must include browser bridge entries."); +expectIncludes("src/features/audit/pages/ProjectAuditCenter.tsx", "Browser Bridge control", "Audit Center must include browser bridge checks."); +expectExists("tools/projectbrain-browser-bridge-extension/manifest.json", "Optional extension manifest must exist."); +expectIncludes("tools/projectbrain-browser-bridge-extension/content-script.js", "ProjectBrain Bridge", "Extension helper must render visible bridge UI."); +expectIncludes("tools/projectbrain-browser-bridge-extension/README.md", "does not read cookies", "Extension README must document safety boundary."); + +const pkg = read("package.json"); +expectIncludes("package.json", "audit:browser-bridge", "package.json must expose audit:browser-bridge."); +expectIncludes("package.json", "audit:session-pack-export && pnpm run audit:browser-bridge", "audit:all must include browser bridge after session pack export."); + +if (failures.length) { + console.error("Browser Bridge readiness failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("Browser Bridge readiness passed."); diff --git a/scripts/check-build-loop-readiness.mjs b/scripts/check-build-loop-readiness.mjs new file mode 100644 index 0000000..ab4cb07 --- /dev/null +++ b/scripts/check-build-loop-readiness.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/buildLoop.ts", + "src/features/build-loop/pages/BuildLoopDashboard.tsx", + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/features/projects/pages/Workspace.tsx", + "src/features/projects/pages/Projects.tsx", + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/features/audit/pages/ProjectAuditCenter.tsx", + "src/shared/lib/projectOperatingStep.ts", + "src/shared/lib/projectSearch.ts", + "src/shared/lib/routeRegistry.ts", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); + +const helper = text["src/shared/lib/buildLoop.ts"] ?? ""; +const page = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? ""; +const routes = text["src/app/routes.tsx"] ?? ""; +const navigation = text["src/shared/lib/navigation.tsx"] ?? ""; +const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const projects = text["src/features/projects/pages/Projects.tsx"] ?? ""; +const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? ""; +const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? ""; +const operatingStep = text["src/shared/lib/projectOperatingStep.ts"] ?? ""; +const projectSearch = text["src/shared/lib/projectSearch.ts"] ?? ""; +const routeRegistry = text["src/shared/lib/routeRegistry.ts"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [helper, "getBuildLoopStatus", "buildLoop helper must expose getBuildLoopStatus."], + [helper, "getCurrentBuildLoopPhase", "buildLoop helper must derive current phase."], + [helper, "getRecommendedBuildLoopAction", "buildLoop helper must recommend next action."], + [helper, "getBuildLoopRisks", "buildLoop helper must derive risks."], + [helper, "getBuildLoopValidationSummary", "buildLoop helper must summarize validation."], + [helper, "getBuildLoopSkillCoverage", "buildLoop helper must summarize skill coverage."], + [helper, "getBuildLoopProgress", "buildLoop helper must calculate progress."], + [helper, "formatBuildLoopMarkdown", "buildLoop helper must export markdown."], + [helper, "repositoryIntakes", "buildLoop helper must use repository intakes/gaps."], + [helper, "codexHandoffs", "buildLoop helper must use Codex handoffs."], + [helper, "handoffReviews", "buildLoop helper must use handoff reviews."], + [helper, "codexSessionPacks", "buildLoop helper must use session packs."], + [helper, "Create Codex session pack", "buildLoop helper must recommend creating session packs."], + [helper, "Run session and add feedback", "buildLoop helper must recommend feedback after session packs."], + [helper, "latestSessionPack", "buildLoop helper must expose latest session pack."], + [helper, "codexExecutionFeedback", "buildLoop helper must use execution feedback."], + [helper, "getActiveProjectSkills", "buildLoop helper must use Codex skills."], + [routes, "/projects/:projectId/build-loop", "Build Loop route missing."], + [routes, "BuildLoopDashboard", "Build Loop page must be imported in routes."], + [navigation, "getPrimaryPathNavigationItems", "Primary navigation must expose the workspace that owns the Build Loop entry."], + [routeRegistry, 'key: "workspace"', "Canonical route registry must expose the workspace that owns the Build Loop entry."], + [flowRail, 'id: "build-loop"', "Project Flow Rail must include Build Loop."], + [navigation, '`${base}/build-loop`', "Technical navigation must expose Build Loop."], + [workspace, "Technische details", "Workspace must progressively disclose technical execution."], + [page, "getBuildLoopStatus", "Technical Build Loop must use the shared helper."], + [projects, "resolveProjectListOperatingState", "Projects must combine daily guidance with live Auto state."], + [projects, "getProjectOperatingStep", "Projects cards must derive the central next safe action."], + [projects, "operatingState.label", "Projects must render the central operating-state label."], + [projects, "operatingState.description", "Projects must render the central operating-state explanation."], + [execution, "View build loop", "Execution Center must link to Build Loop."], + [execution, "getBuildLoopStatus", "Execution Center must use Build Loop recommended action."], + [repoBuilder, "Naar de buildloop", "Repo Builder must link back to the Build Loop from its task-first header."], + [handoffs, "View in Build Loop", "Codex Handoffs must link to Build Loop."], + [feedback, "Linked session pack", "Execution Feedback must link session packs."], + [feedback, "Return to Build Loop", "Execution Feedback must link back to Build Loop."], + [audit, "Build Loop cockpit", "Audit Center must show Build Loop coverage."], + [audit, "Codex session pack readiness", "Audit Center must show session pack coverage."], + [projectSearch, 'type: "buildLoop"', "Search index must include Build Loop result."], + [page, "Technische uitvoeringsstatus", "Build Loop page must show status summary."], + [page, "Interne fasen", "Build Loop page must show timeline."], + [page, "Geslaagde controles", "Build Loop page must show safety and validation."], + [page, "Repositorytoestand", "Build Loop page must show repo health."], + [page, "uitvoeringsprofielen", "Build Loop page must show skill coverage."], + [helper, "latestSessionPack", "Build Loop data must retain session pack readiness."], + [helper, "codexExecutionFeedback", "Build Loop data must retain execution feedback."], + [page, "Recente technische gebeurtenissen", "Build Loop page must show recent events."], + [helper, "formatBuildLoopMarkdown", "Build Loop must retain its export formatter."], + [pkg, "audit:build-loop", "package.json must expose audit:build-loop."], + [pkg, "check-build-loop-readiness.mjs", "package.json must wire Build Loop readiness script."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +const scripts = JSON.parse(pkg || "{}").scripts ?? {}; +const experienceAudit = scripts["audit:experience"] ?? ""; +const allAudit = scripts["audit:all"] ?? ""; +if (!experienceAudit.includes("audit:build-loop") || !allAudit.includes("audit:experience")) { + failures.push("audit:all must include the experience gate with Build Loop readiness."); +} + +if (failures.length) { + console.error("ProjectBrain build loop readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain build loop readiness: PASS"); diff --git a/scripts/check-build-loop-release-consolidation.mjs b/scripts/check-build-loop-release-consolidation.mjs new file mode 100644 index 0000000..cb332fb --- /dev/null +++ b/scripts/check-build-loop-release-consolidation.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/shared/lib/projectStartFlow.ts", + "src/features/projects/pages/Projects.tsx", + "src/features/projects/pages/Workspace.tsx", + "src/features/build-loop/pages/BuildLoopDashboard.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/session-packs/pages/SessionPacks.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/shared/lib/safeData.ts", + "src/shared/lib/codexSessionPackExport.ts", + "src/shared/lib/downloads.ts", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); + +const routes = text["src/app/routes.tsx"] ?? ""; +const navigation = text["src/shared/lib/navigation.tsx"] ?? ""; +const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? ""; +const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? ""; +const projects = text["src/features/projects/pages/Projects.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? ""; +const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? ""; +const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const safeData = text["src/shared/lib/safeData.ts"] ?? ""; +const exportHelper = text["src/shared/lib/codexSessionPackExport.ts"] ?? ""; +const downloads = text["src/shared/lib/downloads.ts"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [routes, "/start", "Unified Start route must be registered."], + [routes, "/projects/new", "Projects/new compatibility route must remain registered."], + [routes, "/projects/:projectId/workspace", "Workspace route missing."], + [routes, "/projects/:projectId/source-library", "Source Library route missing."], + [routes, "/projects/:projectId/skills", "Skill Library route missing."], + [routes, "/projects/:projectId/repo-builder", "Repo Builder route missing."], + [routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."], + [routes, "/projects/:projectId/session-packs", "Session Packs route missing."], + [routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."], + [routes, "/projects/:projectId/build-loop", "Build Loop route missing."], + [routes, "/projects/:projectId/execution", "Execution Center route missing."], + [routes, "/projects/:projectId/history", "History route missing."], + [routes, "/projects/:projectId/audit", "Audit Center route missing."], + [routes, "/projects/:projectId/prompts", "Prompt Builder route missing."], + [navigation, "Build Loop", "Navigation must expose Build Loop."], + [navigation, "Repo Builder", "Navigation must expose Repo Builder."], + [navigation, "Codex Handoffs", "Navigation must expose Codex Handoffs."], + [navigation, "Session Packs", "Navigation must expose Session Packs."], + [navigation, "Execution Feedback", "Navigation must expose Execution Feedback."], + [flowRail, 'id: "source-library"', "Flow Rail must include Source Library."], + [flowRail, 'id: "skills"', "Flow Rail must include Skills."], + [flowRail, 'id: "repo-builder"', "Flow Rail must include Repo Builder."], + [flowRail, 'id: "handoffs"', "Flow Rail must include Handoffs."], + [flowRail, 'id: "session-packs"', "Flow Rail must include Session Packs."], + [flowRail, 'id: "execution-feedback"', "Flow Rail must include Execution Feedback."], + [flowRail, 'id: "build-loop"', "Flow Rail must include Build Loop."], + [startFlow, "/start?path=no-idea", "Start flow must support no-idea path."], + [startFlow, "/start?path=rough-idea", "Start flow must support rough-idea path."], + [startFlow, "/start?path=source-material", "Start flow must support source-material path."], + [startFlow, "/start?path=existing-repo", "Start flow must support existing-repo path."], + [startFlow, "/projects/new?source=unified-start", "Direct project setup must pass through unified project setup route."], + [projects, "/start", "Projects page must link into unified Start."], + [projects, "getProjectNextAction", "Projects page must use shared next-action helper."], + [workspace, "Build Loop", "Workspace must expose Build Loop."], + [workspace, "Session Packs", "Workspace must expose Session Packs."], + [buildLoop, "Open linked module", "Build Loop page must expose linked module navigation."], + [buildLoop, "Open Session Packs", "Build Loop page must link to Session Packs."], + [repoBuilder, "Imported repository evidence is not live filesystem truth", "Repo Builder must label imported evidence boundary."], + [handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs must label simulated review boundary."], + [handoffs, "controlled input for a Codex build session", "Codex Handoffs must explain session pack boundary."], + [sessionPacks, "Download instructions/scripts ZIP", "Session Packs must expose clear instructions/scripts ZIP export."], + [sessionPacks, "Export session pack files", "Session Packs must keep individual-file export fallback."], + [sessionPacks, "ZIP export contains only", "Session Packs must explain ZIP contents."], + [feedback, "not live git truth or live filesystem truth", "Execution Feedback must label feedback truth boundary."], + [safeData, "repositoryIntakes: asArray(project.repositoryIntakes)", "safeData must normalize repository intakes."], + [safeData, "codexHandoffs: asArray(project.codexHandoffs)", "safeData must normalize Codex handoffs."], + [safeData, "handoffReviews: asArray(project.handoffReviews)", "safeData must normalize handoff reviews."], + [safeData, "codexExecutionFeedback: asArray(project.codexExecutionFeedback)", "safeData must normalize execution feedback."], + [safeData, "codexSessionPacks: asArray(project.codexSessionPacks)", "safeData must normalize session packs."], + [safeData, "repoStateSnapshots: asArray(project.repoStateSnapshots)", "safeData must normalize repo state snapshots."], + [safeData, "repoDeliveryPackages: asArray(project.repoDeliveryPackages)", "safeData must normalize delivery packages."], + [safeData, "repoWorkPlans: asArray(project.repoWorkPlans)", "safeData must normalize work plans."], + [safeData, "codexSkills: asArray(project.codexSkills)", "safeData must normalize project skill selections."], + [safeData, "hiddenHistoryEntryIds: asArray(project.hiddenHistoryEntryIds)", "safeData must normalize hidden history ids."], + [exportHelper, "zipExport: true", "Session pack manifest must state ZIP export availability."], + [exportHelper, "downloadCodexSessionPackZip", "Session pack helper must expose ZIP download."], + [exportHelper, "bundle-manifest.json", "Session pack bundle must include manifest."], + [downloads, "URL.revokeObjectURL", "Download helper must revoke Blob URLs."], + [pkg, "audit:release-consolidation", "package.json must expose release consolidation audit."], + [pkg, "check-build-loop-release-consolidation.mjs", "package.json must wire release consolidation audit."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (sessionPacks.includes("Download Bundle / ZIP")) { + failures.push("Session Packs must not imply that a real ZIP is produced."); +} + +const auditAll = JSON.parse(pkg || "{}")?.scripts?.["audit:all"] ?? ""; +if (!auditAll.includes("audit:release-consolidation")) { + failures.push("audit:all must include the release consolidation audit."); +} + +if (failures.length) { + console.error("ProjectBrain release consolidation readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain release consolidation readiness: PASS"); diff --git a/scripts/check-build-loop-walkthrough-readiness.mjs b/scripts/check-build-loop-walkthrough-readiness.mjs new file mode 100644 index 0000000..a1911cf --- /dev/null +++ b/scripts/check-build-loop-walkthrough-readiness.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/buildLoopWalkthrough.ts", + "src/shared/lib/buildLoop.ts", + "src/features/build-loop/pages/BuildLoopDashboard.tsx", + "src/features/projects/pages/Projects.tsx", + "src/features/projects/pages/Workspace.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/features/skills/pages/SkillLibrary.tsx", + "src/features/source-library/pages/SourceLibrary.tsx", + "src/features/operations/pages/ReviewQueue.tsx", + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/history/pages/ProjectHistory.tsx", + "src/features/audit/pages/ProjectAuditCenter.tsx", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); + +const helper = text["src/shared/lib/buildLoopWalkthrough.ts"] ?? ""; +const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? ""; +const projects = text["src/features/projects/pages/Projects.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const skills = text["src/features/skills/pages/SkillLibrary.tsx"] ?? ""; +const sourceLibrary = text["src/features/source-library/pages/SourceLibrary.tsx"] ?? ""; +const reviewQueue = text["src/features/operations/pages/ReviewQueue.tsx"] ?? ""; +const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? ""; +const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? ""; +const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const requiredSteps = [ + "start-project", + "add-sources-or-repo-intake", + "review-context", + "configure-skills", + "shape-repo", + "create-delivery-package", + "create-codex-handoff", + "review-impact", + "create-session-pack", + "execute-in-codex", + "add-execution-feedback", + "continue-next-loop", +]; + +const mustContain = [ + [helper, "getFirstTimeBuildLoopSteps", "Walkthrough helper must expose getFirstTimeBuildLoopSteps."], + [helper, "getBuildLoopEmptyState", "Walkthrough helper must expose getBuildLoopEmptyState."], + [helper, "getBuildLoopModuleExplanation", "Walkthrough helper must expose module explanations."], + [helper, "getBuildLoopStepStatus", "Walkthrough helper must expose step status."], + [helper, "getNextBeginnerAction", "Walkthrough helper must expose next beginner action."], + [helper, "formatBuildLoopWalkthroughMarkdown", "Walkthrough helper must export markdown."], + [buildLoop, "Guided walkthrough", "Build Loop page must show guided walkthrough."], + [buildLoop, "Copy walkthrough", "Build Loop page must support walkthrough export."], + [buildLoop, "getFirstTimeBuildLoopSteps", "Build Loop page must use the walkthrough helper."], + [buildLoop, "Use this when:", "Build Loop page must explain the active module."], + [workspace, "getNextBeginnerAction", "Workspace must use beginner next action."], + [projects, "Existing project memories", "Projects page must put existing project memories first."], + [projects, "Start another project through one flow", "Projects page must still route to unified Start for new projects."], + [projects, "Continue build loop", "Project cards must use harmonized build-loop CTA."], + [repoBuilder, "Repository intake", "Repo Builder must show repository intake subflow."], + [repoBuilder, "Create delivery package", "Repo Builder must show delivery-package step."], + [repoBuilder, "Continue build loop", "Repo Builder must link back to the loop."], + [handoffs, "Create handoff", "Handoffs must show create/review/run/feedback mini-flow."], + [handoffs, "Create session pack", "Handoffs must include create-session-pack action."], + [handoffs, "Run in Codex", "Handoffs must include run-in-Codex stage."], + [handoffs, "Stage:", "Handoff cards must show stage."], + [feedback, "user-provided execution evidence", "Execution Feedback must clarify evidence boundary."], + [feedback, "Linked session pack", "Execution Feedback must link session packs."], + [feedback, "Use this feedback template", "Execution Feedback must use session pack feedback templates."], + [feedback, "Create follow-up handoff", "Execution Feedback must keep follow-up handoff CTA."], + [skills, "No active skills yet", "Skill Library must explain empty active skills state."], + [sourceLibrary, "Use this when:", "Source Library must have module explanation."], + [reviewQueue, "Use this when:", "Review Queue must have module explanation."], + [execution, "Use this when:", "Execution Center must have module explanation."], + [history, "Use this when:", "History must have module explanation."], + [audit, "Use this when:", "Audit Center must have module explanation."], + [audit, "Codex session pack readiness", "Audit Center must show session pack readiness."], + [pkg, "audit:build-loop-walkthrough", "package.json must expose audit:build-loop-walkthrough."], + [pkg, "check-build-loop-walkthrough-readiness.mjs", "package.json must wire the walkthrough readiness script."], +]; + +for (const step of requiredSteps) { + if (!helper.includes(step)) failures.push(`Walkthrough helper missing step: ${step}`); +} + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (!pkg.includes("audit:build-loop-walkthrough") || !pkg.includes("audit:all")) { + failures.push("audit:all must include the build-loop walkthrough readiness check."); +} + +if (failures.length) { + console.error("ProjectBrain build loop walkthrough readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain build loop walkthrough readiness: PASS"); diff --git a/scripts/check-codex-handoff-readiness.mjs b/scripts/check-codex-handoff-readiness.mjs new file mode 100644 index 0000000..b7e7d21 --- /dev/null +++ b/scripts/check-codex-handoff-readiness.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/model.ts", + "src/shared/lib/codexHandoff.ts", + "src/shared/lib/codexSessionPack.ts", + "src/shared/lib/safeData.ts", + "src/shared/api/realClient.ts", + "src/shared/hooks/useProjectData.ts", + "src/shared/contexts/ProjectContext.tsx", + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/prompts/pages/PromptGenerator.tsx", + "src/features/history/pages/ProjectHistory.tsx", + "src/features/audit/pages/ProjectAuditCenter.tsx", + "src/features/projects/pages/Workspace.tsx", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); + +const model = text["src/shared/lib/model.ts"] ?? ""; +const helper = text["src/shared/lib/codexHandoff.ts"] ?? ""; +const sessionPackHelper = text["src/shared/lib/codexSessionPack.ts"] ?? ""; +const safeData = text["src/shared/lib/safeData.ts"] ?? ""; +const realClient = text["src/shared/api/realClient.ts"] ?? ""; +const hooks = text["src/shared/hooks/useProjectData.ts"] ?? ""; +const context = text["src/shared/contexts/ProjectContext.tsx"] ?? ""; +const routes = text["src/app/routes.tsx"] ?? ""; +const navigation = text["src/shared/lib/navigation.tsx"] ?? ""; +const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? ""; +const handoffPage = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const feedbackPage = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? ""; +const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? ""; +const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? ""; +const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [model, "export interface CodexHandoff", "CodexHandoff model missing."], + [model, "export interface CodexHandoffValidationItem", "CodexHandoffValidationItem model missing."], + [model, "export interface CodexHandoffRisk", "CodexHandoffRisk model missing."], + [model, "export interface CodexHandoffScope", "CodexHandoffScope model missing."], + [model, "export interface CodexHandoffQualityScore", "CodexHandoffQualityScore model missing."], + [model, "codexHandoffs?: CodexHandoff[]", "Project model must persist Codex handoffs."], + [model, "codexSessionPacks?: CodexSessionPack[]", "Project model must persist Codex session packs."], + [safeData, "codexHandoffs: asArray(project.codexHandoffs)", "Runtime normalization must preserve Codex handoffs."], + [realClient, "createHandoffBundle", "Real client must expose backend handoff bundle creation."], + [realClient, "createCodexTaskFromHandoff", "Real client must expose backend Codex task creation from handoff bundles."], + [hooks, "useProjectHandoffBundlesData", "Async hooks must expose backend handoff bundles."], + [hooks, "useProjectCodexTasksData", "Async hooks must expose backend Codex tasks."], + [helper, "generateCodexHandoffFromDeliveryPackage", "Handoff helper must generate from delivery packages."], + [helper, "generateCodexHandoffFromRepoGap", "Handoff helper must generate from repository gaps."], + [helper, "generateCodexHandoffFromWorkPlan", "Handoff helper must generate from work plans."], + [helper, "generateCodexHandoffFromRepoState", "Handoff helper must generate from repo state snapshots."], + [helper, "buildAllowedFiles", "Handoff helper must build allowed files."], + [helper, "buildForbiddenChanges", "Handoff helper must build forbidden changes."], + [helper, "buildValidationMatrix", "Handoff helper must build validation matrix."], + [helper, "buildRollbackPlan", "Handoff helper must build rollback plan."], + [helper, "buildDoneCriteria", "Handoff helper must build done criteria."], + [helper, "formatCodexHandoffMarkdown", "Handoff helper must format markdown."], + [helper, "scoreCodexHandoffQuality", "Handoff helper must score quality."], + [helper, "getHandoffWarnings", "Handoff helper must produce warnings."], + [helper, "promptInstructions", "Skill prompt instructions must be included."], + [helper, "validationCommands", "Skill validation commands must be included."], + [helper, "qualityChecklist", "Skill quality checklist must be included."], + [helper, "riskNotes", "Skill risk notes must be included."], + [helper, "This is derived from imported repository evidence, not live filesystem truth", "Repo-gap handoffs must label imported evidence boundary."], + [sessionPackHelper, "generateCodexSessionPack", "Session pack helper must generate packs from handoffs."], + [routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."], + [routes, "CodexHandoffs", "Codex Handoffs page must be imported in routes."], + [navigation, "Codex Handoffs", "Navigation must expose Codex Handoffs."], + [flowRail, 'id: "handoffs"', "Project Flow Rail must include Handoffs."], + [handoffPage, "Codex Handoffs", "Handoff page must exist."], + [handoffPage, "Live backend handoff path", "Handoff page must expose the live backend handoff bridge."], + [handoffPage, "Create live handoff", "Handoff page must support creating backend-persisted handoff bundles."], + [handoffPage, "Create Codex task", "Handoff page must support creating backend-persisted Codex tasks from handoff bundles."], + [handoffPage, "useProjectHandoffBundlesData", "Handoff page must read backend handoff bundles in real mode."], + [handoffPage, "quality", "Handoff page must show quality score."], + [handoffPage, "Allowed files", "Handoff page must show allowed files."], + [handoffPage, "Forbidden changes", "Handoff page must show forbidden changes."], + [handoffPage, "Validation matrix", "Handoff page must show validation matrix."], + [handoffPage, "Rollback", "Handoff page must show rollback."], + [handoffPage, "Generated prompt", "Handoff page must show generated prompt."], + [handoffPage, "Copy next Codex prompt", "Handoff page must support copying prompts."], + [handoffPage, "Generate impact review", "Handoff page must support impact review generation."], + [handoffPage, "ProjectBrain-derived impact simulation, not a live git diff", "Handoff page must distinguish simulated review from live git diff."], + [handoffPage, "Add execution feedback", "Handoff page must link to execution feedback intake."], + [handoffPage, "Create session pack", "Handoff page must create session packs."], + [handoffPage, "Open session pack", "Handoff page must open session packs."], + [handoffPage, "Copy session pack", "Handoff page must copy session packs."], + [handoffPage, "Download session pack", "Handoff page must download session packs."], + [handoffPage, "Download ZIP", "Handoff page must download session pack ZIP."], + [handoffPage, "Export session pack files", "Handoff page must export session pack files."], + [handoffPage, "controlled input for a Codex build session", "Handoff page must explain session pack purpose."], + [handoffPage, "View in Build Loop", "Handoff page must link to Build Loop."], + [feedbackPage, "Execution Feedback", "Execution feedback page must exist for post-handoff evidence intake."], + [repoBuilder, "Create Codex handoff", "Repo Builder must generate handoffs."], + [repoBuilder, "Create handoff from work plan", "Repo Builder must generate handoffs from work plans."], + [repoBuilder, "Create handoff from repo state", "Repo Builder must generate handoffs from repo state."], + [execution, "Codex Handoff Quality Engine", "Execution Center must show handoff engine."], + [execution, "Copy next Codex prompt", "Execution Center must copy next prompt."], + [execution, "Open Codex Handoff", "Execution Center must link to Handoffs."], + [promptBuilder, "Codex Handoff source", "Prompt Builder must expose handoff source."], + [promptBuilder, "Use handoff promptText", "Prompt Builder must use handoff promptText."], + [context, "generateCodexHandoff", "ProjectContext must expose generateCodexHandoff."], + [context, "generateCodexSessionPack", "ProjectContext must expose generateCodexSessionPack."], + [context, "archiveCodexHandoff", "ProjectContext must expose archiveCodexHandoff."], + [context, "codexHandoffItems", "Project search must index Codex handoffs."], + [history, 'kind: "handoff"', "Project History must include handoff records."], + [audit, "Codex handoff quality", "Audit Center must show handoff quality."], + [workspace, "codexHandoffCount", "Workspace must count Codex handoffs."], + [workspace, "Codex Handoffs", "Workspace must expose Codex Handoffs."], + [pkg, "audit:handoffs", "package.json must expose audit:handoffs."], + [pkg, "check-codex-handoff-readiness.mjs", "package.json must wire handoff readiness script."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (!pkg.includes("audit:handoffs") || !pkg.includes("audit:all")) { + failures.push("audit:all must include the Codex handoff readiness check."); +} + +if (failures.length) { + console.error("ProjectBrain Codex handoff readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain Codex handoff readiness: PASS"); diff --git a/scripts/check-database-readiness.mjs b/scripts/check-database-readiness.mjs new file mode 100644 index 0000000..6397b70 --- /dev/null +++ b/scripts/check-database-readiness.mjs @@ -0,0 +1,252 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const migrationsDir = path.join(root, "backend", "prisma", "migrations"); +const schemaPath = path.join(root, "backend", "prisma", "schema.prisma"); +const dockerComposePath = path.join(root, "docker-compose.yml"); +const backendDockerfilePath = path.join(root, "backend", "Dockerfile"); +const rootPackagePath = path.join(root, "package.json"); +const workspacePath = path.join(root, "pnpm-workspace.yaml"); +const backendEntrypointPath = path.join(root, "backend", "scripts", "docker-entrypoint.sh"); +const systemReadinessRoutePath = path.join(root, "backend", "src", "routes", "systemReadiness.ts"); +const appPath = path.join(root, "backend", "src", "app.ts"); +const initiativeRoutesPath = path.join(root, "backend", "src", "routes", "initiatives.ts"); +const initiativeRepoPath = path.join(root, "backend", "src", "repositories", "initiativesRepository.ts"); +const initiativeSmokePath = path.join(root, "backend", "scripts", "smoke-initiatives.mjs"); + +const exists = (filePath) => fs.existsSync(filePath); +const read = (filePath) => fs.readFileSync(filePath, "utf8"); +const fail = []; +const warn = []; +const requiredPnpmBuiltDeps = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"]; + +const parseWorkspaceAllowBuilds = (workspaceYaml) => { + const allowed = new Set(); + const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:"); + if (allowBuildsIndex === -1) return allowed; + const lines = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1); + for (const line of lines) { + if (/^[^\s#]/.test(line)) break; + const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/); + if (match && match[2] === "true") allowed.add(match[1].trim()); + } + return allowed; +}; + +if (!exists(schemaPath)) fail.push("Missing backend/prisma/schema.prisma."); +if (!exists(migrationsDir)) fail.push("Missing backend/prisma/migrations directory."); + +const migrationFolders = exists(migrationsDir) + ? fs.readdirSync(migrationsDir).filter((entry) => fs.statSync(path.join(migrationsDir, entry)).isDirectory()).sort() + : []; + +const migrationSql = migrationFolders.map((folder) => ({ + folder, + path: path.join(migrationsDir, folder, "migration.sql"), +})).filter((item) => exists(item.path)); + +if (migrationSql.length === 0) { + fail.push("No Prisma migration.sql files were found."); +} + +const legacyDuplicateInitMigration = "20260606200000_init_project_workspace"; +if (migrationFolders.includes(legacyDuplicateInitMigration)) { + fail.push(`${legacyDuplicateInitMigration} must not exist. It is an obsolete duplicate init migration that recreates ProjectStatus and breaks fresh Docker database startup.`); +} + +const initMigrations = migrationSql.filter((item) => item.folder.includes("init_project_workspace")); +if (initMigrations.length !== 1) { + fail.push(`Expected exactly one init_project_workspace migration, found ${initMigrations.length}: ${initMigrations.map((item) => item.folder).join(", ") || "none"}.`); +} + +const initIndex = migrationSql.findIndex((item) => item.folder.includes("init_project_workspace")); +if (initIndex < 0) { + fail.push("No init_project_workspace migration found."); +} else if (initIndex !== 0) { + fail.push(`Init migration must be first, but it is at position ${initIndex + 1}: ${migrationSql[initIndex].folder}.`); +} + +const projectTableIndex = migrationSql.findIndex((item) => read(item.path).includes('CREATE TABLE "Project"')); +const captureTableIndex = migrationSql.findIndex((item) => read(item.path).includes('CREATE TABLE "CaptureItem"')); +if (projectTableIndex < 0) fail.push('No migration creates "Project".'); +if (captureTableIndex >= 0 && projectTableIndex >= 0 && projectTableIndex > captureTableIndex) { + fail.push('Migration order is invalid: "CaptureItem" appears before "Project".'); +} + +const schema = exists(schemaPath) ? read(schemaPath) : ""; + +const duplicateFields = []; +const modelBlocks = [...schema.matchAll(/model\s+(\w+)\s+\{([\s\S]*?)\n\}/g)]; +for (const [, modelName, body] of modelBlocks) { + const seen = new Set(); + for (const rawLine of body.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("@@") || line.startsWith("//")) continue; + const fieldName = line.split(/\s+/)[0]; + if (!fieldName || fieldName.startsWith("@")) continue; + if (seen.has(fieldName)) duplicateFields.push(`${modelName}.${fieldName}`); + seen.add(fieldName); + } +} +if (duplicateFields.length > 0) fail.push(`Prisma schema contains duplicate field definitions: ${duplicateFields.join(", ")}.`); +if (!schema.includes("linux-musl-openssl-3.0.x")) fail.push("Prisma generator must include linux-musl-openssl-3.0.x binaryTarget for the Docker Alpine runtime."); + +for (const model of ["Project", "CaptureItem", "AcceptedTruthItem", "CurrentStateReport", "DocumentationItem", "BlueprintGraph", "BlueprintNode", "BlueprintNodeRun", "BrainstormSession", "Initiative"]) { + if (!schema.includes(`model ${model} `)) fail.push(`Schema is missing model ${model}.`); +} + +const migrationText = migrationSql.map((item) => read(item.path)).join("\n"); +for (const table of ["Project", "CaptureItem", "AcceptedTruthItem", "CurrentStateReport", "DocumentationItem", "BlueprintGraph", "BlueprintNode", "BlueprintNodeRun", "BrainstormSession", "Initiative"]) { + if (!migrationText.includes(`"${table}"`)) fail.push(`Migrations do not reference table ${table}.`); +} + + +if (exists(rootPackagePath)) { + const rootPackage = JSON.parse(read(rootPackagePath)); + if (rootPackage.packageManager !== "pnpm@11.5.2") { + fail.push("Root package.json must pin packageManager to pnpm@11.5.2 for reproducible Docker/Corepack builds."); + } + if (rootPackage.pnpm) { + fail.push("Root package.json must not contain a legacy pnpm field; pnpm 11 ignores it and emits Docker build warnings. Keep approvals in pnpm-workspace.yaml allowBuilds."); + } +} else { + fail.push("Missing root package.json."); +} + +if (exists(workspacePath)) { + const workspaceAllowedBuilds = parseWorkspaceAllowBuilds(read(workspacePath)); + for (const dependency of requiredPnpmBuiltDeps) { + if (!workspaceAllowedBuilds.has(dependency)) { + fail.push(`pnpm-workspace.yaml allowBuilds must set ${dependency}: true for Docker installs with pnpm 11.`); + } + } +} else { + fail.push("Missing pnpm-workspace.yaml."); +} + +if (exists(dockerComposePath)) { + const compose = read(dockerComposePath); + if (!compose.includes("projectbrain-postgres")) fail.push("docker-compose.yml does not define projectbrain-postgres."); + if (!compose.includes("PROJECTBRAIN_DATA_SOURCE")) fail.push("docker-compose.yml does not configure PROJECTBRAIN_DATA_SOURCE."); + if (!compose.includes("VITE_PROJECTBRAIN_CLIENT_MODE")) warn.push("docker-compose.yml does not pass VITE_PROJECTBRAIN_CLIENT_MODE to frontend build args."); + if (!compose.includes("PROJECTBRAIN_SEED_ON_START")) warn.push("docker-compose.yml does not expose PROJECTBRAIN_SEED_ON_START."); + if (!compose.includes("/api/system/readiness")) fail.push("docker-compose.yml does not healthcheck /api/system/readiness."); + if (!compose.includes("condition: service_healthy")) fail.push("docker-compose.yml does not wait for healthy backend before frontend startup."); +} else { + fail.push("Missing docker-compose.yml."); +} + +if (exists(backendDockerfilePath)) { + const dockerfile = read(backendDockerfilePath); + if (!dockerfile.includes("prisma:generate")) fail.push("Backend Dockerfile does not run prisma:generate during build."); + if (!dockerfile.includes("docker-entrypoint.sh")) fail.push("Backend Dockerfile does not use the database-aware docker entrypoint."); + if (!dockerfile.includes("/api/system/readiness")) fail.push("Backend Dockerfile does not expose a system readiness healthcheck."); + if (!dockerfile.includes("apk add --no-cache") || !dockerfile.includes("openssl")) fail.push("Backend Dockerfile does not install OpenSSL; Prisma migrations fail at runtime on Alpine without it."); + if (!dockerfile.includes("libc6-compat")) fail.push("Backend Dockerfile does not install libc6-compat for native engine compatibility on Alpine."); +} else { + fail.push("Missing backend/Dockerfile."); +} + +if (exists(backendEntrypointPath)) { + const entrypoint = read(backendEntrypointPath); + if (!entrypoint.includes("prisma migrate deploy")) fail.push("Backend entrypoint does not run prisma migrate deploy."); + if (!entrypoint.includes("PROJECTBRAIN_SEED_ON_START")) fail.push("Backend entrypoint does not support PROJECTBRAIN_SEED_ON_START."); + if (!entrypoint.includes("20260606200000_init_project_workspace")) fail.push("Backend entrypoint must guard against the obsolete duplicate init migration when older overlay deployments left it on disk or in _prisma_migrations."); + if (!entrypoint.includes("prisma migrate resolve --rolled-back 20260606200000_init_project_workspace")) fail.push("Backend entrypoint must resolve failed obsolete duplicate init migration records as rolled back before migrate deploy."); +} else { + fail.push("Missing backend/scripts/docker-entrypoint.sh."); +} + +if (!exists(systemReadinessRoutePath)) { + fail.push("Missing backend/src/routes/systemReadiness.ts live readiness route."); +} else { + const route = read(systemReadinessRoutePath); + if (!route.includes('/api/system/readiness')) fail.push("System readiness route does not expose /api/system/readiness."); + if (!route.includes('prisma.$queryRaw`SELECT 1`')) fail.push("System readiness route does not run a live Prisma SELECT 1 probe."); +} + +if (exists(appPath)) { + const app = read(appPath); + if (!app.includes("registerSystemReadinessRoutes")) fail.push("Fastify app does not register system readiness routes."); + if (!app.includes("registerBrainstormRoutes")) fail.push("Fastify app does not register Brainstorm CPU routes."); + if (!app.includes("registerInitiativeRoutes")) fail.push("Fastify app does not register Initiative routes."); +} else { + fail.push("Missing backend/src/app.ts."); +} + +if (!exists(initiativeRoutesPath)) { + fail.push("Missing backend/src/routes/initiatives.ts."); +} else { + const routes = read(initiativeRoutesPath); + for (const signal of [ + "/api/projects/:projectId/initiatives", + "/api/projects/:projectId/initiatives/:initiativeId", + "/api/projects/:projectId/initiatives/:initiativeId/feature-candidates", + ]) { + if (!routes.includes(signal)) fail.push(`Initiative routes are missing ${signal}.`); + } +} + +if (!exists(initiativeRepoPath)) { + fail.push("Missing backend/src/repositories/initiativesRepository.ts."); +} else { + const repo = read(initiativeRepoPath); + for (const symbol of ["createInitiativeForProject", "updateInitiativeForProject", "getInitiativeForProject", "createFeatureCandidatesForInitiative", "syncInitiativeReviewAction"]) { + if (!repo.includes(symbol)) fail.push(`Initiative repository is missing ${symbol}.`); + } +} + +if (!exists(initiativeSmokePath)) fail.push("Missing backend/scripts/smoke-initiatives.mjs."); +else { + const smoke = read(initiativeSmokePath); + if (!smoke.includes("/review-queue/${firstCandidate.reviewItemId}/accept")) fail.push("Initiative smoke does not validate review accept materialization."); + if (!smoke.includes("initiativeSync")) fail.push("Initiative smoke does not assert initiativeSync output."); +} + +const result = { + status: fail.length === 0 ? "PASS" : "FAIL", + migrationRoots: [path.relative(root, migrationsDir).replaceAll("\\", "/")], + migrations: migrationSql.map((item) => item.folder), + initMigration: initIndex >= 0 ? migrationSql[initIndex]?.folder : null, + checks: { + projectTableBeforeCapture: projectTableIndex >= 0 && (captureTableIndex < 0 || projectTableIndex < captureTableIndex), + backendEntrypoint: exists(backendEntrypointPath), + dockerCompose: exists(dockerComposePath), + schema: exists(schemaPath), + systemReadinessRoute: exists(systemReadinessRoutePath), + legacyDuplicateInitMigrationAbsent: !migrationFolders.includes("20260606200000_init_project_workspace"), + exactlyOneInitMigration: initMigrations.length === 1, + initiativeRoutes: exists(initiativeRoutesPath), + initiativeSmoke: exists(initiativeSmokePath), + duplicateSchemaFields: duplicateFields.length === 0, + prismaAlpineBinaryTarget: schema.includes("linux-musl-openssl-3.0.x"), + backendDockerfileInstallsOpenSSL: exists(backendDockerfilePath) && read(backendDockerfilePath).includes("openssl"), + pnpmDockerBuildScriptsApproved: exists(workspacePath) && (() => { + const allowedBuilds = parseWorkspaceAllowBuilds(read(workspacePath)); + return requiredPnpmBuiltDeps.every((dependency) => allowedBuilds.has(dependency)); + })(), + }, + warnings: warn, + failures: fail, +}; + +if (process.argv.includes("--json")) { + console.log(JSON.stringify(result, null, 2)); +} else { + console.log(`ProjectBrain database readiness: ${result.status}`); + console.log(`Migration roots checked: ${result.migrationRoots.join(", ")}`); + console.log(`Migrations: ${migrationSql.length}`); + console.log(`Init migration: ${result.initMigration ?? "missing"}`); + if (warn.length > 0) { + console.log("Warnings:"); + for (const item of warn) console.log(`- ${item}`); + } + if (fail.length > 0) { + console.log("Failures:"); + for (const item of fail) console.log(`- ${item}`); + } +} + +if (fail.length > 0) process.exit(1); diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs new file mode 100644 index 0000000..c20a4f8 --- /dev/null +++ b/scripts/check-doc-links.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { readFile, readdir, stat } from "node:fs/promises"; +import { dirname, resolve, relative } from "node:path"; +import process from "node:process"; + +const root = resolve(import.meta.dirname, ".."); +const docsRoot = resolve(root, "docs"); +const walk = async (directory) => (await Promise.all((await readdir(directory, { withFileTypes: true })).map((entry) => entry.isDirectory() ? walk(resolve(directory, entry.name)) : [resolve(directory, entry.name)]))).flat(); +const files = (await walk(docsRoot)).filter((path) => path.endsWith(".md") && !path.includes(`${resolve(docsRoot, "archive")}\\`) && !path.includes(`${resolve(docsRoot, "archive")}/`)); +const failures = []; +for (const file of files) { + const source = await readFile(file, "utf8"); + const links = [...source.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1].trim().replace(/^<|>$/g, "")); + for (const link of links) { + if (!link || /^(https?:|mailto:|#)/i.test(link)) continue; + const targetPart = decodeURIComponent(link.split("#")[0]); + if (!targetPart) continue; + const target = resolve(dirname(file), targetPart); + try { await stat(target); } catch { failures.push(`${relative(root, file)} -> ${link}`); } + } +} +if (failures.length) { console.error(`Broken active documentation links:\n- ${failures.join("\n- ")}`); process.exit(1); } +console.log(`Documentation link audit passed for ${files.length} active Markdown files.`); diff --git a/scripts/check-docker-build-readiness.mjs b/scripts/check-docker-build-readiness.mjs new file mode 100644 index 0000000..3c4c527 --- /dev/null +++ b/scripts/check-docker-build-readiness.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const failures = []; +const warnings = []; + +const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8"); +const exists = (relativePath) => fs.existsSync(path.join(root, relativePath)); + +const requiredBuiltDependencies = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"]; + +const parseWorkspaceAllowBuilds = (workspaceYaml) => { + const allowed = new Set(); + const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:"); + if (allowBuildsIndex === -1) return allowed; + const afterAllowBuilds = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1); + for (const line of afterAllowBuilds) { + if (/^[^\s#]/.test(line)) break; + const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/); + if (match && match[2] === "true") allowed.add(match[1].trim()); + } + return allowed; +}; + +if (!exists("package.json")) { + failures.push("Missing package.json."); +} else { + const rootPackage = JSON.parse(read("package.json")); + if (rootPackage.packageManager !== "pnpm@11.5.2") { + failures.push("package.json must pin packageManager to pnpm@11.5.2 for reproducible Docker/Corepack installs."); + } + if (rootPackage.pnpm) { + failures.push("package.json must not contain a legacy pnpm field; pnpm 11 ignores it and emits noisy warnings during Docker builds. Keep build approvals in pnpm-workspace.yaml allowBuilds."); + } +} + +if (!exists("pnpm-workspace.yaml")) { + failures.push("Missing pnpm-workspace.yaml; pnpm 11 Docker builds need workspace-level allowBuilds."); +} else { + const workspaceYaml = read("pnpm-workspace.yaml"); + const workspaceAllowedBuilds = parseWorkspaceAllowBuilds(workspaceYaml); + if (!workspaceYaml.includes("fetchWarnTimeout: 120000")) { + warnings.push("pnpm-workspace.yaml should set fetchWarnTimeout: 120000 for quiet Docker registry fetch logs."); + } + for (const dependency of requiredBuiltDependencies) { + if (!workspaceAllowedBuilds.has(dependency)) { + failures.push(`pnpm-workspace.yaml allowBuilds must set ${dependency}: true for non-interactive pnpm 11 Docker installs.`); + } + } +} + +for (const dockerfilePath of ["backend/Dockerfile", "Dockerfile.frontend"]) { + if (!exists(dockerfilePath)) { + failures.push(`Missing ${dockerfilePath}.`); + continue; + } + const dockerfile = read(dockerfilePath); + if (!dockerfile.includes("corepack enable")) warnings.push(`${dockerfilePath} does not explicitly enable Corepack.`); + if (!dockerfile.includes("ENV CI=true")) warnings.push(`${dockerfilePath} should set CI=true to suppress interactive package-manager notices during Docker builds.`); + if (!dockerfile.includes("COREPACK_ENABLE_DOWNLOAD_PROMPT=0")) warnings.push(`${dockerfilePath} should suppress Corepack download prompts for clean non-interactive Docker logs.`); + if (!dockerfile.includes("npm_config_fetch_warn_timeout=60000")) warnings.push(`${dockerfilePath} should raise npm_config_fetch_warn_timeout to avoid noisy slow-registry warnings in Docker logs.`); + if (!dockerfile.includes("pnpm install --frozen-lockfile")) failures.push(`${dockerfilePath} does not run pnpm install with a frozen lockfile.`); + if (!dockerfile.includes('"[W" "ARN] Request took"')) failures.push(`${dockerfilePath} must filter pnpm slow-registry timing warnings from successful Docker install logs.`); +} + +if (exists(".dockerignore")) { + const dockerignore = read(".dockerignore"); + if (!dockerignore.includes("backend/prisma/migrations/20260606200000_init_project_workspace")) { + failures.push(".dockerignore must exclude the obsolete duplicate init migration so update overlays cannot copy it into Docker images."); + } +} else { + failures.push("Missing .dockerignore."); +} + +if (exists("backend/scripts/docker-entrypoint.sh")) { + const entrypoint = read("backend/scripts/docker-entrypoint.sh"); + if (!entrypoint.includes("20260606200000_init_project_workspace")) { + failures.push("backend/scripts/docker-entrypoint.sh must guard against the obsolete duplicate init migration at runtime."); + } + if (!entrypoint.includes("prisma migrate resolve --rolled-back 20260606200000_init_project_workspace")) { + failures.push("backend/scripts/docker-entrypoint.sh must recover failed obsolete duplicate init migration records with prisma migrate resolve --rolled-back."); + } +} + +if (exists("backend/prisma/migrations/20260606200000_init_project_workspace")) { + failures.push("backend/prisma/migrations/20260606200000_init_project_workspace must be deleted from source; it duplicates the init migration and breaks fresh database startup."); +} + +if (exists("backend/Dockerfile")) { + const backendDockerfile = read("backend/Dockerfile"); + if (!backendDockerfile.includes("pnpm run prisma:generate")) failures.push("backend/Dockerfile must generate Prisma client after install and copy."); + if (!backendDockerfile.includes("scripts/docker-entrypoint.sh")) failures.push("backend/Dockerfile must use the database-aware entrypoint."); + if (!backendDockerfile.includes("apk add --no-cache") || !backendDockerfile.includes("openssl")) failures.push("backend/Dockerfile must install openssl in the Alpine image so Prisma schema engines can run during migrate deploy."); + if (!backendDockerfile.includes("libc6-compat")) warnings.push("backend/Dockerfile should install libc6-compat for native engine compatibility on Alpine."); + if (!backendDockerfile.includes("PRISMA_SKIP_POSTINSTALL_GENERATE=true")) warnings.push("backend/Dockerfile should skip Prisma postinstall generate and run the explicit prisma:generate step after the schema is copied."); +} + +if (exists("backend/prisma/schema.prisma")) { + const prismaSchema = read("backend/prisma/schema.prisma"); + if (!prismaSchema.includes("linux-musl-openssl-3.0.x")) failures.push("Prisma generator must include linux-musl-openssl-3.0.x binaryTarget for the Node Alpine Docker runtime."); +} + +if (exists("backend/src/services/blueprintEngine.ts")) { + const backendBlueprintEngine = read("backend/src/services/blueprintEngine.ts"); + if (!backendBlueprintEngine.includes("const archetypes: Archetype[]")) { + failures.push("backend/src/services/blueprintEngine.ts must type archetypes as Archetype[] so cluster includes checks accept every BlueprintClusterIdDto during backend builds."); + } + if (backendBlueprintEngine.includes("type Archetype = (typeof archetypes)[number]")) { + failures.push("backend/src/services/blueprintEngine.ts must not infer Archetype from the const archetype array; that narrows clusters per object and breaks Docker backend tsc."); + } +} + +const result = { + status: failures.length === 0 ? "PASS" : "FAIL", + migrationRoots: ["backend/prisma/migrations"], + requiredBuiltDependencies, + workspaceAllowBuilds: exists("pnpm-workspace.yaml") ? Array.from(parseWorkspaceAllowBuilds(read("pnpm-workspace.yaml"))).sort() : [], + opensslRuntime: exists("backend/Dockerfile") ? read("backend/Dockerfile").includes("openssl") : false, + prismaMuslTarget: exists("backend/prisma/schema.prisma") ? read("backend/prisma/schema.prisma").includes("linux-musl-openssl-3.0.x") : false, + obsoleteDuplicateInitMigrationExcluded: exists(".dockerignore") ? read(".dockerignore").includes("backend/prisma/migrations/20260606200000_init_project_workspace") : false, + obsoleteDuplicateInitMigrationAbsent: !exists("backend/prisma/migrations/20260606200000_init_project_workspace"), + failures, + warnings, +}; + +if (process.argv.includes("--json")) { + console.log(JSON.stringify(result, null, 2)); +} else { + console.log(`ProjectBrain Docker build readiness: ${result.status}`); + console.log(`Migration roots checked: ${result.migrationRoots.join(", ")}`); + console.log(`Approved build dependencies required: ${requiredBuiltDependencies.join(", ")}`); + console.log(`Workspace allowBuilds: ${exists("pnpm-workspace.yaml") ? Array.from(parseWorkspaceAllowBuilds(read("pnpm-workspace.yaml"))).sort().join(", ") : "missing"}`); + console.log(`Backend OpenSSL runtime: ${exists("backend/Dockerfile") && read("backend/Dockerfile").includes("openssl") ? "yes" : "no"}`); + console.log(`Prisma Alpine binary target: ${exists("backend/prisma/schema.prisma") && read("backend/prisma/schema.prisma").includes("linux-musl-openssl-3.0.x") ? "yes" : "no"}`); + console.log(`Obsolete duplicate init migration excluded: ${exists(".dockerignore") && read(".dockerignore").includes("backend/prisma/migrations/20260606200000_init_project_workspace") ? "yes" : "no"}`); + if (warnings.length > 0) { + console.log("Warnings:"); + for (const warning of warnings) console.log(`- ${warning}`); + } + if (failures.length > 0) { + console.log("Failures:"); + for (const failure of failures) console.log(`- ${failure}`); + } +} + +if (failures.length > 0) process.exit(1); diff --git a/scripts/check-execution-center-readiness.mjs b/scripts/check-execution-center-readiness.mjs new file mode 100644 index 0000000..5511b7e --- /dev/null +++ b/scripts/check-execution-center-readiness.mjs @@ -0,0 +1,63 @@ +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/features/projects/pages/Workspace.tsx", + "package.json", +]; + +const fail = (message) => { + console.error(`ProjectBrain execution center readiness: FAIL - ${message}`); + process.exit(1); +}; + +for (const file of requiredFiles) { + if (!existsSync(file)) fail(`missing ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const page = read("src/features/execution/pages/ExecutionCenter.tsx"); +const routes = read("src/app/routes.tsx"); +const nav = read("src/shared/lib/navigation.tsx"); +const rail = read("src/shared/components/ProjectFlowRail.tsx"); +const workspace = read("src/features/projects/pages/Workspace.tsx"); +const pkg = read("package.json"); + +if (!routes.includes('/projects/:projectId/execution')) fail("execution route is not registered"); +if (!routes.includes('ExecutionCenter')) fail("ExecutionCenter page is not imported in routes"); +if (!nav.includes('Execution Center')) fail("Execution Center is missing from navigation"); +if (!nav.includes('/execution')) fail("execution navigation path is missing"); +if (!rail.includes('id: "execution"')) fail("project flow rail lacks execution step"); +if (!workspace.includes('Open Execution') || !workspace.includes('Execution Center')) fail("workspace lacks execution shortcuts"); +if (!page.includes('RuntimeExecutionQueue')) fail("execution page lacks runtime execution queue"); +if (!page.includes('RuntimeHandoffBuilder') || !page.includes('RuntimeContextPromptPilot')) fail("execution page lacks handoff/prompt bridge"); +if (!page.includes('generateFirstImplementationTask')) fail("execution page cannot create the first implementation task"); +if (!page.includes('downloadText') || !page.includes('navigator.clipboard')) fail("execution page lacks copy/download brief support"); +if (!page.includes('Review-gated execution inputs')) fail("execution page does not expose review-gated inputs"); +if (!page.includes('acceptedStarterReviews')) fail("execution page does not distinguish accepted starter reviews"); +if (!page.includes('Active Codex Skills')) fail("execution page does not show active Codex skills"); +if (!page.includes('formatSkillsForCodexHandoff')) fail("execution brief does not include skill blocks"); +if (!page.includes('QA/QC skill is active')) fail("execution readiness does not check QA/QC skill coverage"); +if (!page.includes('Codex Handoff Quality Engine')) fail("execution page does not show Codex handoff quality engine"); +if (!page.includes('Codex Session Pack')) fail("execution page does not show Codex session pack."); +if (!page.includes('Run in Codex')) fail("execution page must expose Run in Codex action."); +if (!page.includes('Download ZIP')) fail("execution page does not expose session pack ZIP action."); +if (!page.includes('/session-packs')) fail("execution page does not link to Session Packs."); +if (!page.includes('Copy next Codex prompt')) fail("execution page cannot copy next Codex prompt"); +if (!page.includes('Open Codex Handoff')) fail("execution page does not link to Codex handoffs"); +if (!page.includes('Review impact')) fail("execution page does not expose handoff impact review"); +if (!page.includes('Recommended to approve the impact review before executing this prompt')) fail("execution page must warn before unapproved prompt execution"); +if (!page.includes('Codex Execution Feedback')) fail("execution page must show execution feedback status"); +if (!page.includes('View build loop')) fail("execution page must link to Build Loop"); +if (!routes.includes('/projects/:projectId/execution-feedback')) fail("execution feedback route is not registered"); +if (!routes.includes('/projects/:projectId/build-loop')) fail("build loop route is not registered"); +if (!pkg.includes('audit:execution-feedback')) fail("package scripts lack audit:execution-feedback"); +if (!pkg.includes('audit:build-loop')) fail("package scripts lack audit:build-loop"); +if (!pkg.includes('audit:execution-center')) fail("package scripts lack audit:execution-center"); +if (!pkg.includes('audit:history-center') || !pkg.includes('audit:source-library') || !pkg.includes('audit:guided-start') || !pkg.includes('audit:skills') || !pkg.includes('audit:repository-intake') || !pkg.includes('audit:handoffs') || !pkg.includes('audit:handoff-review') || !pkg.includes('audit:execution-feedback') || !pkg.includes('audit:build-loop')) fail("audit chain lost previous ProjectBrain gates"); + +console.log("ProjectBrain execution center readiness: PASS"); diff --git a/scripts/check-execution-feedback-readiness.mjs b/scripts/check-execution-feedback-readiness.mjs new file mode 100644 index 0000000..9bd4aa0 --- /dev/null +++ b/scripts/check-execution-feedback-readiness.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/model.ts", + "src/shared/lib/codexExecutionFeedback.ts", + "src/shared/lib/safeData.ts", + "src/shared/api/realClient.ts", + "src/shared/hooks/useProjectData.ts", + "src/shared/contexts/ProjectContext.tsx", + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/history/pages/ProjectHistory.tsx", + "src/features/audit/pages/ProjectAuditCenter.tsx", + "src/features/projects/pages/Workspace.tsx", + "src/shared/lib/projectStartFlow.ts", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); + +const model = text["src/shared/lib/model.ts"] ?? ""; +const helper = text["src/shared/lib/codexExecutionFeedback.ts"] ?? ""; +const safeData = text["src/shared/lib/safeData.ts"] ?? ""; +const realClient = text["src/shared/api/realClient.ts"] ?? ""; +const hooks = text["src/shared/hooks/useProjectData.ts"] ?? ""; +const context = text["src/shared/contexts/ProjectContext.tsx"] ?? ""; +const routes = text["src/app/routes.tsx"] ?? ""; +const navigation = text["src/shared/lib/navigation.tsx"] ?? ""; +const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? ""; +const page = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? ""; +const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? ""; +const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [model, "export interface CodexExecutionFeedback", "CodexExecutionFeedback type missing."], + [model, "export interface CodexExecutionValidationResult", "CodexExecutionValidationResult type missing."], + [model, "export interface CodexExecutionChangedFile", "CodexExecutionChangedFile type missing."], + [model, "export type CodexExecutionOutcome", "CodexExecutionOutcome type missing."], + [model, "export interface CodexExecutionFollowUp", "CodexExecutionFollowUp type missing."], + [model, "export type CodexExecutionEvidenceStatus", "CodexExecutionEvidenceStatus type missing."], + [model, "codexExecutionFeedback?: CodexExecutionFeedback[]", "Project model must persist execution feedback."], + [safeData, "codexExecutionFeedback: asArray(project.codexExecutionFeedback)", "Runtime normalization must preserve execution feedback."], + [helper, "parseCodexExecutionReport", "Feedback parser helper missing."], + [helper, "extractChangedFilesFromReport", "Changed-file parser missing."], + [helper, "extractValidationResultsFromReport", "Validation parser missing."], + [helper, "compareFeedbackToHandoff", "Feedback-to-handoff comparison missing."], + [helper, "deriveExecutionOutcome", "Outcome derivation missing."], + [helper, "deriveResolvedGaps", "Resolved gap derivation missing."], + [helper, "deriveNewGapCandidates", "New gap candidate derivation missing."], + [helper, "deriveExecutionFollowUps", "Follow-up derivation missing."], + [helper, "formatExecutionFeedbackMarkdown", "Feedback markdown formatter missing."], + [helper, "scoreFeedbackCompleteness", "Feedback completeness scoring missing."], + [helper, "not live git truth", "Helper must state feedback is not live git truth."], + [realClient, "importCodexOutput", "Real client must expose backend Codex output import."], + [hooks, "useProjectCodexTasksData", "Async hooks must expose backend Codex tasks for feedback import."], + [hooks, "useProjectReviewQueueData", "Async hooks must expose Review Queue reload after feedback import."], + [context, "saveCodexExecutionFeedback", "ProjectContext must save feedback."], + [context, "updateCodexExecutionFeedbackStatus", "ProjectContext must update evidence status."], + [context, "archiveCodexExecutionFeedback", "ProjectContext must archive feedback."], + [context, "createFollowUpHandoffFromFeedback", "ProjectContext must create follow-up handoff from feedback."], + [context, "executionFeedbackItems", "Project search must index execution feedback."], + [routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."], + [navigation, "Execution Feedback", "Navigation must expose Execution Feedback."], + [flowRail, 'id: "execution-feedback"', "Project Flow Rail must include feedback step."], + [page, "Intake Codex report", "Execution Feedback page must show intake form."], + [page, "Live backend Codex output import", "Execution Feedback page must expose the live backend Codex output import bridge."], + [page, "Import to backend Review Queue", "Execution Feedback page must import backend Codex output into Review Queue."], + [page, "useProjectCodexTasksData", "Execution Feedback page must read live backend Codex tasks."], + [page, "realClient.runtimeV7.importCodexOutput", "Execution Feedback page must call the backend Codex output import endpoint."], + [page, "Linked handoff", "Execution Feedback page must link handoffs."], + [page, "Linked session pack", "Execution Feedback page must link session packs."], + [page, "Session pack feedback boundary", "Execution Feedback page must show session pack feedback boundary."], + [page, "Use this feedback template", "Execution Feedback page must insert session pack feedback template."], + [page, "Download template", "Execution Feedback page must download session pack feedback template."], + [page, "Paste Codex report generated from this session pack", "Execution Feedback page must label reports from session packs."], + [page, "Parsed preview", "Execution Feedback page must show parsed preview."], + [page, "Claimed changed files", "Execution Feedback page must show changed files."], + [page, "Validation results", "Execution Feedback page must show validation results."], + [page, "Accept evidence", "Execution Feedback page must accept evidence."], + [page, "Create follow-up handoff", "Execution Feedback page must create follow-up handoffs."], + [page, "not live git truth", "Execution Feedback page must show evidence boundary."], + [page, "Return to Build Loop", "Execution Feedback page must link back to Build Loop."], + [handoffs, "Add execution feedback", "Codex Handoffs page must link feedback intake."], + [handoffs, "Create session pack", "Codex Handoffs page must create session packs."], + [handoffs, "Feedback status", "Codex Handoffs page must show feedback status."], + [execution, "Codex Execution Feedback", "Execution Center must show execution feedback."], + [execution, "Codex Session Pack", "Execution Center must show session pack status."], + [repoBuilder, "feedback records", "Repo Builder must show feedback status."], + [history, 'kind: "executionFeedback"', "Project History must include feedback records."], + [history, 'kind: "sessionPack"', "Project History must include session pack records."], + [audit, "Execution feedback intake", "Audit Center must show feedback coverage."], + [audit, "Codex session pack readiness", "Audit Center must show session pack coverage."], + [workspace, "Execution Feedback", "Workspace must expose Execution Feedback."], + [startFlow, "add-execution-feedback", "Next action must add execution feedback."], + [startFlow, "review-execution-feedback", "Next action must review execution feedback."], + [startFlow, "create-feedback-follow-up", "Next action must create feedback follow-up."], + [pkg, "audit:execution-feedback", "package.json must expose audit:execution-feedback."], + [pkg, "check-execution-feedback-readiness.mjs", "package.json must wire feedback readiness script."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (!pkg.includes("audit:execution-feedback") || !pkg.includes("audit:all")) { + failures.push("audit:all must include the execution feedback readiness check."); +} + +if (failures.length) { + console.error("ProjectBrain execution feedback readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain execution feedback readiness: PASS"); diff --git a/scripts/check-frontend-runtime-readiness.mjs b/scripts/check-frontend-runtime-readiness.mjs new file mode 100644 index 0000000..fd3896c --- /dev/null +++ b/scripts/check-frontend-runtime-readiness.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import { readFileSync, existsSync } from 'node:fs'; + +const checks = []; +const fail = (message) => checks.push({ ok: false, message }); +const pass = (message) => checks.push({ ok: true, message }); +const read = (path) => readFileSync(path, 'utf8'); + +const boundaryPath = 'src/shared/components/RuntimeErrorBoundary.tsx'; +if (existsSync(boundaryPath)) { + const boundary = read(boundaryPath); + boundary.includes('componentDidCatch') ? pass('RuntimeErrorBoundary catches render crashes') : fail('RuntimeErrorBoundary must implement componentDidCatch'); + boundary.includes('Frontend runtime guard') ? pass('RuntimeErrorBoundary renders user-facing fallback') : fail('RuntimeErrorBoundary needs visible fallback copy'); + boundary.includes('Copy diagnostics') && boundary.includes('navigator.clipboard.writeText') + ? pass('RuntimeErrorBoundary can copy diagnostics') + : fail('RuntimeErrorBoundary must support one-click diagnostic copy'); +} else { + fail('RuntimeErrorBoundary component is missing'); +} + +const app = read('src/App.tsx'); +app.includes('') ? pass('App wraps routes in RuntimeErrorBoundary') : fail('App must wrap routes in RuntimeErrorBoundary'); +const routes = read('src/app/routes.tsx'); + +const projectBrainClient = read('src/shared/api/projectBrainClient.ts'); +projectBrainClient.includes('const useRealClient = false') + ? pass('Synchronous projectBrainClient cannot call async realClient') + : fail('projectBrainClient must not route sync selectors to async realClient'); +projectBrainClient.includes('Real HTTP calls are async') + ? pass('Sync/real client boundary is documented') + : fail('Sync/real client boundary must be documented'); + +const projectContext = read('src/shared/contexts/ProjectContext.tsx'); +projectContext.includes('toProjectArray(getProjects())') + ? pass('ProjectContext normalizes project arrays') + : fail('ProjectContext must normalize getProjects result before .map'); +projectContext.includes('toFeatureGroups(project)') + ? pass('ProjectContext guards featureGroups access') + : fail('ProjectContext must guard featureGroups access'); +projectContext.includes('realClient.projects.listProjects()') && projectContext.includes('reloadProjectData') + ? pass('ProjectContext can hydrate projects from the real backend') + : fail('ProjectContext must expose a real backend project hydration path'); +projectContext.includes('realClient.projects.createProject') && projectContext.includes('Project could not be saved to the backend') + ? pass('ProjectContext can create projects through the real backend') + : fail('ProjectContext must create projects through the real backend in real mode'); +projectContext.includes('realClient.workspace.updateActiveContextPack') && projectContext.includes('Active context pack could not be saved to the backend') + ? pass('ProjectContext can persist active context pack changes') + : fail('ProjectContext must persist active context pack changes in real mode'); +projectContext.includes('realClient.workspace.getWorkspaceState(projectId)') && projectContext.includes('realClient.projectDNA.getProjectDNA(projectId)') + ? pass('ProjectContext hydrates workspace and DNA with project data') + : fail('ProjectContext must hydrate workspace and DNA in real project mode'); +projectContext.includes('projectDataMode: "local" | "real"') && projectContext.includes('projectDataLoading') && projectContext.includes('projectDataError') + ? pass('ProjectContext exposes data mode, loading and error state') + : fail('ProjectContext must expose project data mode, loading and error state'); +routes.includes('ProjectRouteSync') && routes.includes('routeProjectExists') && routes.includes('setActiveProjectId(routeProjectId)') + ? pass('Project routes follow known project route changes') + : fail('Project route sync must follow known project route changes without activating unknown slugs'); + +const projectsPage = read('src/features/projects/pages/Projects.tsx'); +projectsPage.includes('Array.isArray(project.featureGroups)') + ? pass('Projects page guards featureGroups before stats') + : fail('Projects page must guard featureGroups before stats'); +projectsPage.includes('project.workspaceState?.focusAreas?.length') + ? pass('Projects page guards focusAreas length') + : fail('Projects page must guard focusAreas length'); +projectsPage.includes('Database projects') && projectsPage.includes('Reload projects') + ? pass('Projects page exposes real data mode and reload affordance') + : fail('Projects page must expose real data mode and reload affordance'); + +const workspacePage = read('src/features/projects/pages/Workspace.tsx'); +workspacePage.includes('Database-backed project context') && workspacePage.includes('Reload project') + ? pass('Workspace exposes real project context and reload affordance') + : fail('Workspace must expose real project context and reload affordance'); + +const failures = checks.filter((check) => !check.ok); +if (failures.length) { + console.error('ProjectBrain frontend runtime readiness: FAIL'); + for (const check of checks) console.error(`${check.ok ? '✓' : '✗'} ${check.message}`); + process.exit(1); +} + +console.log('ProjectBrain frontend runtime readiness: PASS'); +for (const check of checks) console.log(`✓ ${check.message}`); diff --git a/scripts/check-guided-start-readiness.mjs b/scripts/check-guided-start-readiness.mjs new file mode 100644 index 0000000..ff9240c --- /dev/null +++ b/scripts/check-guided-start-readiness.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); +const exists = (file) => fs.existsSync(path.join(root, file)); +const failures = []; +const requireFile = (file, reason) => { + if (!exists(file)) failures.push(`${file}: ${reason}`); +}; +const requireIncludes = (file, needle, reason) => { + if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${reason}`); +}; +const requireExcludes = (file, needle, reason) => { + if (exists(file) && read(file).includes(needle)) failures.push(`${file}: ${reason}`); +}; +const requireBefore = (file, firstNeedle, secondNeedle, reason) => { + if (!exists(file)) { + failures.push(`${file}: ${reason}`); + return; + } + const content = read(file); + const firstIndex = content.indexOf(firstNeedle); + const secondIndex = content.indexOf(secondNeedle); + if (firstIndex < 0 || secondIndex < 0 || firstIndex > secondIndex) failures.push(`${file}: ${reason}`); +}; + +requireFile("src/features/start/pages/GuidedStart.tsx", "Guided Start page must exist."); +requireFile("src/shared/lib/guidedStart.ts", "Guided Start deterministic idea helper must exist."); +requireFile("src/shared/lib/projectStartFlow.ts", "Unified project start flow helper must exist."); +requireIncludes("src/app/routes.tsx", 'path="/start"', "Guided Start route must be registered."); +requireIncludes("src/app/routes.tsx", 'path="/projects/new"', "Project setup compatibility route must stay registered."); +requireIncludes("src/app/routes.tsx", '', "Root route should land on Guided Start."); +requireIncludes("src/app/routes.tsx", "ProjectSetupFlow", "Project setup compatibility route should use the shared setup component."); +requireIncludes("src/shared/lib/navigation.tsx", 'label: "Nieuw project"', "Navigation should expose the unified Start flow."); +requireBefore("src/shared/lib/navigation.tsx", 'label: "Projecten"', 'label: "Nieuw project"', "Navigation should list Projects before Start for returning users."); +requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", 'searchParams.get("projectType")', "Wizard should accept guided-start projectType prefill."); +requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", 'searchParams.get("firstSlice")', "Wizard should accept first-slice prefill."); +requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "Project maken", "Project setup page should identify itself as part of the unified start flow."); +requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "recommendation.recommendedSkills.length", "Project setup should derive execution profiles automatically."); +requireIncludes("src/shared/lib/guidedStart.ts", ".replace(/[-_/]+/g", "Guided Start should split hyphenated/slashed terms such as self-hosted and repo-builder."); +requireIncludes("src/features/start/pages/GuidedStart.tsx", 'navigate(`/projects/new?', "All new project paths should use the shared setup route."); +requireIncludes("src/features/start/pages/GuidedStart.tsx", "projectEntryPath(continuationProject.id)", "Existing project continuation should use the canonical project entry route."); +requireIncludes("src/app/routes.tsx", "resolveProjectResume", "Canonical project entry should resolve to the live overview or next required project step."); +requireIncludes("src/shared/lib/projectWizard.ts", "recommendSkillIdsForProjectProfile", "Project creation should use the Codex skill catalog for recommendations."); +requireIncludes("src/shared/lib/model.ts", "recommendedSkills?: string[]", "First-run state should persist recommended Codex skills."); +requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "firstImplementationTaskReady", "The guided launchpad should show status-aware first execution guidance."); +requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "Eerste implementatietaak maken", "Launchpad should create the first implementation task."); +requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "Eerste taak beoordelen", "Launchpad should become status-aware after the first task exists."); +requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "firstImplementationTaskReady", "The launchpad should derive first-task status from project review data."); +requireIncludes("src/shared/lib/projectFirstRunGenerationCommands.ts", "first-implementation-task-${projectId}", "First implementation task id should be stable per project."); +requireIncludes("src/shared/contexts/ProjectContext.tsx", "decideStarterReviewItem", "Starter review decisions should persist back to project data."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "item.attribution?.approvalState", "Review Queue should derive starter review status from project approval state."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "decideStarterReviewItem", "Review Queue should persist starter item actions to ProjectContext."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Bij acceptatie kan dit:", "Review Queue should show an impact preview before accepting an item."); +requireIncludes("src/features/projects/pages/Projects.tsx", "Projectcatalogus", "Projects page should present existing projects as the primary returning-user catalog."); +requireIncludes("src/features/projects/pages/Projects.tsx", "Ga verder waar je stopte", "Projects page should surface the current project continuation path."); +requireIncludes("src/features/projects/pages/Projects.tsx", "Nieuw project", "Projects page should send new projects to the unified Start flow."); +requireIncludes("src/features/projects/pages/Projects.tsx", "sortedProjects", "Projects page should sort project memories by current relevance."); +requireIncludes("src/features/projects/pages/Projects.tsx", "getProjectOperatingStep", "Projects page should use shared next-action logic."); +requireIncludes("src/shared/lib/projectStartFlow.ts", "source-material", "Start flow must support source material."); +requireIncludes("src/shared/lib/projectStartFlow.ts", "existing-repo", "Start flow must support existing repositories."); +requireIncludes("src/shared/lib/projectStartFlow.ts", "direct-project", "Start flow must support direct project creation."); +requireIncludes("src/shared/lib/projectStartFlow.ts", "newProjectStartFlowEntries", "New-project entries should be separated from existing-project continuation."); +requireIncludes("src/shared/lib/projectStartFlow.ts", "Vaardigheden", "Start flow must include the Skills step."); +requireIncludes("src/shared/lib/projectStartFlow.ts", "import-repository-state", "Existing repository projects should next-action into repository intake."); +requireIncludes("src/shared/lib/projectStartFlow.ts", "Momentopname van repository maken", "Existing repository projects should progress from intake to repo state snapshot."); +requireIncludes("src/shared/lib/projectWizard.ts", "Import repository state in Repo Builder", "Project setup should mention repository intake when an existing repo is provided."); +requireIncludes("src/features/projects/pages/Workspace.tsx", "resolveProjectWorkState", "Workspace should derive continuation from canonical delivery and Auto state."); +requireIncludes("src/features/projects/pages/Workspace.tsx", "onClick={() => navigate(workState.primaryRoute)}", "Workspace should route its primary continuation through the shared operating step."); +requireIncludes("src/components/layout/Sidebar.tsx", "staticProjectStep", "Sidebar should prioritize explicit attention and defer live status to overview."); +requireIncludes("src/app/routes.tsx", "/projects/:projectId/skills", "Unified project flow should include Skill Library route."); +requireIncludes("src/shared/components/ProjectFlowRail.tsx", 'id: "skills"', "Unified project flow rail should include Codex Skills."); +requireExcludes("src/features/projects/pages/Projects.tsx", "New Project Wizard", "Projects page should not keep the old competing wizard CTA."); +requireExcludes("src/features/projects/pages/Projects.tsx", "Open Wizard", "Projects page should not keep a separate wizard CTA."); +requireExcludes("src/features/start/pages/GuidedStart.tsx", "Skip to wizard", "Guided Start should not present a separate wizard escape hatch."); +requireExcludes("src/features/brainstorm/pages/BrainstormLab.tsx", "Open wizard", "Brainstorm project conversion should open project setup, not a separate wizard concept."); +requireExcludes("src/features/brainstorm/pages/BrainstormLab.tsx", "first-run wizard", "Brainstorm copy should use unified Start/project setup language."); +requireIncludes("src/features/brainstorm/pages/BrainstormLab.tsx", "Open Review Queue", "Brainstorm review action should state that it opens Review Queue."); +requireExcludes("src/features/projects/components/FirstRunLaunchpad.tsx", "The wizard created", "Launchpad should describe project setup output instead of a separate wizard."); +requireExcludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "The wizard will", "Project setup page should not reintroduce old wizard language."); + +if (failures.length) { + console.error("ProjectBrain guided start readiness: FAIL"); + failures.forEach((failure) => console.error(`- ${failure}`)); + process.exit(1); +} + +console.log("ProjectBrain guided start readiness: PASS"); diff --git a/scripts/check-handoff-review-readiness.mjs b/scripts/check-handoff-review-readiness.mjs new file mode 100644 index 0000000..f05aa2f --- /dev/null +++ b/scripts/check-handoff-review-readiness.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/model.ts", + "src/shared/lib/handoffReview.ts", + "src/shared/lib/safeData.ts", + "src/shared/contexts/ProjectContext.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/prompts/pages/PromptGenerator.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/history/pages/ProjectHistory.tsx", + "src/features/audit/pages/ProjectAuditCenter.tsx", + "src/features/projects/pages/Workspace.tsx", + "src/shared/lib/projectStartFlow.ts", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); +const model = text["src/shared/lib/model.ts"] ?? ""; +const helper = text["src/shared/lib/handoffReview.ts"] ?? ""; +const safeData = text["src/shared/lib/safeData.ts"] ?? ""; +const context = text["src/shared/contexts/ProjectContext.tsx"] ?? ""; +const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const feedbackPage = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? ""; +const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? ""; +const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? ""; +const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [model, "export interface HandoffReview", "HandoffReview type missing."], + [model, "export interface HandoffFileImpact", "HandoffFileImpact type missing."], + [model, "export interface HandoffImpactRisk", "HandoffImpactRisk type missing."], + [model, "export interface HandoffValidationCoverage", "HandoffValidationCoverage type missing."], + [model, "export type HandoffReviewDecision", "HandoffReviewDecision type missing."], + [model, "HandoffSimulationSource", "HandoffSimulationSource type missing."], + [model, "handoffReviews?: HandoffReview[]", "Project model must persist handoff reviews."], + [safeData, "handoffReviews: asArray(project.handoffReviews)", "Runtime normalization must preserve handoff reviews."], + [helper, "generateHandoffReview", "handoffReview helper must generate reviews."], + [helper, "simulateFileImpacts", "handoffReview helper must simulate file impacts."], + [helper, "classifyHandoffFileAction", "handoffReview helper must classify file actions."], + [helper, "scoreFileImpactRisk", "handoffReview helper must score file impact risk."], + [helper, "buildValidationCoverage", "handoffReview helper must build validation coverage."], + [helper, "buildRollbackCoverage", "handoffReview helper must build rollback coverage."], + [helper, "getMissingSafeguards", "handoffReview helper must list missing safeguards."], + [helper, "recommendHandoffReviewDecision", "handoffReview helper must recommend decisions."], + [helper, "formatHandoffReviewMarkdown", "handoffReview helper must format markdown."], + [helper, "not a live git diff", "Review helper must label simulation boundary."], + [context, "generateHandoffReview", "ProjectContext must expose generateHandoffReview."], + [context, "updateHandoffReviewDecision", "ProjectContext must update review decisions."], + [context, "archiveHandoffReview", "ProjectContext must archive handoff reviews."], + [context, "handoffReviewItems", "Project search must index handoff reviews."], + [handoffs, "Generate impact review", "Codex Handoffs page must generate impact reviews."], + [handoffs, "Create session pack", "Codex Handoffs page must create session packs after review."], + [handoffs, "Refresh impact review", "Codex Handoffs page must refresh impact reviews."], + [handoffs, "Copy review", "Codex Handoffs page must copy reviews."], + [handoffs, "Download review", "Codex Handoffs page must download reviews."], + [handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs page must show simulation boundary."], + [handoffs, "simulatedFileImpacts", "Codex Handoffs page must show simulated file impacts."], + [handoffs, "missingSafeguards", "Codex Handoffs page must show missing safeguards."], + [handoffs, "recommendedReviewDecision", "Codex Handoffs page must show recommended decision."], + [feedbackPage, "Linked handoff", "Execution Feedback must link feedback to handoffs/reviews."], + [execution, "Review impact", "Execution Center must show review impact action."], + [execution, "Codex Session Pack", "Execution Center must show session pack handoff boundary."], + [execution, "Recommended to approve the impact review before executing this prompt", "Execution Center must warn before unapproved execution."], + [promptBuilder, "Pre-execution impact review", "Prompt Builder must include pre-execution review summary."], + [promptBuilder, "missingSafeguards", "Prompt Builder must include missing safeguards."], + [repoBuilder, "Handoff review status", "Repo Builder must show handoff review status."], + [history, 'kind: "handoffReview"', "Project History must include handoff review records."], + [history, 'kind: "sessionPack"', "Project History must include session pack records."], + [audit, "Handoff review simulation", "Audit Center must show handoff review coverage."], + [audit, "Codex session pack readiness", "Audit Center must show session pack readiness."], + [workspace, "handoffsWithoutReview", "Workspace must detect handoffs without review."], + [workspace, "fix safeguards", "Workspace must expose safeguard-fix next action."], + [startFlow, "review-handoff-impact", "Project next actions must review handoff impact."], + [startFlow, "fix-handoff-safeguards", "Project next actions must fix handoff safeguards."], + [startFlow, "execute-approved-handoff", "Project next actions must execute approved handoffs."], + [pkg, "audit:handoff-review", "package.json must expose audit:handoff-review."], + [pkg, "check-handoff-review-readiness.mjs", "package.json must wire handoff review readiness script."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (!pkg.includes("audit:handoff-review") || !pkg.includes("audit:all")) { + failures.push("audit:all must include the handoff review readiness check."); +} + +if (failures.length) { + console.error("ProjectBrain handoff review readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain handoff review readiness: PASS"); diff --git a/scripts/check-history-center-readiness.mjs b/scripts/check-history-center-readiness.mjs new file mode 100644 index 0000000..d83b2b2 --- /dev/null +++ b/scripts/check-history-center-readiness.mjs @@ -0,0 +1,50 @@ +import { readFileSync, existsSync } from "node:fs"; + +const requiredFiles = [ + "src/features/history/pages/ProjectHistory.tsx", + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "src/shared/contexts/ProjectContext.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/features/projects/pages/Workspace.tsx", +]; + +const fail = (message) => { + console.error(`ProjectBrain history center readiness: FAIL - ${message}`); + process.exit(1); +}; + +for (const file of requiredFiles) { + if (!existsSync(file)) fail(`missing ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const page = read("src/features/history/pages/ProjectHistory.tsx"); +const routes = read("src/app/routes.tsx"); +const nav = read("src/shared/lib/navigation.tsx"); +const context = read("src/shared/contexts/ProjectContext.tsx"); +const historyCommands = read("src/shared/lib/projectHistoryCommands.ts"); +const rail = read("src/shared/components/ProjectFlowRail.tsx"); +const workspace = read("src/features/projects/pages/Workspace.tsx"); +const routeRegistry = read("src/shared/lib/routeRegistry.ts"); +const pkg = read("package.json"); + +if (!routes.includes('/projects/:projectId/history')) fail("history route is not registered"); +if (!routes.includes('ProjectHistory')) fail("ProjectHistory page is not imported in routes"); +if (!nav.includes('getPrimaryPathNavigationItems') || !nav.includes('projectRoutePath(projectId, key)')) fail("Project activity/history is missing from primary navigation"); +if (!routeRegistry.includes('key: "activity"') || !routeRegistry.includes('segment: "history"')) fail("Project activity/history is missing from the canonical route registry"); +if (!nav.includes('/history')) fail("history navigation path is missing"); +if (!context.includes('deleteProjectHistoryEntry')) fail("context lacks history entry removal action"); +if (!historyCommands.includes('hiddenHistoryEntryIds')) fail("project hidden history ids are not persisted"); +if (!page.includes('downloadText')) fail("history page lacks download support"); +if (!page.includes('navigator.clipboard')) fail("history page lacks copy/export support"); +if (!page.includes('Uit geschiedenis verwijderen')) fail("history page lacks remove action"); +if (!page.includes('getSnapshotsForProject') || !page.includes('getBuildsForProject')) fail("history page does not include snapshots and builds"); +if (!page.includes('project.sourceLibrary') || !page.includes('project.mediaAssets')) fail("history page does not include source/media history"); +if (!page.includes('project.starterReviewItems') || !page.includes('project.brainstorms')) fail("history page does not include review/brainstorm history"); +if (!rail.includes('id: "history"')) fail("project flow rail lacks history step"); +if (!nav.includes('/history')) fail("workspace navigation lacks history access"); +if (!pkg.includes('audit:history-center')) fail("package scripts lack audit:history-center"); +if (!pkg.includes('audit:source-library') || !pkg.includes('audit:guided-start')) fail("audit chain lost previous ProjectBrain gates"); + +console.log("ProjectBrain history center readiness: PASS"); diff --git a/scripts/check-release-hygiene.mjs b/scripts/check-release-hygiene.mjs new file mode 100644 index 0000000..2e0472e --- /dev/null +++ b/scripts/check-release-hygiene.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const failures = []; +const warnings = []; +const ignoredArtifacts = []; +const showLocalArtifacts = + process.env.PROJECTBRAIN_AUDIT_VERBOSE_LOCAL_ARTIFACTS === '1'; + +const gitLines = (args) => { + try { + return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + } catch { + return []; + } +}; + +const isTracked = (path) => gitLines(['ls-files', '--', path]).length > 0; +const isIgnored = (path) => gitLines(['check-ignore', '--', path]).length > 0; +const reportArtifact = (path, kind = 'path') => { + const absolute = join(root, path); + if (!existsSync(absolute)) return; + if (isTracked(path)) { + failures.push(`Forbidden tracked release artifact/${kind} exists: ${path}`); + } else if (isIgnored(path)) { + ignoredArtifacts.push(`Ignored local artifact/${kind} present but not release-blocking: ${path}`); + } else { + failures.push(`Forbidden unignored release artifact/${kind} exists: ${path}`); + } +}; + +const forbiddenPaths = [ + 'node_modules', + 'backend/node_modules', + 'dist', + 'backend/dist', + 'checkpoints', + 'src/App.css', + 'src/globals.css', +]; + +for (const path of forbiddenPaths) { + reportArtifact(path); +} + +const forbiddenFilePatterns = [/^vite-.*\.log$/, /\.zip$/i, /\.log$/i]; +const rootFiles = existsSync(root) ? readdirSync(root) : []; +for (const file of rootFiles) { + if (forbiddenFilePatterns.some((pattern) => pattern.test(file))) { + reportArtifact(file, 'file'); + } +} + +const textFilesToScan = [ + 'package.json', + 'vite.config.ts', + 'index.html', + 'README.md', + 'backend/README.md', +]; +const forbiddenText = [/componentTagger/i, /component-tagger/i]; +for (const path of textFilesToScan) { + const absolute = join(root, path); + if (!existsSync(absolute)) { + warnings.push(`Expected scan file missing: ${path}`); + continue; + } + const content = readFileSync(absolute, 'utf8'); + for (const pattern of forbiddenText) { + if (pattern.test(content)) { + failures.push(`Forbidden branding/tooling text ${pattern} found in ${path}`); + } + } +} + +const expectedFiles = [ + '.env.example', + 'backend/.env.example', + 'Dockerfile.frontend', + 'backend/Dockerfile', + 'docker-compose.yml', + 'docker-compose.dev.yml', + 'docker-compose.release.yml', + 'scripts/stage-release-oci.sh', + 'docs/PROJECTBRAIN_DOCUMENTATION_INDEX.md', + 'docs/PROJECTBRAIN_OPERATOR_RELEASE_GUIDE.md', + 'docs/FULL_VERSION_ROADMAP.md', + 'docs/VERTICAL_SLICE_CORE_FLOW_PLAN.md', + 'public/site.webmanifest', + 'public/favicon.svg', + 'public/favicon.ico', +]; +for (const path of expectedFiles) { + if (!existsSync(join(root, path))) { + warnings.push(`Expected project hygiene file missing: ${path}`); + } +} + +const workflowPaths = ['.gitea/workflows/ci.yml', '.gitea/workflows/release.yml']; +const workflow = workflowPaths + .map((path) => readFileSync(join(root, path), 'utf8')) + .join('\n'); +for (const marker of [ + 'file: Dockerfile.unraid', + 'Record immutable archive digests', + 'Scan Unraid release image', + 'archive-sha256.txt', + 'trivy-unraid.json', + 'VITE_PROJECTBRAIN_COMMIT=${{ github.sha }}', + 'org.opencontainers.image.revision=${{ github.sha }}', +]) { + if (!workflow.includes(marker)) failures.push(`Release workflow is missing required artifact evidence marker: ${marker}`); +} +if (workflow.includes('Dockerfile.backend')) { + failures.push('Release workflow references nonexistent Dockerfile.backend; use backend/Dockerfile.'); +} +if (/aquasec\/trivy(?::[\w.-]+)(?!@sha256:)/.test(workflow) || !workflow.includes('aquasec/trivy@sha256:')) { + failures.push('Trivy containers must be pinned to an immutable sha256 digest.'); +} +if (/uses:\s+(?!docker:\/\/)[^\s@]+@(?![a-f0-9]{40}(?:\s|$))/.test(workflow)) { + failures.push('External workflow actions must be pinned to immutable 40-character commit SHAs.'); +} + +const maxLargeFileBytes = 5 * 1024 * 1024; +const ignoredDirs = new Set(['.git', 'node_modules', 'backend/node_modules']); +function walk(dir) { + for (const entry of readdirSync(dir)) { + const absolute = join(dir, entry); + const rel = relative(root, absolute).replaceAll('\\\\', '/'); + if (ignoredDirs.has(rel) || ignoredDirs.has(entry)) continue; + const stat = statSync(absolute); + if (stat.isDirectory()) { + walk(absolute); + } else if (stat.size > maxLargeFileBytes) { + warnings.push(`Large file in source tree (${Math.round(stat.size / 1024 / 1024)}MB): ${rel}`); + } + } +} +walk(root); + +console.log('ProjectBrain release hygiene check'); +console.log('===================================='); +if (ignoredArtifacts.length) { + if (showLocalArtifacts) { + console.log('\nLocal ignored artifacts:'); + for (const artifact of ignoredArtifacts) console.log(`- ${artifact}`); + } +} +if (warnings.length) { + console.log('\nWarnings:'); + for (const warning of warnings) console.log(`- ${warning}`); +} +if (failures.length) { + console.log('\nFailures:'); + for (const failure of failures) console.log(`- ${failure}`); + process.exit(1); +} +console.log('\nPASS: no blocking release hygiene issues found.'); diff --git a/scripts/check-repo-builder-readiness.mjs b/scripts/check-repo-builder-readiness.mjs new file mode 100644 index 0000000..41fb353 --- /dev/null +++ b/scripts/check-repo-builder-readiness.mjs @@ -0,0 +1,156 @@ +import { readFileSync, existsSync } from 'node:fs'; + +const requiredFiles = [ + 'src/features/repo-builder/pages/RepoBuilder.tsx', + 'src/app/routes.tsx', + 'src/shared/lib/navigation.tsx', + 'src/shared/components/ProjectFlowRail.tsx', + 'src/shared/contexts/ProjectContext.tsx', + 'src/shared/lib/model.ts', + 'src/shared/lib/safeData.ts', + 'src/shared/lib/codexSkills.ts', + 'src/shared/lib/repositoryIntake.ts', + 'src/shared/lib/projectSearch.ts', + 'src/shared/lib/projectOperatingStep.ts', + 'src/features/history/pages/ProjectHistory.tsx', + 'src/features/execution-feedback/pages/ExecutionFeedback.tsx', + 'src/shared/lib/buildLoop.ts', +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, 'utf8'); +const repoPage = read('src/features/repo-builder/pages/RepoBuilder.tsx'); +const routes = read('src/app/routes.tsx'); +const navigation = read('src/shared/lib/navigation.tsx'); +const flowRail = read('src/shared/components/ProjectFlowRail.tsx'); +const context = read('src/shared/contexts/ProjectContext.tsx'); +const model = read('src/shared/lib/model.ts'); +const safeData = read('src/shared/lib/safeData.ts'); +const skills = read('src/shared/lib/codexSkills.ts'); +const repositoryIntake = read('src/shared/lib/repositoryIntake.ts'); +const projectSearch = read('src/shared/lib/projectSearch.ts'); +const operatingStep = read('src/shared/lib/projectOperatingStep.ts'); +const history = read('src/features/history/pages/ProjectHistory.tsx'); +const pkg = read('package.json'); + +const mustContain = [ + [repoPage, 'Repositorybasis genereren', 'Repo Builder page must generate foundation blueprints.'], + [repoPage, 'Uitbreiding uit selectie maken', 'Repo Builder must generate targeted expansion briefs.'], + [repoPage, 'Ontwerpdocumentatie voor deze uitbreiding', 'Repo Builder must explicitly use design documentation as repo-expansion input.'], + [repoPage, 'Actieve Codex-handoff', 'Repo Builder must expose an active Codex handoff.'], + [repoPage, 'Repository changes', 'Repo Builder must show file-level repository changes.'], + [repoPage, 'goedgekeurde ontwerpdocumentatie', 'Repo Builder must explain its place in the reviewed project flow.'], + [routes, '/projects/:projectId/repo-builder', 'Repo Builder route missing.'], + [navigation, 'label: "Repository"', 'Repository navigation item missing.'], + [navigation, '`${base}/repo-builder`', 'Local repository navigation must route to Repo Builder.'], + [flowRail, 'Repo Builder', 'Repo Builder flow rail step missing.'], + [flowRail, 'shape the repo', 'Flow rail must place Repo Builder logically before delivery.'], + [context, 'generateRepoBlueprint', 'Project context must expose generateRepoBlueprint.'], + [context, 'archiveRepoBlueprint', 'Project context must expose archiveRepoBlueprint.'], + [context, 'generateRepoExpansionBrief', 'Project context must expose generateRepoExpansionBrief.'], + [context, 'archiveRepoExpansionBrief', 'Project context must expose archiveRepoExpansionBrief.'], + [projectSearch, 'repoExpansionBriefItems', 'Search index must include repo expansion briefs.'], + [model, 'export interface RepoBlueprint', 'RepoBlueprint model missing.'], + [model, 'export interface RepoExpansionBrief', 'RepoExpansionBrief model missing.'], + [model, 'repoBlueprints?: RepoBlueprint[]', 'Project model must persist repo blueprints.'], + [model, 'repoExpansionBriefs?: RepoExpansionBrief[]', 'Project model must persist repo expansion briefs.'], + [safeData, 'repoBlueprints: asArray(project.repoBlueprints)', 'Runtime normalization must preserve repo blueprints.'], + [safeData, 'repoExpansionBriefs: asArray(project.repoExpansionBriefs)', 'Runtime normalization must preserve repo expansion briefs.'], + [history, 'Repo Builder / foundation', 'History must surface foundation repo blueprint entries.'], + [history, 'repoExpansionEntries', 'History must surface repo expansion brief entries.'], + [read('src/shared/lib/buildLoop.ts'), 'getBuildLoopStatus', 'Repository shaping must retain technical build-loop guidance.'], + [operatingStep, 'Nieuwe opdracht geven', 'Daily guidance must allow direct assignments.'], + [operatingStep, 'activeWork', 'Daily guidance must account for active assignments.'], + [operatingStep, 'attentionCount', 'Daily guidance must account for explicit human attention.'], + [repoPage, 'Repository-deliverypakket', 'Repo Builder must expose delivery packages as the final repo handoff layer.'], + [repoPage, 'Deliverypakket genereren', 'Repo Builder must generate Codex-ready delivery packages.'], + [repoPage, 'File manifest', 'Repo delivery packages must show a file manifest.'], + [repoPage, 'Validatieplan', 'Repo delivery packages must show a validation plan.'], + [context, 'generateRepoDeliveryPackage', 'Project context must expose generateRepoDeliveryPackage.'], + [context, 'archiveRepoDeliveryPackage', 'Project context must expose archiveRepoDeliveryPackage.'], + [projectSearch, 'repoDeliveryPackageItems', 'Search index must include repo delivery packages.'], + [model, 'export interface RepoDeliveryPackage', 'RepoDeliveryPackage model missing.'], + [model, 'repoDeliveryPackages?: RepoDeliveryPackage[]', 'Project model must persist repo delivery packages.'], + [safeData, 'repoDeliveryPackages: asArray(project.repoDeliveryPackages)', 'Runtime normalization must preserve repo delivery packages.'], + [history, 'repoDeliveryPackageEntries', 'History must surface repo delivery package entries.'], + [repoPage, 'Repositorywerkplan', 'Repo Builder must expose repo work plans as the sequencing layer.'], + [repoPage, 'Werkplan genereren', 'Repo Builder must generate sequenced repo work plans.'], + [repoPage, 'Aanbevolen volgende handoff', 'Repo work plans must show the next recommended handoff.'], + [context, 'generateRepoWorkPlan', 'Project context must expose generateRepoWorkPlan.'], + [context, 'archiveRepoWorkPlan', 'Project context must expose archiveRepoWorkPlan.'], + [projectSearch, 'repoWorkPlanItems', 'Search index must include repo work plans.'], + [model, 'export interface RepoWorkPlan', 'RepoWorkPlan model missing.'], + [model, 'repoWorkPlans?: RepoWorkPlan[]', 'Project model must persist repo work plans.'], + [safeData, 'repoWorkPlans: asArray(project.repoWorkPlans)', 'Runtime normalization must preserve repo work plans.'], + [history, 'repoWorkPlanEntries', 'History must surface repo work plan entries.'], + [repoPage, 'Repositorystatus', 'Repo Builder must expose repo state snapshots as the repository inventory layer.'], + [repoPage, 'Repositorystatus vernieuwen', 'Repo Builder must let users refresh the repo state snapshot.'], + [repoPage, 'Een afgeleide inventaris van bekende en geplande bestanden, geraakte gebieden, hiaten', 'Repo state snapshot must explain inventory, planned files, touched areas and gaps.'], + [context, 'generateRepoStateSnapshot', 'Project context must expose generateRepoStateSnapshot.'], + [context, 'archiveRepoStateSnapshot', 'Project context must expose archiveRepoStateSnapshot.'], + [projectSearch, 'repoStateSnapshotItems', 'Search index must include repo state snapshots.'], + [model, 'export interface RepoStateSnapshot', 'RepoStateSnapshot model missing.'], + [model, 'repoStateSnapshots?: RepoStateSnapshot[]', 'Project model must persist repo state snapshots.'], + [safeData, 'repoStateSnapshots: asArray(project.repoStateSnapshots)', 'Runtime normalization must preserve repo state snapshots.'], + [history, 'repoStateSnapshotEntries', 'History must surface repo state snapshot entries.'], + [repoPage, 'Skill set', 'Repo Builder must expose active project skills.'], + [repoPage, 'formatSkillsForCodexHandoff', 'Repo Builder exports must include selected skill blocks.'], + [context, 'skillIds', 'Repo generation must persist linked skill ids.'], + [model, 'skillIds?: ID[]', 'Repo artifacts must support linked skill ids.'], + [skills, 'getSkillGapsForRepoState', 'Repo state snapshots must be able to derive skill gaps.'], + [repoPage, 'Bestaande repository inventariseren', 'Repo Builder must expose existing repository intake.'], + [repoPage, 'ProjectBrain gebruikt dit als geïmporteerde informatie, niet als bewijs van de live-bestanden.', 'Repo Builder must separate imported repository evidence from live truth.'], + [repoPage, 'Uitbreidingsbrief maken', 'Repo Builder must create expansion briefs from repository gaps.'], + [repoPage, 'Deliverypakket maken', 'Repo Builder must create delivery packages from repository gaps.'], + [repositoryIntake, 'deriveRepositoryGaps', 'Repository intake helper must derive repository gaps.'], + [repositoryIntake, 'mapRepositoryEntriesToSkills', 'Repository intake helper must map imported files to Codex skills.'], + [projectSearch, 'repositoryIntakeItems', 'Search index must include repository intakes.'], + [context, 'linkedRepositoryIntakeIds', 'Repo artifacts must keep repository intake references.'], + [model, 'export interface RepositoryIntake', 'RepositoryIntake model missing.'], + [model, 'repositoryIntakes?: RepositoryIntake[]', 'Project model must persist repository intakes.'], + [safeData, 'repositoryIntakes: asArray(project.repositoryIntakes)', 'Runtime normalization must preserve repository intakes.'], + [history, 'repoIntakeEntries', 'History must surface repository intake entries.'], + [pkg, 'audit:skills', 'package.json must expose audit:skills.'], + [pkg, 'audit:repository-intake', 'package.json must expose audit:repository-intake.'], + [repoPage, 'Codex-handoff maken', 'Repo Builder must create Codex handoffs from repo artifacts.'], + [repoPage, 'Handoff vanuit werkplan maken', 'Repo Builder must create Codex handoffs from work plans.'], + [repoPage, 'Handoff vanuit status maken', 'Repo Builder must create Codex handoffs from repo state snapshots.'], + [repoPage, 'Controle van handoffs', 'Repo Builder must show handoff review status.'], + [repoPage, 'impactcontroles', 'Repo Builder must show impact review count.'], + [repoPage, 'feedbackitems', 'Repo Builder must show execution feedback count.'], + [repoPage, 'Naar de buildloop', 'Repo Builder must link to Build Loop.'], + [context, 'generateCodexHandoff', 'Project context must expose generateCodexHandoff.'], + [context, 'generateHandoffReview', 'Project context must expose generateHandoffReview.'], + [context, 'saveCodexExecutionFeedback', 'Project context must expose saveCodexExecutionFeedback.'], + [model, 'export interface CodexHandoff', 'CodexHandoff model missing.'], + [model, 'export interface HandoffReview', 'HandoffReview model missing.'], + [model, 'export interface CodexExecutionFeedback', 'CodexExecutionFeedback model missing.'], + [safeData, 'codexHandoffs: asArray(project.codexHandoffs)', 'Runtime normalization must preserve Codex handoffs.'], + [safeData, 'handoffReviews: asArray(project.handoffReviews)', 'Runtime normalization must preserve handoff reviews.'], + [safeData, 'codexExecutionFeedback: asArray(project.codexExecutionFeedback)', 'Runtime normalization must preserve execution feedback.'], + [pkg, 'audit:handoffs', 'package.json must expose audit:handoffs.'], + [pkg, 'audit:handoff-review', 'package.json must expose audit:handoff-review.'], + [pkg, 'audit:execution-feedback', 'package.json must expose audit:execution-feedback.'], + [pkg, 'audit:build-loop', 'package.json must expose audit:build-loop.'], + [pkg, 'audit:repo-builder', 'package.json must expose audit:repo-builder.'], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (!repoPage.includes('Open reviewitems blijven zichtbaar') || !repoPage.includes('alleen goedgekeurde invoer mag de uitvoering sturen') || !repoPage.includes('uiteindelijke overdrachtsgrens')) { + failures.push('Repo Builder must communicate review-gated behavior, accepted input boundaries and the final delivery-package handoff boundary.'); +} + +if (failures.length) { + console.error('ProjectBrain repo builder readiness: FAIL'); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log('ProjectBrain repo builder readiness: PASS'); diff --git a/scripts/check-repository-intake-readiness.mjs b/scripts/check-repository-intake-readiness.mjs new file mode 100644 index 0000000..bba12ba --- /dev/null +++ b/scripts/check-repository-intake-readiness.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/model.ts", + "src/shared/lib/repositoryIntake.ts", + "src/shared/lib/safeData.ts", + "src/shared/lib/projectStartFlow.ts", + "src/shared/lib/projectWizard.ts", + "src/shared/contexts/ProjectContext.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/history/pages/ProjectHistory.tsx", + "src/features/audit/pages/ProjectAuditCenter.tsx", + "src/features/projects/pages/Workspace.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); + +const model = text["src/shared/lib/model.ts"] ?? ""; +const helper = text["src/shared/lib/repositoryIntake.ts"] ?? ""; +const safeData = text["src/shared/lib/safeData.ts"] ?? ""; +const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? ""; +const wizard = text["src/shared/lib/projectWizard.ts"] ?? ""; +const context = text["src/shared/contexts/ProjectContext.tsx"] ?? ""; +const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? ""; +const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [model, "export interface RepositoryIntake", "RepositoryIntake model missing."], + [model, "export interface RepositoryTreeEntry", "RepositoryTreeEntry model missing."], + [model, "export interface RepositoryGap", "RepositoryGap model missing."], + [model, "RepositorySkillMapping", "RepositorySkillMapping model missing."], + [model, "RepositoryImportMode", "RepositoryImportMode model missing."], + [model, "repositoryIntakes?: RepositoryIntake[]", "Project model must persist repository intakes."], + [model, "linkedRepositoryIntakeIds?: ID[]", "Repo artifacts must preserve linked repository intake ids."], + [safeData, "repositoryIntakes: asArray(project.repositoryIntakes)", "Runtime normalization must preserve repository intakes."], + [helper, "parseRepositoryTreeInput", "Repository intake parser helper missing."], + [helper, "classifyRepositoryEntry", "Repository entry classifier missing."], + [helper, "detectRepositoryStack", "Repository stack detector missing."], + [helper, "detectPackageManagers", "Package manager detector missing."], + [helper, "detectFrameworks", "Framework detector missing."], + [helper, "mapRepositoryEntriesToSkills", "Repository-to-skill mapping helper missing."], + [helper, "deriveRepositoryGaps", "Repository gap derivation helper missing."], + [helper, "formatRepositoryIntakeMarkdown", "Repository intake export formatter missing."], + [helper, "recommendRepoBuilderActionsFromIntake", "Repository intake recommendation helper missing."], + [helper, "frontend-react-vite-build", "Skill mapping must cover React/Vite evidence."], + [helper, "docker-runtime-hardening", "Skill mapping must cover Docker evidence."], + [helper, "database-migration", "Skill mapping must cover database evidence."], + [helper, "documentation-system-build", "Skill mapping must cover documentation evidence."], + [helper, "qa-qc-review", "Skill mapping must cover test evidence."], + [helper, "Imported repository evidence is not live filesystem truth", "Intake export must label imported evidence as non-live truth."], + [repoBuilder, "Existing repository intake", "Repo Builder must expose Existing repository intake section."], + [repoBuilder, "Paste a file tree, package manifest, repo summary or current-state notes", "Repo Builder must explain repository intake source types."], + [repoBuilder, "will not treat it as live disk truth", "Repo Builder must communicate the intake trust boundary."], + [repoBuilder, "saveRepositoryIntake", "Repo Builder must save repository intakes."], + [repoBuilder, "archiveRepositoryIntake", "Repo Builder must archive repository intakes."], + [repoBuilder, "formatRepositoryIntakeMarkdown", "Repo Builder must copy/download intake markdown."], + [repoBuilder, "Create expansion brief", "Repository gaps must create expansion briefs."], + [repoBuilder, "Create delivery package", "Repository gaps must create delivery packages."], + [repoBuilder, "Create Codex handoff", "Repository gaps must create Codex handoffs."], + [context, "generateCodexHandoff", "ProjectContext must generate Codex handoffs from repository evidence."], + [context, "generateHandoffReview", "ProjectContext must generate impact reviews for handoffs from repository evidence."], + [context, "repositoryIntakeItems", "Project search index must include repository intakes."], + [context, "saveRepositoryIntake", "ProjectContext must expose saveRepositoryIntake."], + [context, "archiveRepositoryIntake", "ProjectContext must expose archiveRepositoryIntake."], + [context, "createRepoExpansionFromGap", "ProjectContext must create expansion briefs from repository gaps."], + [context, "createRepoDeliveryPackageFromGap", "ProjectContext must create delivery packages from repository gaps."], + [context, "deriveRepositoryGaps", "Repo state snapshots must derive repository gaps from intakes."], + [context, "imported files", "Repo state snapshots must include imported file evidence."], + [context, "not live disk truth", "Generated handoffs must keep imported evidence separate from live truth."], + [history, "repoIntakeEntries", "Project History must include repository intake records."], + [history, "Repo Builder / repository intake", "History entries must label repository intake source."], + [audit, "Repository intake coverage", "Audit Center must show repository intake coverage."], + [workspace, "repositoryIntakeCount", "Workspace must count repository intakes."], + [workspace, "repositoryGapCount", "Workspace must count repository gaps."], + [startFlow, "import-repository-state", "Unified start next-actions must include repository intake for existing repo projects."], + [startFlow, "Generate repo state snapshot", "Unified start next-actions must lead from intake to repo state snapshot."], + [wizard, "Import repository state in Repo Builder", "First-run wizard recommendations must mention repository intake for existing repos."], + [flowRail, "Import repo evidence, derive repo state, resolve gaps", "Flow rail must describe repository intake/state/gap/delivery flow."], + [pkg, "audit:repository-intake", "package.json must expose audit:repository-intake."], + [pkg, "check-repository-intake-readiness.mjs", "package.json must wire repository intake readiness script."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (!pkg.includes("audit:repository-intake") || !pkg.includes("audit:all")) { + failures.push("audit:all must include the repository intake readiness check."); +} + +if (failures.length) { + console.error("ProjectBrain repository intake readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain repository intake readiness: PASS"); diff --git a/scripts/check-review-closure-readiness.mjs b/scripts/check-review-closure-readiness.mjs new file mode 100644 index 0000000..f7388d0 --- /dev/null +++ b/scripts/check-review-closure-readiness.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const failures = []; + +const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); +const exists = (file) => fs.existsSync(path.join(root, file)); +const requireFile = (file) => { + if (!exists(file)) failures.push(`Missing required file: ${file}`); +}; +const requireIncludes = (file, needle, message) => { + if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${message}`); +}; + +[ + "backend/src/routes/runtimePersistence.ts", + "backend/src/repositories/runtimeRepository.ts", + "backend/src/routes/p7Runtime.ts", + "src/shared/api/realClient.ts", + "src/shared/hooks/useProjectData.ts", + "src/features/operations/pages/ReviewQueue.tsx", + "package.json", +].forEach(requireFile); + +requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/review-queue/:reviewItemId/materialize-truth", "Review materialization endpoint must exist."); +requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/accepted-truth/:truthItemId/project-updates", "Project update creation endpoint must exist."); +requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/project-updates/:projectUpdateId/apply", "Project update apply endpoint must exist."); +requireIncludes("backend/src/routes/p7Runtime.ts", "/api/projects/:projectId/current-state/generate", "Current state generation endpoint must exist."); +requireIncludes("backend/src/repositories/runtimeRepository.ts", "materializeReviewItemAsTruthInDatabase", "Repository must materialize review items as accepted truth."); +requireIncludes("backend/src/repositories/runtimeRepository.ts", "createProjectUpdateFromTruthInDatabase", "Repository must prepare project updates from accepted truth."); +requireIncludes("backend/src/repositories/runtimeRepository.ts", "applyProjectUpdateInDatabase", "Repository must apply project updates."); +requireIncludes("src/shared/api/realClient.ts", "materializeReviewItemAsTruth", "Real client must expose materialize review as truth."); +requireIncludes("src/shared/api/realClient.ts", "createProjectUpdateFromTruth", "Real client must expose project update creation."); +requireIncludes("src/shared/api/realClient.ts", "applyProjectUpdate", "Real client must expose project update apply."); +requireIncludes("src/shared/api/realClient.ts", "generateCurrentStateReport", "Real client must expose current state generation."); +requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectAcceptedTruthData", "Async hooks must expose accepted truth."); +requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectProjectUpdatesData", "Async hooks must expose project updates."); +requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectCurrentStateReportsData", "Async hooks must expose current state reports."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Live review closure", "Review Queue must expose live closure bridge."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Accept + close selected review", "Review Queue must provide explicit closure action."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "closeSelectedReviewIntoState", "Review Queue must run the closure workflow."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.materializeReviewItemAsTruth", "Closure workflow must materialize accepted truth."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.createProjectUpdateFromTruth", "Closure workflow must prepare project updates."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.applyProjectUpdate", "Closure workflow must apply project updates."); +requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimeV7.generateCurrentStateReport", "Closure workflow must regenerate current state."); +requireIncludes("package.json", "audit:review-closure", "package.json must expose audit:review-closure."); +requireIncludes("package.json", "check-review-closure-readiness.mjs", "package.json must wire the review closure readiness script."); + +const scripts = JSON.parse(read("package.json")).scripts ?? {}; +const experienceAudit = scripts["audit:experience"] ?? ""; +const allAudit = scripts["audit:all"] ?? ""; +const reviewClosureIndex = experienceAudit.indexOf("audit:review-closure"); +const buildLoopIndex = experienceAudit.indexOf("audit:build-loop"); +if (!allAudit.includes("audit:experience") || reviewClosureIndex < 0 || buildLoopIndex < 0 || reviewClosureIndex > buildLoopIndex) { + failures.push("package.json: audit:all must run the experience gate with review closure before build-loop readiness."); +} + +if (failures.length) { + console.error("ProjectBrain review closure readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain review closure readiness: PASS"); diff --git a/scripts/check-runtime-readiness.mjs b/scripts/check-runtime-readiness.mjs new file mode 100644 index 0000000..d1c5998 --- /dev/null +++ b/scripts/check-runtime-readiness.mjs @@ -0,0 +1,440 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const failures = []; +const warnings = []; + +const requiredFiles = [ + 'src/features/projects/pages/RuntimeReadiness.tsx', + 'src/shared/hooks/useAsyncResource.ts', + 'src/shared/hooks/useProjectData.ts', + 'src/shared/api/realClient.ts', + 'docs/RUNTIME_ROUND_1_REAL_MODE_PILOT.md', + 'docs/RUNTIME_ROUND_2_REAL_READ_PILOT.md', + 'docs/RUNTIME_ROUND_3_REAL_READ_PILOT.md', + 'backend/scripts/smoke-real-read-pilot.mjs', + 'backend/scripts/smoke-vertical-slice.mjs', + 'docs/RUNTIME_ROUND_4_VERTICAL_SLICE_SMOKE.md', + 'docs/P2_LOCAL_RUNTIME_VERIFICATION.md', + 'docs/RUNTIME_ROUND_5_BACKEND_HEALTH_AND_SUITE.md', + 'backend/scripts/smoke-runtime-suite.mjs', + 'src/features/operations/lib/operationRuntime.ts', + 'src/features/operations/pages/RuntimeStateManager.tsx', + 'docs/RUNTIME_ROUND_7_SHARED_LOCAL_OPERATING_STATE.md', + 'docs/RUNTIME_ROUND_8_LOCAL_RUNTIME_PORTABILITY.md', + 'docs/RUNTIME_ROUND_10_LOCAL_TRUTH_LEDGER.md', + 'src/features/operations/pages/RuntimeTruthLedger.tsx', + 'src/features/operations/pages/RuntimeHandoffBuilder.tsx', + 'docs/RUNTIME_ROUND_11_HANDOFF_BUNDLES.md', + 'src/features/operations/pages/RuntimeFlowProgress.tsx', + 'docs/RUNTIME_ROUND_12_FLOW_PROGRESS_AND_CODEX_HANDOFF.md', + 'docs/RUNTIME_ROUND_13_GUIDED_VERTICAL_SLICE_RUNNER.md', + 'src/features/operations/pages/RuntimeMissionControl.tsx', + 'docs/RUNTIME_ROUND_14_MISSION_CONTROL.md', + 'src/features/operations/pages/RuntimeTraceInspector.tsx', + 'docs/RUNTIME_ROUND_15_TRACE_INSPECTOR.md', + 'src/features/operations/pages/RuntimeTodoBoard.tsx', + 'docs/RUNTIME_ROUND_16_TODO_BOARD.md', + 'src/features/operations/pages/RuntimeDeliveryPlan.tsx', + 'docs/RUNTIME_ROUND_17_DELIVERY_PLAN.md', + 'src/features/operations/pages/RuntimeExecutionQueue.tsx', + 'docs/RUNTIME_ROUND_18_EXECUTION_QUEUE.md', + 'src/features/operations/pages/RuntimeGateChecklist.tsx', + 'src/features/operations/pages/RuntimeGateEvidence.tsx', + 'docs/RUNTIME_ROUND_19_P2_GATE_CHECKLIST.md', + 'docs/RUNTIME_ROUND_20_P2_SMOKE_EVIDENCE.md', + 'src/features/operations/pages/RuntimeP3ReadinessAdvisor.tsx', + 'docs/RUNTIME_ROUND_21_P3_READINESS_ADVISOR.md', + 'src/features/operations/pages/RuntimeP3ImplementationPlanner.tsx', + 'docs/RUNTIME_ROUND_22_P3_IMPLEMENTATION_PLANNER.md', + 'src/features/operations/pages/RuntimeWorkSessionPanel.tsx', + 'docs/RUNTIME_ROUND_23_WORK_SESSIONS.md', + 'docs/RUNTIME_ROUND_24_REAL_MODE_FEATURE_DETAIL_PILOT.md', + 'docs/STABILIZATION_PASS_V37.md', + 'src/features/operations/pages/RuntimeContextPromptPilot.tsx', + 'docs/RUNTIME_ROUND_25_CONTEXT_PROMPT_REAL_PILOT.md', + 'docs/STABILIZATION_PASS_V38.md', + 'src/features/operations/pages/RuntimeProjectUpdates.tsx', + 'docs/RUNTIME_ROUND_26_PROJECT_UPDATE_MATERIALIZATION.md', + 'docs/STABILIZATION_PASS_V39.md', + 'src/features/operations/pages/RuntimeFeatureOverlay.tsx', + 'docs/RUNTIME_ROUND_27_FEATURE_MAP_OVERLAY.md', + 'docs/STABILIZATION_PASS_V40.md', + 'docs/RUNTIME_ROUND_28_CAPTURE_REVIEW_REAL_PILOT.md', + 'docs/STABILIZATION_PASS_V43.md', + 'docs/PERSISTENCE_P6_TRUTH_UPDATES_OPERATING_LOG.md', + 'docs/STABILIZATION_PASS_V44_P6_TRUTH_UPDATES_LOG.md', + 'backend/scripts/smoke-p6.mjs', + 'src/features/operations/pages/RuntimePersistencePilot.tsx', + 'docs/RUNTIME_ROUND_29_P6_FRONTEND_PERSISTENCE_PILOT.md', + 'docs/STABILIZATION_PASS_V45.md', + 'src/features/operations/pages/RuntimeDeliveryPersistencePilot.tsx', + 'docs/PERSISTENCE_P7_CURRENT_STATE_HANDOFF_CODEX.md', + 'docs/RUNTIME_ROUND_30_P7_DELIVERY_PERSISTENCE_PILOT.md', + 'docs/STABILIZATION_PASS_V46_P7_DELIVERY_PERSISTENCE.md', + 'backend/scripts/smoke-p7.mjs', + 'backend/scripts/smoke-p8.mjs', + 'src/features/operations/pages/RuntimeKnowledgePersistencePilot.tsx', + 'docs/PERSISTENCE_P8_DOCUMENTATION_KNOWLEDGE_MEMORY.md', + 'docs/RUNTIME_ROUND_31_P8_KNOWLEDGE_PERSISTENCE_PILOT.md', + 'docs/STABILIZATION_PASS_V47_P8_KNOWLEDGE_MEMORY_BRANDING.md', +]; + +for (const file of requiredFiles) { + if (!existsSync(join(root, file))) failures.push(`Missing runtime-readiness file: ${file}`); +} + +const routeFile = join(root, 'src/app/routes.tsx'); +if (existsSync(routeFile)) { + const routes = readFileSync(routeFile, 'utf8'); + if (!routes.includes('/projects/:projectId/runtime')) failures.push('Runtime readiness route is not registered.'); +} else { + failures.push('Missing src/app/routes.tsx'); +} + +const runtimeReadinessFile = join(root, 'src/features/projects/pages/RuntimeReadiness.tsx'); +if (existsSync(runtimeReadinessFile)) { + const runtimeReadiness = readFileSync(runtimeReadinessFile, 'utf8'); + for (const marker of ['Tower deployment runbook', 'Deployment update path', 'git pull --ff-only', 'scripts/deploy-unraid-single.sh', 'Configurable public URL']) { + if (!runtimeReadiness.includes(marker)) warnings.push(`RuntimeReadiness missing deployment runbook marker: ${marker}`); + } +} + +const sidebarFile = join(root, 'src/components/layout/Sidebar.tsx'); +if (existsSync(sidebarFile)) { + const sidebar = readFileSync(sidebarFile, 'utf8'); + const navigationFile = join(root, 'src/shared/lib/navigation.tsx'); + const navigation = existsSync(navigationFile) ? readFileSync(navigationFile, 'utf8') : ''; + if (!sidebar.includes('/runtime') && !navigation.includes('/runtime')) warnings.push('Navigation does not expose Runtime Readiness.'); +} else { + failures.push('Missing Sidebar.tsx'); +} + +const realClientFile = join(root, 'src/shared/api/realClient.ts'); +if (existsSync(realClientFile)) { + const realClient = readFileSync(realClientFile, 'utf8'); + for (const marker of ['health: healthApi', 'unwrapPaginatedItems', 'unwrapWorkspaceState', 'mapBackendFeature', 'mapBackendEvidence', 'listFeatures', 'listEvidence', 'listEvidenceForFeature', 'listContextPacks', 'listChecks', 'listBuildRuns', 'listSnapshots', 'listKnowledge', 'listCaptureItems', 'createCaptureItem', 'classifyCaptureItem', 'sendCaptureToReview', 'listReviewItems', 'acceptReviewItem', 'convertReviewItem', 'listActions', 'runtimePersistence', 'listAcceptedTruth', 'materializeReviewItemAsTruth', 'listProjectUpdates', 'createProjectUpdateFromTruth', 'applyProjectUpdate', 'listOperatingLog', 'createOperatingLogEntry', 'runtimeV7', 'listCurrentStateReports', 'generateCurrentStateReport', 'listHandoffBundles', 'createHandoffBundle', 'listCodexTasks', 'createCodexTaskFromHandoff', 'importCodexOutput', 'createKnowledge', 'updateKnowledge', 'createMemoryRule', 'updateMemoryRule', 'listDocumentationItems', 'createDocumentationItem', 'updateDocumentationItem']) { + if (!realClient.includes(marker)) warnings.push(`realClient missing expected marker: ${marker}`); + } +} + + +const projectDataHooksFile = join(root, 'src/shared/hooks/useProjectData.ts'); +if (existsSync(projectDataHooksFile)) { + const hooks = readFileSync(projectDataHooksFile, 'utf8'); + for (const marker of ['useBackendHealthData', 'useProjectFeatureGroupsData', 'useProjectFeaturesData', 'useProjectFeatureDetailData', 'useProjectEvidenceData', 'useFeatureEvidenceData', 'useProjectContextPacksData', 'useProjectChecksData', 'useProjectBuildRunsData', 'useProjectSnapshotsData', 'useProjectKnowledgeData', 'useProjectDocumentationItemsData', 'useProjectCaptureItemsData', 'useProjectReviewQueueData', 'useProjectAcceptedTruthData', 'useProjectProjectUpdatesData', 'useProjectOperatingLogData', 'useProjectCurrentStateReportsData', 'useProjectHandoffBundlesData', 'useProjectCodexTasksData', 'useProjectAIActionsData']) { + if (!hooks.includes(marker)) warnings.push(`useProjectData missing expected runtime hook: ${marker}`); + } +} else { + failures.push('Missing src/shared/hooks/useProjectData.ts'); +} + + +const featureMapFile = join(root, 'src/features/features/pages/FeatureMap.tsx'); +if (existsSync(featureMapFile)) { + const featureMap = readFileSync(featureMapFile, 'utf8'); + for (const marker of ['Feature data source', 'useProjectFeatureGroupsData', 'Reload features', 'RuntimeFeatureOverlay']) { + if (!featureMap.includes(marker)) warnings.push(`FeatureMap missing real-mode pilot marker: ${marker}`); + } +} + +const featureDetailFile = join(root, 'src/features/features/pages/FeatureDetail.tsx'); +if (existsSync(featureDetailFile)) { + const featureDetail = readFileSync(featureDetailFile, 'utf8'); + for (const marker of ['Feature detail data source', 'useProjectFeatureDetailData', 'useFeatureEvidenceData', 'Reload detail']) { + if (!featureDetail.includes(marker)) warnings.push(`FeatureDetail missing real-mode pilot marker: ${marker}`); + } +} + +const operationRuntimeFile = join(root, 'src/features/operations/lib/operationRuntime.ts'); +if (existsSync(operationRuntimeFile)) { + const runtime = readFileSync(operationRuntimeFile, 'utf8'); + for (const marker of ['exportRuntime', 'exportRuntimeMarkdown', 'importRuntime', 'sendCaptureToReview', 'truthItems', 'RuntimeTruthItem', 'RuntimeHandoffBundle', 'createHandoffBundle', 'handoffBundles', 'runGuidedVerticalSlice', 'runtimeTraceRows', 'exportRuntimeTraceMarkdown', 'RuntimeTodoItem', 'todoItems', 'addTodo', 'toggleTodo', 'createTodosFromPendingReviews', 'exportTodoMarkdown', 'RuntimeDeliveryGate', 'runtimeDeliveryGates', 'createTodosFromTraceGaps', 'createTodosFromDeliveryGaps', 'completeDeliveryGate', 'exportDeliveryPlanMarkdown', 'RuntimeWorkSession', 'workSessions', 'startWorkSession', 'completeWorkSession', 'exportWorkSessionMarkdown', 'RuntimeProjectUpdate', 'projectUpdates', 'materializeAcceptedTruth', 'applyProjectUpdate', 'exportProjectUpdatesMarkdown']) { + if (!runtime.includes(marker)) warnings.push(`operationRuntime missing expected portability marker: ${marker}`); + } +} + +const truthLedgerFile = join(root, 'src/features/operations/pages/RuntimeTruthLedger.tsx'); +if (existsSync(truthLedgerFile)) { + const ledger = readFileSync(truthLedgerFile, 'utf8'); + for (const marker of ['Export truth markdown', 'Create context handoff', 'Create Codex handoff']) { + if (!ledger.includes(marker)) warnings.push(`RuntimeTruthLedger missing expected control: ${marker}`); + } +} + +const codexPreviewFile = join(root, 'src/features/operations/pages/CodexTaskPreview.tsx'); +if (existsSync(codexPreviewFile)) { + const codex = readFileSync(codexPreviewFile, 'utf8'); + for (const marker of ['Create runtime handoff', 'Runtime handoff attached', 'Runtime Handoff Context']) { + if (!codex.includes(marker)) warnings.push(`CodexTaskPreview missing expected runtime-handoff marker: ${marker}`); + } +} + +const runtimeStateManagerFile = join(root, 'src/features/operations/pages/RuntimeStateManager.tsx'); +if (existsSync(runtimeStateManagerFile)) { + const manager = readFileSync(runtimeStateManagerFile, 'utf8'); + for (const marker of ['Export JSON', 'Export Markdown', 'Import JSON', 'Reset local runtime', 'Handoffs']) { + if (!manager.includes(marker)) warnings.push(`RuntimeStateManager missing expected control: ${marker}`); + } +} + + + + +const missionControlFile = join(root, 'src/features/operations/pages/RuntimeMissionControl.tsx'); +if (existsSync(missionControlFile)) { + const mission = readFileSync(missionControlFile, 'utf8'); + for (const marker of ['Runtime Mission Control', 'Run full local flow', 'Create Codex handoff', 'Copy mission markdown', 'Import Codex output preview']) { + if (!mission.includes(marker)) warnings.push(`RuntimeMissionControl missing expected marker: ${marker}`); + } +} + + +const traceInspectorFile = join(root, 'src/features/operations/pages/RuntimeTraceInspector.tsx'); +if (existsSync(traceInspectorFile)) { + const trace = readFileSync(traceInspectorFile, 'utf8'); + for (const marker of ['Runtime Trace Inspector', 'Seed trace example', 'Copy trace markdown', 'Create trace handoff']) { + if (!trace.includes(marker)) warnings.push(`RuntimeTraceInspector missing expected marker: ${marker}`); + } +} + + + +const todoBoardFile = join(root, 'src/features/operations/pages/RuntimeTodoBoard.tsx'); +if (existsSync(todoBoardFile)) { + const todoBoard = readFileSync(todoBoardFile, 'utf8'); + for (const marker of ['Runtime Todo Board', 'Create todos from reviews', 'Export todo markdown', 'Add manual todo']) { + if (!todoBoard.includes(marker)) warnings.push(`RuntimeTodoBoard missing expected marker: ${marker}`); + } +} + + + + +const executionQueueFile = join(root, 'src/features/operations/pages/RuntimeExecutionQueue.tsx'); +if (existsSync(executionQueueFile)) { + const execution = readFileSync(executionQueueFile, 'utf8'); + for (const marker of ['Runtime Execution Queue', 'Prepare execution burst', 'Mark top todo done', 'Export execution queue', 'Create execution handoff']) { + if (!execution.includes(marker)) warnings.push(`RuntimeExecutionQueue missing expected marker: ${marker}`); + } +} + +const deliveryPlanFile = join(root, 'src/features/operations/pages/RuntimeDeliveryPlan.tsx'); +if (existsSync(deliveryPlanFile)) { + const delivery = readFileSync(deliveryPlanFile, 'utf8'); + for (const marker of ['Runtime Delivery Plan', 'Create gate todos', 'Create trace-gap todos', 'Export delivery plan']) { + if (!delivery.includes(marker)) warnings.push(`RuntimeDeliveryPlan missing expected marker: ${marker}`); + } +} + +const flowProgressFile = join(root, 'src/features/operations/pages/RuntimeFlowProgress.tsx'); +if (existsSync(flowProgressFile)) { + const flow = readFileSync(flowProgressFile, 'utf8'); + for (const marker of ['Runtime Vertical Slice Progress', 'Create suggested handoff', 'Run guided local slice', 'Next best runtime step']) { + if (!flow.includes(marker)) warnings.push(`RuntimeFlowProgress missing expected marker: ${marker}`); + } +} + +const handoffBuilderFile = join(root, 'src/features/operations/pages/RuntimeHandoffBuilder.tsx'); +if (existsSync(handoffBuilderFile)) { + const builder = readFileSync(handoffBuilderFile, 'utf8'); + for (const marker of ['Create handoff bundle', 'Copy latest markdown', 'Send latest to Review Queue']) { + if (!builder.includes(marker)) warnings.push(`RuntimeHandoffBuilder missing expected control: ${marker}`); + } +} + + +const gateChecklistFile = join(root, 'src/features/operations/pages/RuntimeGateChecklist.tsx'); +if (existsSync(gateChecklistFile)) { + const gate = readFileSync(gateChecklistFile, 'utf8'); + for (const marker of ['P2 Database Gate Checklist', 'Copy commands', 'Create todos for incomplete steps', 'Mark gate complete']) { + if (!gate.includes(marker)) warnings.push(`RuntimeGateChecklist missing expected marker: ${marker}`); + } +} + + +const gateEvidenceFile = join(root, 'src/features/operations/pages/RuntimeGateEvidence.tsx'); +if (existsSync(gateEvidenceFile)) { + const evidence = readFileSync(gateEvidenceFile, 'utf8'); + for (const marker of ['P2 Smoke Evidence Recorder', 'Save evidence', 'Export evidence', 'Evidence history']) { + if (!evidence.includes(marker)) warnings.push(`RuntimeGateEvidence missing expected marker: ${marker}`); + } +} + + + +const p3AdvisorFile = join(root, 'src/features/operations/pages/RuntimeP3ReadinessAdvisor.tsx'); +if (existsSync(p3AdvisorFile)) { + const p3 = readFileSync(p3AdvisorFile, 'utf8'); + for (const marker of ['Feature/Evidence parity proof', 'Create parity proof todos', 'Create parity handoff', 'Export readiness', 'Mark P2 gate complete']) { + if (!p3.includes(marker)) warnings.push(`RuntimeP3ReadinessAdvisor missing expected marker: ${marker}`); + } +} + + + +const workSessionFile = join(root, 'src/features/operations/pages/RuntimeWorkSessionPanel.tsx'); +if (existsSync(workSessionFile)) { + const sessions = readFileSync(workSessionFile, 'utf8'); + for (const marker of ['Runtime Work Sessions', 'Start next work session', 'Complete active session', 'Export sessions']) { + if (!sessions.includes(marker)) warnings.push(`RuntimeWorkSessionPanel missing expected marker: ${marker}`); + } +} + + +const projectUpdatesFile = join(root, 'src/features/operations/pages/RuntimeProjectUpdates.tsx'); +if (existsSync(projectUpdatesFile)) { + const updates = readFileSync(projectUpdatesFile, 'utf8'); + for (const marker of ['Runtime Project Updates', 'Prepare from truth', 'Apply ready', 'Export updates']) { + if (!updates.includes(marker)) warnings.push(`RuntimeProjectUpdates missing expected marker: ${marker}`); + } +} + + + +const runtimePersistencePilotFile = join(root, 'src/features/operations/pages/RuntimePersistencePilot.tsx'); +if (existsSync(runtimePersistencePilotFile)) { + const pilot = readFileSync(runtimePersistencePilotFile, 'utf8'); + for (const marker of ['Runtime Persistence Pilot', 'Materialize truth', 'Prepare updates', 'Apply ready', 'Add log note', 'realClient.runtimePersistence']) { + if (!pilot.includes(marker)) warnings.push(`RuntimePersistencePilot missing expected marker: ${marker}`); + } +} else { + warnings.push('Missing RuntimePersistencePilot component.'); +} + + + +const runtimeDeliveryPersistencePilotFile = join(root, 'src/features/operations/pages/RuntimeDeliveryPersistencePilot.tsx'); +if (existsSync(runtimeDeliveryPersistencePilotFile)) { + const pilot = readFileSync(runtimeDeliveryPersistencePilotFile, 'utf8'); + for (const marker of ['Runtime Delivery Persistence Pilot', 'Generate Current State', 'Create Handoff', 'Create Codex Task', 'Import Codex Output', 'realClient.runtimeV7']) { + if (!pilot.includes(marker)) warnings.push(`RuntimeDeliveryPersistencePilot missing expected marker: ${marker}`); + } +} else { + warnings.push('Missing RuntimeDeliveryPersistencePilot component.'); +} + +const runtimeFeatureOverlayFile = join(root, 'src/features/operations/pages/RuntimeFeatureOverlay.tsx'); +if (existsSync(runtimeFeatureOverlayFile)) { + const overlay = readFileSync(runtimeFeatureOverlayFile, 'utf8'); + for (const marker of ['Runtime Feature Overlay', 'Prepare updates', 'Apply ready', 'Create handoff', 'Export']) { + if (!overlay.includes(marker)) warnings.push(`RuntimeFeatureOverlay missing expected marker: ${marker}`); + } +} + +const packageFile = join(root, 'package.json'); +if (existsSync(packageFile)) { + const pkg = readFileSync(packageFile, 'utf8'); + if (!pkg.includes('smoke:backend:real-read-pilot')) warnings.push('Root package is missing smoke:backend:real-read-pilot script.'); + if (!pkg.includes('smoke:backend:vertical-slice')) warnings.push('Root package is missing smoke:backend:vertical-slice script.'); + if (!pkg.includes('smoke:backend:runtime-suite')) warnings.push('Root package is missing smoke:backend:runtime-suite script.'); +} + +console.log('ProjectBrain runtime readiness check'); +console.log('===================================='); +if (warnings.length) { + console.log('\nWarnings:'); + for (const warning of warnings) console.log(`- ${warning}`); +} + +const p3ImplementationPlannerFile = join(root, 'src/features/operations/pages/RuntimeP3ImplementationPlanner.tsx'); +if (existsSync(p3ImplementationPlannerFile)) { + const planner = readFileSync(p3ImplementationPlannerFile, 'utf8'); + for (const marker of ['Feature/Evidence parity reference', 'Create parity todos', 'Create parity handoff', 'Export parity plan']) { + if (!planner.includes(marker)) warnings.push(`RuntimeP3ImplementationPlanner missing expected marker: ${marker}`); + } +} + + +const contextPromptPilotFile = join(root, 'src/features/operations/pages/RuntimeContextPromptPilot.tsx'); +if (existsSync(contextPromptPilotFile)) { + const pilot = readFileSync(contextPromptPilotFile, 'utf8'); + for (const marker of ['Runtime Context -> Prompt Pilot', 'Generate runtime prompt', 'realClient.prompts.generatePromptFromContextPack', 'useProjectContextPacksData']) { + if (!pilot.includes(marker)) warnings.push(`RuntimeContextPromptPilot missing expected marker: ${marker}`); + } +} + +const promptGeneratorFile = join(root, 'src/features/prompts/pages/PromptGenerator.tsx'); +if (existsSync(promptGeneratorFile)) { + const prompt = readFileSync(promptGeneratorFile, 'utf8'); + for (const marker of ['Prompt data source', 'realClient.prompts.generatePromptFromContextPack', 'useProjectContextPacksData']) { + if (!prompt.includes(marker)) warnings.push(`PromptGenerator missing real prompt marker: ${marker}`); + } +} + +const contextPacksPageFile = join(root, 'src/features/context-packs/pages/ContextPacks.tsx'); +if (existsSync(contextPacksPageFile)) { + const packs = readFileSync(contextPacksPageFile, 'utf8'); + for (const marker of ['Context pack data source', 'useProjectContextPacksData', 'Fastify R4 read endpoint']) { + if (!packs.includes(marker)) warnings.push(`ContextPacks missing real context-pack marker: ${marker}`); + } +} + + +const captureCenterFile = join(root, 'src/features/operations/pages/CaptureCenter.tsx'); +if (existsSync(captureCenterFile)) { + const captureCenter = readFileSync(captureCenterFile, 'utf8'); + for (const marker of ['Capture data source', 'Real API pilot', 'createCaptureItem', 'classifyCaptureItem', 'sendCaptureToReview']) { + if (!captureCenter.includes(marker)) warnings.push(`CaptureCenter missing real API pilot marker: ${marker}`); + } +} + +const reviewQueueFile = join(root, 'src/features/operations/pages/ReviewQueue.tsx'); +if (existsSync(reviewQueueFile)) { + const reviewQueue = readFileSync(reviewQueueFile, 'utf8'); + for (const marker of ['Review Queue data source', 'Real API pilot', 'acceptReviewItem', 'convertReviewItem', 'mergeReviewItem']) { + if (!reviewQueue.includes(marker)) warnings.push(`ReviewQueue missing real API pilot marker: ${marker}`); + } +} + + +const backendPackageFile = join(root, 'backend/package.json'); +if (existsSync(backendPackageFile)) { + const backendPackage = readFileSync(backendPackageFile, 'utf8'); + for (const marker of ['smoke:p6', 'smoke-p6.mjs', 'smoke:p7', 'smoke-p7.mjs']) { + if (!backendPackage.includes(marker)) warnings.push(`backend package missing P6 marker: ${marker}`); + } +} + +const runtimePersistenceRouteFile = join(root, 'backend/src/routes/runtimePersistence.ts'); +if (existsSync(runtimePersistenceRouteFile)) { + const runtimePersistence = readFileSync(runtimePersistenceRouteFile, 'utf8'); + for (const marker of ['accepted-truth', 'project-updates', 'operating-log', 'materialize-truth']) { + if (!runtimePersistence.includes(marker)) warnings.push(`runtimePersistence route missing P6 marker: ${marker}`); + } +} else { + warnings.push('Missing P6 runtimePersistence route file.'); +} + + + +const p7RuntimeRouteFile = join(root, 'backend/src/routes/p7Runtime.ts'); +if (existsSync(p7RuntimeRouteFile)) { + const p7Runtime = readFileSync(p7RuntimeRouteFile, 'utf8'); + for (const marker of ['current-state', 'handoff-bundles', 'codex-tasks', 'import-output']) { + if (!p7Runtime.includes(marker)) warnings.push(`p7Runtime route missing P7 marker: ${marker}`); + } +} else { + warnings.push('Missing P7 p7Runtime route file.'); +} + + +const knowledgePilotFile = join(root, 'src/features/operations/pages/RuntimeKnowledgePersistencePilot.tsx'); +if (existsSync(knowledgePilotFile)) { + const pilot = readFileSync(knowledgePilotFile, 'utf8'); + for (const marker of ['Runtime Knowledge Persistence Pilot', 'Persist documentation', 'Persist knowledge', 'Persist memory rule']) { + if (!pilot.includes(marker)) warnings.push(`RuntimeKnowledgePersistencePilot missing expected marker: ${marker}`); + } +} else { + failures.push('Missing RuntimeKnowledgePersistencePilot.tsx'); +} + +if (failures.length) { + console.log('\nFailures:'); + for (const failure of failures) console.log(`- ${failure}`); + process.exit(1); +} +console.log('\nPASS: runtime readiness scaffolding is present.'); diff --git a/scripts/check-session-pack-export-readiness.mjs b/scripts/check-session-pack-export-readiness.mjs new file mode 100644 index 0000000..257a520 --- /dev/null +++ b/scripts/check-session-pack-export-readiness.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/codexSessionPackExport.ts", + "src/shared/lib/downloads.ts", + "src/features/session-packs/pages/SessionPacks.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/build-loop/pages/BuildLoopDashboard.tsx", + "src/shared/lib/buildLoop.ts", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/shared/lib/codexExecutionFeedback.ts", + "src/features/prompts/pages/PromptGenerator.tsx", + "src/features/audit/pages/ProjectAuditCenter.tsx", + "src/shared/contexts/ProjectContext.tsx", + "pnpm-lock.yaml", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); + +const exportHelper = text["src/shared/lib/codexSessionPackExport.ts"] ?? ""; +const downloads = text["src/shared/lib/downloads.ts"] ?? ""; +const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? ""; +const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const buildLoopPage = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? ""; +const buildLoop = text["src/shared/lib/buildLoop.ts"] ?? ""; +const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const feedbackHelper = text["src/shared/lib/codexExecutionFeedback.ts"] ?? ""; +const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? ""; +const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? ""; +const context = text["src/shared/contexts/ProjectContext.tsx"] ?? ""; +const lockfile = text["pnpm-lock.yaml"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [exportHelper, "buildCodexSessionPackFiles", "Export helper must build bundle files."], + [exportHelper, "formatAllowedFilesManifest", "Export helper must format allowed files manifest."], + [exportHelper, "formatForbiddenChangesManifest", "Export helper must format forbidden changes manifest."], + [exportHelper, "formatValidationPlanMarkdown", "Export helper must format validation plan."], + [exportHelper, "formatRollbackPlanMarkdown", "Export helper must format rollback plan."], + [exportHelper, "formatFeedbackTemplateMarkdown", "Export helper must format feedback template."], + [exportHelper, "formatSessionPackReadme", "Export helper must format quick-start README."], + [exportHelper, "formatImportableFeedbackTemplate", "Export helper must format importable feedback template."], + [exportHelper, "downloadCodexSessionPackBundle", "Export helper must expose bundle download."], + [exportHelper, "buildZipEntriesFromSessionPack", "Export helper must expose ZIP entries helper."], + [exportHelper, "buildCodexSessionPackZipBlob", "Export helper must expose ZIP Blob builder."], + [exportHelper, "downloadCodexSessionPackZip", "Export helper must expose ZIP download."], + [exportHelper, "getCodexSessionPackZipFilename", "Export helper must expose ZIP filename helper."], + [exportHelper, "zipExport: true", "Bundle manifest must state ZIP export is available."], + [exportHelper, "fflate", "Export helper must use the selected browser-safe ZIP dependency."], + [exportHelper, "codex-session-pack.md", "Bundle must include markdown handoff file."], + [exportHelper, "codex-session-pack.json", "Bundle must include JSON file."], + [exportHelper, "allowed-files.txt", "Bundle must include allowed files manifest."], + [exportHelper, "forbidden-changes.md", "Bundle must include forbidden changes manifest."], + [exportHelper, "validation-plan.md", "Bundle must include validation plan."], + [exportHelper, "rollback-plan.md", "Bundle must include rollback plan."], + [exportHelper, "feedback-template.md", "Bundle must include feedback template."], + [exportHelper, "README_RUN_THIS_CODEX_SESSION.md", "Bundle must include quick-start README."], + [downloads, "downloadTextFile", "Shared download helper must expose downloadTextFile."], + [downloads, "downloadJsonFile", "Shared download helper must expose downloadJsonFile."], + [downloads, "downloadBlobFile", "Shared download helper must expose downloadBlobFile."], + [sessionPacks, "Bundle contents", "Session Packs page must show bundle contents."], + [sessionPacks, "Download instructions/scripts ZIP", "Session Packs page must expose clear instructions/scripts ZIP download action."], + [sessionPacks, "Export session pack files", "Session Packs page must expose honest session pack file export action."], + [sessionPacks, "Copy feedback template", "Session Packs page must copy feedback template."], + [sessionPacks, "Download feedback template", "Session Packs page must download feedback template."], + [sessionPacks, "Copy quick-start README", "Session Packs page must copy quick-start README."], + [sessionPacks, "ZIP export contains only", "Session Packs page must document ZIP contents."], + [sessionPacks, "Export session pack files", "Session Packs page must keep individual-file fallback."], + [handoffs, "Download ZIP", "Handoffs page must expose ZIP export when session pack exists."], + [handoffs, "Export session pack files", "Handoffs page must export session pack files."], + [handoffs, "controlled input for a Codex build session", "Handoffs page must explain session pack purpose."], + [buildLoopPage, "Download ZIP", "Build Loop page must expose latest session pack ZIP."], + [buildLoopPage, "Bundle readiness", "Build Loop page must show bundle readiness."], + [buildLoopPage, "Feedback template readiness", "Build Loop page must show feedback template readiness."], + [buildLoop, "Bundle readiness", "Build Loop markdown must include bundle readiness."], + [buildLoop, "Feedback template readiness", "Build Loop markdown must include feedback template readiness."], + [feedback, "Use this feedback template", "Execution Feedback must use session pack template."], + [feedback, "Download template", "Execution Feedback must download template."], + [feedback, "Insert template into report field", "Execution Feedback must insert template into report field."], + [feedbackHelper, "Validation output", "Feedback parser/formatter must recognize validation output heading."], + [feedbackHelper, "Final status", "Feedback parser/formatter must recognize final status heading."], + [promptBuilder, "Use session pack prompt", "Prompt Builder must use session pack prompt."], + [promptBuilder, "Copy feedback template", "Prompt Builder must copy feedback template."], + [audit, "Missing allowed/rollback", "Audit Center must show allowed/rollback bundle readiness."], + [context, "validation-plan.md", "Search index must include bundle files."], + [pkg, '"fflate"', "package.json must include selected ZIP dependency."], + [pkg, "audit:session-pack-export", "package.json must expose audit:session-pack-export."], + [pkg, "check-session-pack-export-readiness.mjs", "package.json must wire session pack export readiness script."], + [lockfile, "fflate", "pnpm lockfile must include selected ZIP dependency."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (sessionPacks.includes("Download Bundle / ZIP")) { + failures.push("Session Packs page must not imply a real ZIP is downloaded."); +} + +if (exportHelper.includes("zipExport: false") || exportHelper.includes("reasonZipIsNotIncluded")) { + failures.push("Bundle manifest must no longer document ZIP export as unavailable."); +} + +if (!pkg.includes("audit:session-pack-export") || !pkg.includes("audit:all")) { + failures.push("audit:all must include session pack export readiness."); +} + +if (failures.length) { + console.error("ProjectBrain session pack export readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain session pack export readiness: PASS"); diff --git a/scripts/check-session-pack-readiness.mjs b/scripts/check-session-pack-readiness.mjs new file mode 100644 index 0000000..fae3ff5 --- /dev/null +++ b/scripts/check-session-pack-readiness.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/model.ts", + "src/shared/lib/codexSessionPack.ts", + "src/shared/lib/safeData.ts", + "src/shared/contexts/ProjectContext.tsx", + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/features/session-packs/pages/SessionPacks.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/build-loop/pages/BuildLoopDashboard.tsx", + "src/shared/lib/buildLoop.ts", + "src/shared/lib/buildLoopWalkthrough.ts", + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/prompts/pages/PromptGenerator.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/features/history/pages/ProjectHistory.tsx", + "src/features/audit/pages/ProjectAuditCenter.tsx", + "src/features/projects/pages/Workspace.tsx", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); + +const model = text["src/shared/lib/model.ts"] ?? ""; +const helper = text["src/shared/lib/codexSessionPack.ts"] ?? ""; +const safeData = text["src/shared/lib/safeData.ts"] ?? ""; +const context = text["src/shared/contexts/ProjectContext.tsx"] ?? ""; +const routes = text["src/app/routes.tsx"] ?? ""; +const navigation = text["src/shared/lib/navigation.tsx"] ?? ""; +const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? ""; +const page = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? ""; +const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const buildLoopPage = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? ""; +const buildLoop = text["src/shared/lib/buildLoop.ts"] ?? ""; +const walkthrough = text["src/shared/lib/buildLoopWalkthrough.ts"] ?? ""; +const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? ""; +const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? ""; +const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? ""; +const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [model, "export interface CodexSessionPack", "CodexSessionPack type missing."], + [model, "export interface CodexSessionPackSection", "CodexSessionPackSection type missing."], + [model, "export interface CodexSessionPackFileReference", "CodexSessionPackFileReference type missing."], + [model, "export interface CodexSessionPackValidationPlan", "CodexSessionPackValidationPlan type missing."], + [model, "export interface CodexSessionPackFeedbackTemplate", "CodexSessionPackFeedbackTemplate type missing."], + [model, "export type CodexSessionPackExportFormat", "CodexSessionPackExportFormat type missing."], + [model, "codexSessionPacks?: CodexSessionPack[]", "Project model must persist session packs."], + [safeData, "codexSessionPacks: asArray(project.codexSessionPacks)", "Runtime normalization must preserve session packs."], + [helper, "generateCodexSessionPack", "Session pack helper must generate packs."], + [helper, "buildSessionPackContext", "Session pack helper must build context."], + [helper, "buildSessionPackValidationPlan", "Session pack helper must build validation plan."], + [helper, "buildSessionPackFeedbackTemplate", "Session pack helper must build feedback template."], + [helper, "formatCodexSessionPackMarkdown", "Session pack helper must format markdown."], + [helper, "formatCodexSessionPackJson", "Session pack helper must format JSON."], + [helper, "scoreCodexSessionPackQuality", "Session pack helper must score quality."], + [helper, "getCodexSessionPackWarnings", "Session pack helper must produce warnings."], + [helper, "generated from context pack", "Session pack export must keep context-pack provenance language."], + [context, "generateCodexSessionPack", "ProjectContext must expose session pack generation."], + [context, "archiveCodexSessionPack", "ProjectContext must expose session pack archival."], + [context, "sessionPackItems", "Project search must index session packs."], + [routes, "/projects/:projectId/session-packs", "Session Packs route missing."], + [routes, "SessionPacks", "Session Packs page must be imported in routes."], + [navigation, "Session Packs", "Navigation must expose Session Packs."], + [flowRail, 'id: "session-packs"', "Project Flow Rail must include Session Packs."], + [page, "Session Packs", "Session Packs page must exist."], + [page, "Copy markdown", "Session Packs page must copy markdown."], + [page, "Download markdown", "Session Packs page must download markdown."], + [page, "Copy JSON", "Session Packs page must copy JSON."], + [page, "Download JSON", "Session Packs page must download JSON."], + [page, "Download instructions/scripts ZIP", "Session Packs page must expose clear instructions/scripts ZIP export."], + [page, "Export session pack files", "Session Packs page must expose bundle file export."], + [page, "Bundle contents", "Session Packs page must show bundle contents."], + [page, "Validation plan", "Session Packs page must show validation plan."], + [page, "Feedback template", "Session Packs page must show feedback template."], + [handoffs, "Create session pack", "Codex Handoffs must create session packs."], + [handoffs, "Open session pack", "Codex Handoffs must open session packs."], + [handoffs, "Copy session pack", "Codex Handoffs must copy session packs."], + [handoffs, "Download session pack", "Codex Handoffs must download session packs."], + [handoffs, "Download ZIP", "Codex Handoffs must download session pack ZIP."], + [handoffs, "Export session pack files", "Codex Handoffs must export session pack files."], + [buildLoop, "Create Codex session pack", "Build Loop must recommend creating session packs."], + [buildLoop, "Run session and add feedback", "Build Loop must recommend feedback after session pack."], + [buildLoop, "latestSessionPack", "Build Loop status must expose latest session pack."], + [buildLoopPage, "Session pack", "Build Loop page must show session pack status."], + [walkthrough, "create-session-pack", "Build Loop walkthrough must include create-session-pack step."], + [walkthrough, "session-packs", "Build Loop walkthrough must explain Session Packs module."], + [execution, "Codex Session Pack", "Execution Center must show recommended session pack."], + [execution, "Run in Codex", "Execution Center must expose session packs as runnable Codex input."], + [promptBuilder, "Codex Session Pack source", "Prompt Builder must expose session pack source."], + [promptBuilder, "Use session pack prompt", "Prompt Builder must load session pack markdown."], + [feedback, "Linked session pack", "Execution Feedback must link session packs."], + [feedback, "Session pack feedback boundary", "Execution Feedback must show pack feedback template."], + [feedback, "Paste Codex report generated from this session pack", "Execution Feedback textarea must adapt to linked pack."], + [history, 'kind: "sessionPack"', "Project History must include session pack records."], + [audit, "Codex session pack readiness", "Audit Center must show session pack coverage."], + [audit, "Approved handoffs without pack", "Audit Center must count handoffs without session pack."], + [workspace, "Session Packs", "Workspace must expose Session Packs."], + [workspace, "activeSessionPacks", "Workspace must inspect active session packs."], + [pkg, "audit:session-packs", "package.json must expose audit:session-packs."], + [pkg, "check-session-pack-readiness.mjs", "package.json must wire session pack readiness script."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +if (page.includes("Download Bundle / ZIP")) { + failures.push("Session Packs page must use no-ZIP wording for bundle file export."); +} + +if (!pkg.includes("audit:session-packs") || !pkg.includes("audit:all")) { + failures.push("audit:all must include the session pack readiness check."); +} + +if (failures.length) { + console.error("ProjectBrain session pack readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain session pack readiness: PASS"); diff --git a/scripts/check-skill-library-readiness.mjs b/scripts/check-skill-library-readiness.mjs new file mode 100644 index 0000000..ce567b9 --- /dev/null +++ b/scripts/check-skill-library-readiness.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "src/shared/lib/model.ts", + "src/shared/lib/codexSkills.ts", + "src/shared/lib/repositoryIntake.ts", + "src/shared/lib/codexHandoff.ts", + "src/shared/lib/projectWizard.ts", + "src/shared/lib/safeData.ts", + "src/shared/contexts/ProjectContext.tsx", + "src/features/skills/pages/SkillLibrary.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/prompts/pages/PromptGenerator.tsx", + "src/features/history/pages/ProjectHistory.tsx", + "src/features/projects/pages/Workspace.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const fileText = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); +const catalog = fileText["src/shared/lib/codexSkills.ts"] ?? ""; +const repositoryIntake = fileText["src/shared/lib/repositoryIntake.ts"] ?? ""; +const codexHandoff = fileText["src/shared/lib/codexHandoff.ts"] ?? ""; +const model = fileText["src/shared/lib/model.ts"] ?? ""; +const routes = fileText["src/app/routes.tsx"] ?? ""; +const navigation = fileText["src/shared/lib/navigation.tsx"] ?? ""; +const context = fileText["src/shared/contexts/ProjectContext.tsx"] ?? ""; +const skillPage = fileText["src/features/skills/pages/SkillLibrary.tsx"] ?? ""; +const repoBuilder = fileText["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const execution = fileText["src/features/execution/pages/ExecutionCenter.tsx"] ?? ""; +const promptBuilder = fileText["src/features/prompts/pages/PromptGenerator.tsx"] ?? ""; +const history = fileText["src/features/history/pages/ProjectHistory.tsx"] ?? ""; +const projectWizard = fileText["src/shared/lib/projectWizard.ts"] ?? ""; +const safeData = fileText["src/shared/lib/safeData.ts"] ?? ""; +const flowRail = fileText["src/shared/components/ProjectFlowRail.tsx"] ?? ""; +const workspace = fileText["src/features/projects/pages/Workspace.tsx"] ?? ""; +const pkg = fileText["package.json"] ?? ""; + +const mustContain = [ + [model, "export interface CodexSkill", "CodexSkill model missing."], + [model, "ProjectSkillSelection", "Project skill selection model missing."], + [model, "codexSkills?: ProjectSkillSelection[]", "Project model must persist skill selections."], + [catalog, "getBuiltInCodexSkills", "Built-in skill catalog helper missing."], + [catalog, "recommendSkillsForProject", "Project skill recommendation helper missing."], + [catalog, "formatSkillsForCodexHandoff", "Codex handoff formatter missing."], + [safeData, "codexSkills: asArray(project.codexSkills)", "Runtime normalization must preserve project skills."], + [projectWizard, "Recommended Codex skills", "Project creation output must include recommended Codex skills."], + [context, "setProjectSkillEnabled", "ProjectContext must allow enabling/disabling project skills."], + [context, "applyRecommendedProjectSkills", "ProjectContext must apply recommended skills."], + [context, "codexSkillItems", "Global search must include Codex skills."], + [routes, "/projects/:projectId/skills", "Skill Library route missing."], + [routes, "SkillLibrary", "Skill Library page missing from routes."], + [navigation, "Codex Skills", "Navigation must expose Codex Skills."], + [flowRail, 'id: "skills"', "Project flow rail must include the Skills step."], + [workspace, "Codex Skills", "Workspace must expose Codex Skills."], + [skillPage, "Built-in skills", "Skill Library must show built-in skills."], + [skillPage, "Recommended", "Skill Library must show recommended skills."], + [skillPage, "Active project skills", "Skill Library must show active project skills."], + [skillPage, "requiredContext", "Skill detail must expose required context."], + [skillPage, "validationCommands", "Skill detail must expose validation commands."], + [skillPage, "promptInstructions", "Skill detail must expose prompt instructions."], + [repoBuilder, "Skill set", "Repo Builder must expose a Skill set panel."], + [repoBuilder, "formatSkillsForCodexHandoff", "Repo Builder handoff/export must include skill blocks."], + [repoBuilder, "skillIds", "Repo Builder artifacts must persist skill links."], + [repositoryIntake, "mapRepositoryEntriesToSkills", "Repository intake must map imported repo evidence to Codex skills."], + [repositoryIntake, "linkedSkillIds", "Repository intakes must retain linked skill ids."], + [repoBuilder, "Matched skills", "Repository intake detail must show matched skills."], + [codexHandoff, "promptInstructions", "Codex handoffs must include skill prompt instructions."], + [codexHandoff, "qualityChecklist", "Codex handoffs must include skill quality checklist."], + [codexHandoff, "riskNotes", "Codex handoffs must include skill risk notes."], + [codexHandoff, "typicalFiles", "Codex handoffs must include skill typical files."], + [execution, "Active Codex Skills", "Execution Center must show active skills."], + [execution, "formatSkillsForCodexHandoff", "Execution brief must include skill blocks."], + [promptBuilder, "Active Codex Skills", "Prompt Builder must show active skills."], + [promptBuilder, "withSkillBlock", "Prompt Builder must append skill blocks to generated prompts."], + [history, 'kind: "skill"', "History must include skill records."], + [pkg, "audit:skills", "package.json must expose audit:skills."], + [pkg, "check-skill-library-readiness.mjs", "package.json must wire the skill readiness script."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +const requiredSkillIds = [ + "repo-foundation-build", + "frontend-react-vite-build", + "backend-api-build", + "database-migration", + "docker-runtime-hardening", + "qa-qc-review", + "documentation-system-build", + "repo-expansion-planning", + "codex-handoff-packaging", + "ui-ux-polish-pass", + "geoai-backend-build", + "postgis-migration", + "raster-pipeline", + "frontend-maplibre", + "geospatial-qa-qc-review", + "ml-pipeline-prototyping", + "etl-data-ingestion", + "auth-security-hardening", +]; + +for (const skillId of requiredSkillIds) { + if (!catalog.includes(`id: "${skillId}"`)) failures.push(`Built-in skill missing: ${skillId}`); +} + +if (failures.length) { + console.error("ProjectBrain skill library readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain skill library readiness: PASS"); diff --git a/scripts/check-source-library-readiness.mjs b/scripts/check-source-library-readiness.mjs new file mode 100644 index 0000000..0151669 --- /dev/null +++ b/scripts/check-source-library-readiness.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); +const exists = (file) => fs.existsSync(path.join(root, file)); +const failures = []; +const requireFile = (file, reason) => { if (!exists(file)) failures.push(`${file}: ${reason}`); }; +const requireIncludes = (file, needle, reason) => { + if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${reason}`); +}; + +requireFile("src/features/source-library/pages/SourceLibrary.tsx", "Source Library page must exist."); +requireFile("src/shared/lib/projectSourceCommands.ts", "Source intake commands must exist."); +requireFile("src/shared/lib/projectOperatingStep.ts", "Shared project operating-step helper must exist."); +requireIncludes("src/app/routes.tsx", 'path="/projects/:projectId/source-library"', "Source Library route must be registered."); +requireIncludes("src/shared/lib/navigation.tsx", "Source Library", "Navigation should expose Source Library."); +requireIncludes("src/shared/lib/model.ts", "ProjectSourceItem", "Project model must preserve original source material."); +requireIncludes("src/shared/lib/model.ts", "ProjectSourceAsset", "Project model must preserve extracted media/document assets."); +requireIncludes("src/shared/lib/safeData.ts", "sourceLibrary: asArray", "Runtime normalization should keep sourceLibrary arrays safe."); +requireIncludes("src/shared/lib/safeData.ts", "mediaAssets: asArray", "Runtime normalization should keep mediaAssets arrays safe."); +requireIncludes("src/shared/contexts/ProjectContext.tsx", "addProjectSource", "ProjectContext should create source-library items."); +requireIncludes("src/shared/contexts/ProjectContext.tsx", "promoteSourceToReview", "ProjectContext should promote sources to Review Queue."); +requireIncludes("src/shared/lib/projectSourceCommands.ts", "const inferAssets", "Source intake should extract media/document asset candidates."); +requireIncludes("src/shared/lib/projectSourceCommands.ts", "mediaAssets: [...assets", "Extracted source assets should persist with project media."); +requireIncludes("src/features/operations/pages/CaptureCenter.tsx", "Source Library", "Capture Center should link to Source Library."); +requireIncludes("src/features/operations/pages/DocumentationHub.tsx", "Source-backed documentation intake", "Documentation Hub should make the source/docs link visible."); +requireIncludes("src/shared/lib/projectOperatingStep.ts", "Nieuwe opdracht geven", "The shared next-step layer should allow direct assignments without mandatory extra sources."); +requireIncludes("src/shared/components/ProjectFlowRail.tsx", "source-library", "Project flow rail should include the source-library step."); + +if (failures.length) { + console.error("ProjectBrain source library readiness: FAIL"); + failures.forEach((failure) => console.error(`- ${failure}`)); + process.exit(1); +} + +console.log("ProjectBrain source library readiness: PASS"); diff --git a/scripts/check-typescript-gate.mjs b/scripts/check-typescript-gate.mjs new file mode 100644 index 0000000..187db89 --- /dev/null +++ b/scripts/check-typescript-gate.mjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const manifest = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); +const command = manifest.scripts?.typecheck; +const required = ["tsconfig.app.json", "tsconfig.node.json"]; +const missing = required.filter((config) => !command?.includes(`-p ${config}`)); + +if (missing.length) { + console.error(`TypeScript gate does not compile: ${missing.join(", ")}`); + process.exit(1); +} + +for (const config of required) { + const content = await readFile(resolve(root, config), "utf8"); + if (!/"noEmit"\s*:\s*true/.test(content) && !command.includes("--noEmit")) { + console.error(`TypeScript gate may emit files for ${config}.`); + process.exit(1); + } +} + +console.log("TypeScript gate covers application and Node configurations."); diff --git a/scripts/check-unraid-template-readiness.mjs b/scripts/check-unraid-template-readiness.mjs new file mode 100644 index 0000000..098e6ef --- /dev/null +++ b/scripts/check-unraid-template-readiness.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; + +const requiredFiles = [ + "unraid/install.sh", + "unraid/projectbrain.xml", + "unraid/projectbrain-icon.png", + "unraid/README.md", + "docker-compose.yml", + "Dockerfile.unraid", + "docker/nginx.single.conf", + "scripts/deploy-unraid.sh", + "scripts/deploy-unraid-single.sh", + "scripts/unraid-single-entrypoint.sh", + "docker/unraid/README.md", + "docker/unraid/projectbrain.env.example", + "docker/unraid/projectbrain-container.env.example", + "docker/unraid/projectbrain-compose-template.xml", + "docker/unraid/projectbrain-container-template.xml", + "docker/unraid/projectbrain-icon.png", + "docs/DOCKER_UNRAID_SETUP.md", +]; + +const failures = []; +const read = (file) => readFileSync(file, "utf8"); + +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const compose = existsSync("docker-compose.yml") ? read("docker-compose.yml") : ""; +const env = existsSync("docker/unraid/projectbrain.env.example") ? read("docker/unraid/projectbrain.env.example") : ""; +const containerEnv = existsSync("docker/unraid/projectbrain-container.env.example") ? read("docker/unraid/projectbrain-container.env.example") : ""; +const xml = existsSync("docker/unraid/projectbrain-compose-template.xml") ? read("docker/unraid/projectbrain-compose-template.xml") : ""; +const containerXml = existsSync("docker/unraid/projectbrain-container-template.xml") ? read("docker/unraid/projectbrain-container-template.xml") : ""; +const icon = existsSync("docker/unraid/projectbrain-icon.png") ? "png-icon" : ""; +const readme = existsSync("docker/unraid/README.md") ? read("docker/unraid/README.md") : ""; +const docs = existsSync("docs/DOCKER_UNRAID_SETUP.md") ? read("docs/DOCKER_UNRAID_SETUP.md") : ""; +const deploy = existsSync("scripts/deploy-unraid.sh") ? read("scripts/deploy-unraid.sh") : ""; +const singleDockerfile = existsSync("Dockerfile.unraid") ? read("Dockerfile.unraid") : ""; +const singleNginx = existsSync("docker/nginx.single.conf") ? read("docker/nginx.single.conf") : ""; +const singleEntry = existsSync("scripts/unraid-single-entrypoint.sh") ? read("scripts/unraid-single-entrypoint.sh") : ""; +const singleDeploy = existsSync("scripts/deploy-unraid-single.sh") ? read("scripts/deploy-unraid-single.sh") : ""; +const unraidInstall = existsSync("unraid/install.sh") ? read("unraid/install.sh") : ""; +const unraidTemplate = existsSync("unraid/projectbrain.xml") ? read("unraid/projectbrain.xml") : ""; +const unraidIcon = existsSync("unraid/projectbrain-icon.png") ? "png-icon" : ""; +const unraidReadme = existsSync("unraid/README.md") ? read("unraid/README.md") : ""; + +const mustContain = [ + [compose, "${PROJECTBRAIN_WEB_PORT:-1209}:80", "docker-compose.yml must keep the frontend host port configurable."], + [compose, "${POSTGRES_PASSWORD:?", "Production compose must require a PostgreSQL password."], + [compose, "${PROJECTBRAIN_OWNER_PASSWORD:?", "Production compose must require the owner password."], + [compose, "${PROJECTBRAIN_AUTH_SECRET:?", "Production compose must require the authentication secret."], + [unraidInstall, "templates-user", "Root Unraid installer must write a DockerMan template."], + [unraidInstall, "dockerMan/images", "Root Unraid installer must write a DockerMan icon."], + [unraidInstall, "git clone", "Root Unraid installer must support installing from Gitea."], + [unraidInstall, "deploy-unraid-single.sh", "Root Unraid installer must deploy the single-container app."], + [unraidTemplate, "projectbrain", "Root Unraid template name must match the running container name so DockerMan can show Edit."], + [unraidTemplate, "projectbrain-unraid:latest", "Root Unraid template must reference the built ProjectBrain image."], + [unraidTemplate, "Target=\"80\"", "Root Unraid template must expose container port 80."], + [unraidTemplate, "projectbrain-icon.png", "Root Unraid template must reference the PNG icon."], + [unraidTemplate, "--label net.unraid.docker.managed=dockerman", "Root Unraid template must create DockerMan-managed containers."], + [unraidTemplate, "Target=\"PROJECTBRAIN_OWNER_PASSWORD\" Default=\"\"", "Root Unraid template must require an explicit owner password."], + [unraidTemplate, "Target=\"PROJECTBRAIN_AUTH_SECRET\" Default=\"\"", "Root Unraid template must require an explicit authentication secret."], + [unraidTemplate, "Target=\"POSTGRES_PASSWORD\" Default=\"\"", "Root Unraid template must not ship an embedded PostgreSQL password."], + [unraidIcon, "png-icon", "Root Unraid PNG icon must exist."], + [unraidReadme, "Docker > Add Container > Template: ProjectBrain", "Root Unraid README must explain where the template appears."], + [env, "PROJECTBRAIN_WEB_PORT=1209", "Unraid env template must expose PROJECTBRAIN_WEB_PORT."], + [env, "VITE_PROJECTBRAIN_CLIENT_MODE=real", "Unraid env template must default the frontend to real mode."], + [env, "PROJECTBRAIN_DATA_SOURCE=database", "Unraid env template must default backend data source to database."], + [containerEnv, "PROJECTBRAIN_IMAGE=projectbrain-unraid:latest", "Single-container env template must expose the local image name."], + [containerEnv, "PROJECTBRAIN_DOCKER_NETWORK=auto", "Single-container env template must default Docker network detection to auto."], + [containerEnv, "PROJECTBRAIN_STOP_COMPOSE_APP_SERVICES=true", "Single-container env template must stop old compose app containers by default."], + [containerEnv, "PROJECTBRAIN_USE_EXTERNAL_DATABASE=false", "Single-container env template must default to embedded Postgres."], + [containerEnv, "PROJECTBRAIN_DEPLOY_NO_CACHE=false", "Single-container env template must default to cached deploys."], + [containerEnv, "PROJECTBRAIN_MIN_DOCKER_FREE_MB=2048", "Single-container env template must expose Docker storage threshold."], + [containerEnv, "PROJECTBRAIN_REQUIRE_FRESH_BACKUP=false", "Single-container env template must expose backup freshness gate."], + [containerEnv, "POSTGRES_PASSWORD=", "Single-container env template must expose the embedded Postgres secret input."], + [xml, "ProjectBrain", "Unraid XML template must name ProjectBrain."], + [xml, "PROJECTBRAIN_WEB_PORT", "Unraid XML template must expose the web port variable."], + [xml, "http://[IP]:[PORT:1209]", "Unraid XML template must define the WebUI URL."], + [xml, "projectbrain-icon.png", "Unraid XML template must reference the PNG icon asset."], + [xml, "POSTGRES_PASSWORD", "Unraid XML template must expose Postgres password."], + [containerXml, "projectbrain", "Single-container XML template name must match the running container name."], + [containerXml, "projectbrain-unraid:latest", "Single-container XML template must point at the local ProjectBrain image."], + [containerXml, "--label net.unraid.docker.managed=dockerman", "Single-container XML template must create DockerMan-managed containers."], + [containerXml, "Target=\"80\"", "Single-container XML template must expose container port 80."], + [containerXml, "Target=\"/var/lib/projectbrain\"", "Single-container XML template must map persistent appdata."], + [containerXml, "Target=\"PROJECTBRAIN_OWNER_PASSWORD\" Default=\"\"", "Single-container XML template must require an explicit owner password."], + [containerXml, "Target=\"PROJECTBRAIN_AUTH_SECRET\" Default=\"\"", "Single-container XML template must require an explicit authentication secret."], + [containerXml, "Target=\"POSTGRES_PASSWORD\" Default=\"\"", "Single-container XML template must not ship an embedded PostgreSQL password."], + [containerXml, "projectbrain-icon.png", "Single-container XML template must reference the PNG icon asset."], + [icon, "png-icon", "Unraid PNG icon must exist."], + [singleDockerfile, "nginx.single.conf", "Single-container Dockerfile must use the single-container nginx config."], + [singleDockerfile, "unraid-single-entrypoint.sh", "Single-container Dockerfile must use the single-container entrypoint."], + [singleDockerfile, "HEALTHCHECK", "Single-container Dockerfile must define a healthcheck."], + [singleNginx, "127.0.0.1:4000", "Single-container nginx config must proxy API calls to the internal backend."], + [singleEntry, "DATABASE_URL is required", "Single-container entrypoint must fail clearly without DATABASE_URL."], + [singleEntry, "POSTGRES_PASSWORD is required for embedded PostgreSQL", "Single-container entrypoint must fail closed without an embedded PostgreSQL password."], + [singleEntry, "PROJECTBRAIN_USE_EXTERNAL_DATABASE", "Single-container entrypoint must default to embedded Postgres unless external DB is requested."], + [singleEntry, "initdb", "Single-container entrypoint must initialize embedded Postgres."], + [singleEntry, "nginx -g", "Single-container entrypoint must start nginx."], + [readme, "Change The External Port", "Unraid README must explain port changes."], + [readme, "Single Container", "Unraid README must document the single-container template."], + [readme, "scripts/deploy-unraid.sh", "Unraid README must reference the deploy helper."], + [readme, "scripts/deploy-unraid-single.sh", "Unraid README must reference the single-container deploy helper."], + [readme, "docker compose up -d --build", "Unraid README must include restart command."], + [docs, "projectbrain.env.example", "Docker/Unraid docs must reference the Unraid env template."], + [docs, "projectbrain-container-template.xml", "Docker/Unraid docs must reference the single-container XML template."], + [docs, "scripts/deploy-unraid.sh", "Docker/Unraid docs must reference the deploy helper."], + [docs, "scripts/deploy-unraid-single.sh", "Docker/Unraid docs must reference the single-container deploy helper."], + [deploy, "git pull --ff-only", "Unraid deploy helper must pull the latest repository state."], + [deploy, "projectbrain.env.example", "Unraid deploy helper must apply the env template."], + [deploy, "docker compose build --no-cache", "Unraid deploy helper must rebuild the stack."], + [deploy, "api/system/readiness", "Unraid deploy helper must check readiness."], + [singleDeploy, "Dockerfile.unraid", "Single-container deploy helper must build Dockerfile.unraid."], + [singleDeploy, "PROJECTBRAIN_DEPLOY_NO_CACHE", "Single-container deploy helper must make no-cache configurable."], + [singleDeploy, "docker build -f Dockerfile.unraid", "Single-container deploy helper must allow cached builds by default."], + [singleDeploy, "Docker storage preflight", "Single-container deploy helper must show Docker storage preflight."], + [singleDeploy, "docker system df", "Single-container deploy helper must print Docker storage usage."], + [singleDeploy, "PROJECTBRAIN_MIN_DOCKER_FREE_MB", "Single-container deploy helper must expose Docker free-space threshold."], + [singleDeploy, "backup freshness preflight", "Single-container deploy helper must check backup freshness."], + [singleDeploy, "PROJECTBRAIN_REQUIRE_FRESH_BACKUP", "Single-container deploy helper must expose strict backup gate."], + [singleDeploy, "git rev-parse --short HEAD", "Single-container deploy helper must report the current git commit."], + [singleDeploy, "started ${CONTAINER_NAME}", "Single-container deploy helper must report container replacement id."], + [singleDeploy, "projectbrain-postgres", "Single-container deploy helper must auto-detect the Postgres container network."], + [singleDeploy, "projectbrain-frontend projectbrain-api", "Single-container deploy helper must remove old compose app containers for live cutover."], + [singleDeploy, "projectbrain-postgres", "Single-container deploy helper must remove the old standalone ProjectBrain Postgres container."], + [singleDeploy, "projectbrain-icon.png", "Single-container deploy helper must set the Unraid PNG icon label."], + [singleDeploy, "docker run -d", "Single-container deploy helper must run one app container."], + [singleDeploy, "projectbrain-container.env.example", "Single-container deploy helper must apply the container env template."], + [singleDeploy, "api/system/readiness", "Single-container deploy helper must check readiness."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +for (const secretName of ["POSTGRES_PASSWORD", "PROJECTBRAIN_OWNER_PASSWORD", "PROJECTBRAIN_AUTH_SECRET"]) { + if (new RegExp(`^${secretName}=.+$`, "m").test(containerEnv)) failures.push(`Single-container env template must not ship a default ${secretName}.`); +} + +for (const [templateName, template] of [["root", unraidTemplate], ["single-container", containerXml]]) { + if (/Target="POSTGRES_PASSWORD"\s+Default="projectbrain"/i.test(template)) failures.push(`${templateName} Unraid template must not ship the historical PostgreSQL password.`); + if (/Target="PROJECTBRAIN_OWNER_PASSWORD"\s+Default="[^"]+"/i.test(template)) failures.push(`${templateName} Unraid template must not ship an owner password.`); + if (/Target="PROJECTBRAIN_AUTH_SECRET"\s+Default="[^"]+"/i.test(template)) failures.push(`${templateName} Unraid template must not ship an authentication secret.`); +} + +if (failures.length) { + console.error("ProjectBrain Unraid template readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain Unraid template readiness: PASS"); diff --git a/scripts/check-v1-contract-freeze.mjs b/scripts/check-v1-contract-freeze.mjs new file mode 100644 index 0000000..3867b55 --- /dev/null +++ b/scripts/check-v1-contract-freeze.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFile, readdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import process from "node:process"; + +const root = resolve(import.meta.dirname, ".."); +const manifestPath = resolve(root, "config/v1-contract-freeze.json"); +const files = ["backend/prisma/schema.prisma", "packages/contracts/index.js", "packages/contracts/index.d.ts", "docs/openapi/projectbrain-v1.json"]; +const migrations = (await readdir(resolve(root, "backend/prisma/migrations"), { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); +const hashes = Object.fromEntries(await Promise.all(files.map(async (name) => [name, createHash("sha256").update(await readFile(resolve(root, name))).digest("hex")]))); +const current = { version: "1.0.0", migrations, hashes }; +if (process.argv.includes("--write")) { + await writeFile(manifestPath, `${JSON.stringify(current, null, 2)}\n`); + console.log(`Wrote v1 contract freeze manifest with ${migrations.length} migrations.`); +} else { + const frozen = JSON.parse(await readFile(manifestPath, "utf8")); + if (JSON.stringify(frozen) !== JSON.stringify(current)) { + console.error("The frozen v1 schema/API surface changed. Review compatibility and run pnpm freeze:v1 only for an approved release decision."); + console.error("Current canonical v1 snapshot:"); + console.error(JSON.stringify(current, null, 2)); + process.exit(1); + } + console.log(`v1 contract freeze verified: ${files.length} canonical surfaces and ${migrations.length} migrations.`); +} diff --git a/scripts/check-visual-flow-readiness.mjs b/scripts/check-visual-flow-readiness.mjs new file mode 100644 index 0000000..d7e2c80 --- /dev/null +++ b/scripts/check-visual-flow-readiness.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; + +const requiredFiles = [ + "src/app/routes.tsx", + "src/shared/lib/navigation.tsx", + "src/shared/components/ProjectFlowRail.tsx", + "src/shared/components/BuildLoopActionRail.tsx", + "src/shared/components/ProjectRouteFallback.tsx", + "src/shared/lib/projectOperatingStep.ts", + "src/shared/lib/projectStartFlow.ts", + "src/shared/lib/routeRegistry.ts", + "src/features/projects/pages/Projects.tsx", + "src/features/projects/pages/Workspace.tsx", + "src/features/build-loop/pages/BuildLoopDashboard.tsx", + "src/features/repo-builder/pages/RepoBuilder.tsx", + "src/features/handoffs/pages/CodexHandoffs.tsx", + "src/features/session-packs/pages/SessionPacks.tsx", + "src/features/execution/pages/ExecutionCenter.tsx", + "src/features/execution-feedback/pages/ExecutionFeedback.tsx", + "src/features/operations/pages/ReviewQueue.tsx", + "package.json", +]; + +const failures = []; +for (const file of requiredFiles) { + if (!existsSync(file)) failures.push(`Missing required file: ${file}`); +} + +const read = (file) => readFileSync(file, "utf8"); +const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)])); +const listSourceFiles = (dir) => { + if (!existsSync(dir)) return []; + return readdirSync(dir).flatMap((entry) => { + const path = `${dir}/${entry}`; + const stat = statSync(path); + if (stat.isDirectory()) return listSourceFiles(path); + return /\.(ts|tsx|js|jsx)$/.test(path) ? [path] : []; + }); +}; +const sourceText = Object.fromEntries(listSourceFiles("src").map((file) => [file, read(file)])); + +const routes = text["src/app/routes.tsx"] ?? ""; +const navigation = text["src/shared/lib/navigation.tsx"] ?? ""; +const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? ""; +const buildLoopActionRail = text["src/shared/components/BuildLoopActionRail.tsx"] ?? ""; +const projectRouteFallback = text["src/shared/components/ProjectRouteFallback.tsx"] ?? ""; +const operatingStep = text["src/shared/lib/projectOperatingStep.ts"] ?? ""; +const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? ""; +const routeRegistry = text["src/shared/lib/routeRegistry.ts"] ?? ""; +const projects = text["src/features/projects/pages/Projects.tsx"] ?? ""; +const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? ""; +const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? ""; +const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? ""; +const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? ""; +const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? ""; +const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? ""; +const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? ""; +const reviewQueue = text["src/features/operations/pages/ReviewQueue.tsx"] ?? ""; +const pkg = text["package.json"] ?? ""; + +const mustContain = [ + [routes, 'projectRoutePattern("workspace")', "Workspace route missing."], + [routes, "/projects/:projectId/build-loop", "Build Loop route missing."], + [routes, "/projects/:projectId/source-library", "Source Library route missing."], + [routes, "/projects/:projectId/review-queue", "Review Queue route missing."], + [routes, "/projects/:projectId/skills", "Skill Library route missing."], + [routes, "/projects/:projectId/repo-builder", "Repo Builder route missing."], + [routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."], + [routes, "/projects/:projectId/session-packs", "Session Packs route missing."], + [routes, "/projects/:projectId/execution", "Execution Center route missing."], + [routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."], + [routes, "/projects/:projectId/history", "History route missing."], + [routes, "/projects/:projectId/audit", "Audit Center route missing."], + [routes, "/projects/:projectId/prompts", "Prompt Builder route missing."], + [navigation, "getPrimaryPathNavigationItems", "Primary navigation must expose the workspace that owns the project flow."], + [routeRegistry, 'key: "workspace"', "Canonical route registry must expose the workspace that owns the project flow."], + [flowRail, 'id: "build-loop"', "Project Flow Rail must expose Build Loop."], + [buildLoopActionRail, "Build-loop closure path", "Build Loop action rail must name the handoff-to-current-state path."], + [buildLoopActionRail, "execution output is evidence only", "Build Loop action rail must preserve review-before-truth language."], + [buildLoopActionRail, "/runtime", "Build Loop action rail must link Current State/Runtime endpoint."], + [projectRouteFallback, "Projecten zoeken", "Project route fallback must guide users back to projects."], + [projectRouteFallback, "Nieuw project starten", "Project route fallback must guide users back to the unified start flow."], + [routes, "routeProjectExists", "Route sync must avoid activating unknown project slugs."], + [routes, "ProjectRouteSync", "The app router must synchronize valid project routes."], + [navigation, "getProjectIdFromPath", "Project route parsing must be shared with navigation."], + [projects, "getProjectOperatingStep", "Projects cards must derive the shared next safe action."], + [projects, "operatingState.label", "Projects must make the recommended continuation label explicit."], + [projects, "operatingState.description", "Projects must make the recommended continuation explanation explicit."], + [buildLoop, "getBuildLoopStatus", "Technical Build Loop must derive its state from the shared helper."], + [buildLoop, "getProjectReleaseReadiness", "Technical Build Loop must retain release gates."], + [startFlow, "Repository structureren", "Project start flow must expose repository shaping."], + [workspace, "Technische details", "Workspace must progressively disclose technical execution."], + [navigation, 'level: "advanced"', "Navigation must keep specialist modules outside the primary path."], + [workspace, 'data-page-primary="true"', "Workspace must expose one state-driven primary action."], + [buildLoop, "Technische route openen", "Build Loop must provide linked module CTA."], + [buildLoop, "navigate(step.route)", "Build Loop must link each technical stage to its destination."], + [buildLoop, "navigate(gate.route)", "Build Loop must expose destinations for release evidence gates."], + [buildLoop, "getFirstTimeBuildLoopSteps", "Build Loop must derive its technical stage navigation."], + [buildLoop, "ProjectRouteFallback", "Build Loop must use the shared project fallback."], + [repoBuilder, "Huidige fase:", "Repo Builder must expose the current repository phase."], + [repoBuilder, "Repository-informatie importeren", "Repo Builder must expose repository intake as the first task in its flow."], + [repoBuilder, "ProjectBrain gebruikt dit als geïmporteerde informatie, niet als bewijs van de live-bestanden.", "Repo Builder must show the imported-evidence boundary."], + [repoBuilder, "Dit is een ProjectBrain-simulatie, geen live git-diff.", "Repo Builder must label the impact-review simulation boundary."], + [handoffs, "Review impact", "Codex Handoffs must expose Review impact flow language."], + [handoffs, "Handoff quality gate", "Codex Handoffs must expose the quality gate."], + [handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs must show impact simulation boundary."], + [handoffs, "Download ZIP", "Codex Handoffs must expose real session-pack ZIP export."], + [handoffs, "Export session pack files", "Codex Handoffs must keep individual session-pack file export."], + [handoffs, "controlled input for a Codex build session", "Codex Handoffs must describe session packs as controlled input."], + [handoffs, "BuildLoopActionRail", "Codex Handoffs must show the shared build-loop action rail."], + [handoffs, "ProjectRouteFallback", "Codex Handoffs must use the shared project fallback."], + [sessionPacks, "A session pack is controlled input for a Codex build session", "Session Packs must show controlled input boundary."], + [sessionPacks, "Session pack execution path", "Session Packs must expose the execution path."], + [sessionPacks, "Download instructions/scripts ZIP", "Session Packs must expose clear instructions/scripts ZIP download."], + [sessionPacks, "Export session pack files", "Session Packs must use honest export label."], + [sessionPacks, "Create Codex handoff", "Session Packs empty state must link back to handoffs."], + [sessionPacks, "Return to Build Loop", "Session Packs empty state must link back to Build Loop."], + [sessionPacks, "ProjectRouteFallback", "Session Packs must use the shared project fallback."], + [execution, "Run in Codex", "Execution Center must expose Run in Codex step language."], + [execution, "ProjectRouteFallback", "Execution Center must use the shared project fallback."], + [feedback, "Pasted Codex reports are user-provided execution evidence", "Execution Feedback must show user-provided evidence boundary."], + [feedback, "Create follow-up handoff", "Execution Feedback must expose follow-up handoff action."], + [feedback, "BuildLoopActionRail", "Execution Feedback must show the shared build-loop action rail."], + [feedback, "ProjectRouteFallback", "Execution Feedback must use the shared project fallback."], + [reviewQueue, "BuildLoopActionRail", "Review Queue must show the shared build-loop action rail."], + [reviewQueue, "Close evidence into project state", "Review Queue must explain closure into project state."], + [reviewQueue, "ProjectRouteFallback", "Review Queue must use the shared project fallback."], + [pkg, "audit:visual-flow", "package.json must expose audit:visual-flow."], + [pkg, "check-visual-flow-readiness.mjs", "package.json must wire visual flow readiness."], +]; + +for (const [haystack, needle, message] of mustContain) { + if (!haystack.includes(needle)) failures.push(message); +} + +for (const [file, contents] of Object.entries(sourceText)) { + if (file.startsWith("src/") && contents.includes("Download Bundle / ZIP")) { + failures.push(`${file} still contains misleading Download Bundle / ZIP copy.`); + } + if (file.startsWith("src/") && /existing (lint )?warnings remain/i.test(contents)) { + failures.push(`${file} still contains stale existing-warnings copy.`); + } + if (file.startsWith("src/features/") && contents.includes("

    Project not found
    ")) { + failures.push(`${file} still contains a bare project-not-found fallback.`); + } +} + +const scripts = JSON.parse(pkg || "{}").scripts ?? {}; +const auditAll = scripts["audit:all"] ?? ""; +const experienceAudit = scripts["audit:experience"] ?? ""; +if (!auditAll.includes("audit:experience") || !experienceAudit.includes("audit:visual-flow")) { + failures.push("audit:all must include the experience gate with visual flow readiness."); +} + +if (failures.length) { + console.error("ProjectBrain visual flow readiness: FAIL"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log("ProjectBrain visual flow readiness: PASS"); diff --git a/scripts/check-visual-polish.mjs b/scripts/check-visual-polish.mjs new file mode 100644 index 0000000..92400fc --- /dev/null +++ b/scripts/check-visual-polish.mjs @@ -0,0 +1,157 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); +const exists = (file) => fs.existsSync(path.join(root, file)); +const checks = []; +const pass = (name, ok, detail = '') => checks.push({ name, ok, detail }); + +const blueprint = read('src/features/blueprint/pages/BlueprintEngine.tsx'); +pass('Blueprint ListChecks import is present', blueprint.includes('ListChecks')); +pass('Blueprint route should not trigger missing ListChecks ReferenceError', /import\s*\{[^}]*ListChecks[^}]*\}\s*from\s*["']lucide-react["']/.test(blueprint)); + +const appLayout = read('src/components/layout/AppLayout.tsx'); +pass('Main shell has compact responsive header offset', appLayout.includes('pt-6') && appLayout.includes('lg:pt-10')); +pass('Main shell prevents page-level horizontal overflow', appLayout.includes('overflow-x-hidden') && appLayout.includes('min-w-0')); + +const pageHeader = read('src/components/ui/PageHeader.tsx'); +pass('PageHeader wraps actions on mobile', pageHeader.includes('flex-wrap') && pageHeader.includes('md:w-auto') && pageHeader.includes('break-words')); + +const routes = read('src/app/routes.tsx'); +pass('Route scroll reset is enabled', routes.includes('ScrollToTop') && routes.includes('data-projectbrain-main')); +pass('Route loading fallback explains project loading', routes.includes('Project laden…') && routes.includes('aria-label="ProjectBrain-pagina laden"')); + +const sidebar = read('src/components/layout/Sidebar.tsx'); +pass('Sidebar descriptions are hidden on smaller desktop widths', sidebar.includes('xl:block')); + +const prompt = read('src/features/prompts/pages/PromptGenerator.tsx'); +pass('Prompt Builder starts with task input', prompt.includes('What should AI/Codex do?')); +pass('Prompt Builder uses multiline output area', prompt.includes('min-h-40 font-mono text-xs')); + +const knowledge = read('src/features/knowledge/pages/KnowledgeVault.tsx'); +pass('Knowledge Vault avoids preview wording in primary actions', !knowledge.includes('preview')); + +const audit = read('src/features/audit/pages/ProjectAuditCenter.tsx'); +pass('Audit Center treats old priority labels as historical', audit.includes('was {finding.severity}')); + +const header = read('src/components/layout/Header.tsx'); +pass('Header New Project menu navigates to unified Start', header.includes('navigate("/start")')); +pass('Header exposes runtime status popover', header.includes('Runtimestatus') && header.includes('useBackendSystemReadinessData')); +pass('Header runtime status links to Runtime page', header.includes('Runtimepagina') && header.includes('/runtime')); +pass('Header labels unavailable backend as local mode', header.includes('Lokale modus') && !header.includes('degraded: "Degraded"')); + +const commandMenu = read('src/components/layout/CommandMenu.tsx'); +const commandMenuSearch = read('src/components/layout/commandMenuSearch.ts'); +pass('Command palette shortcut listens in capture phase', commandMenu.includes('window.addEventListener("keydown", down, { capture: true })')); +pass( + 'Command palette prioritizes best-matching navigation while searching', + commandMenu.includes('rankNavigationItems') && + commandMenu.includes('') && + commandMenu.includes('{navigationSearchGroup}') && + commandMenuSearch.includes('getNavigationScore') && + commandMenuSearch.includes('label.startsWith(normalizedQuery)'), +); +pass('Command palette teaches route-oriented examples', commandMenu.includes('Zoek op repo, review, contextpack, historiek, bron of skill')); +const commandUi = read('src/components/ui/command.tsx'); +pass('Command dialog has screenreader title and description', commandUi.includes('DialogTitle className="sr-only"') && commandUi.includes('DialogDescription className="sr-only"')); + +const projectsPage = read('src/features/projects/pages/Projects.tsx'); +pass( + 'Projects mobile hero actions are full-width and stacked', + projectsPage.includes('flex w-full flex-col gap-2 sm:w-auto sm:flex-row') && + projectsPage.includes('className="w-full sm:w-auto"') && + projectsPage.includes('{refreshing ? "Bijwerken…" : "Bijwerken"}') && + projectsPage.includes('disabled={refreshing}'), +); +pass('Projects progressively reveal large portfolios', projectsPage.includes('PROJECT_PAGE_SIZE') && projectsPage.includes('remainingProjectCount')); + +const runtimeReadiness = read('src/features/projects/pages/RuntimeReadiness.tsx'); +pass( + 'System status prevents page-level mobile overflow', + runtimeReadiness.includes('min-w-0 space-y-6 overflow-x-hidden') && + runtimeReadiness.includes('grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3'), +); +pass( + 'System status capability cards wrap long runtime evidence safely', + runtimeReadiness.includes('className="min-w-0 overflow-hidden"') && + runtimeReadiness.includes('[overflow-wrap:anywhere]') && + runtimeReadiness.includes('max-w-[45%] shrink-0 whitespace-normal text-center'), +); +pass( + 'System status release/build proof avoids mobile overflow', + runtimeReadiness.includes('min-w-0 overflow-hidden scroll-mt-24 border-primary/25') && + runtimeReadiness.includes('break-all font-semibold') && + runtimeReadiness.includes('break-all font-mono text-xs'), +); + +const review = read('src/features/operations/pages/ReviewQueue.tsx'); +pass('Review Queue bulk accept requires confirmation', review.includes('Confirm bulk accept')); + +pass('V84 documentation exists', exists('docs/STABILIZATION_PASS_V84_VISUAL_RC_POLISH_2.md')); +pass('V85 documentation exists', exists('docs/STABILIZATION_PASS_V85_RUNTIME_STATUS_POLISH.md')); +pass('V87 correction documentation exists', exists('docs/STABILIZATION_PASS_V87_REAL_PRODUCT_IMPROVEMENT_CORRECTION.md')); +pass('Audit Center no longer exposes V86 catalog tab', !audit.includes('V86 1000') && !audit.includes('megaImprovementItems')); +const relationshipGraph = read('src/features/relationships/pages/RelationshipGraph.tsx'); +pass('Relationship Graph shows real suggestions instead of only empty state', relationshipGraph.includes('buildRelationshipSuggestions') && relationshipGraph.includes('suggestions.map')); +const repoBuilder = read('src/features/repo-builder/pages/RepoBuilder.tsx'); +pass('Repo Builder mobile lifecycle has swipe affordance', repoBuilder.includes('Veeg zijwaarts om de latere fasen te bekijken.') && repoBuilder.includes('overflow-x-auto')); +pass( + 'Repo Builder archive actions are confirmation-gated', + repoBuilder.includes('ConfirmActionButton') && + repoBuilder.includes('title="Geselecteerde repository-intake archiveren?"') && + repoBuilder.includes('onConfirm={archiveSelectedRepositoryIntake}') && + repoBuilder.includes('title="Deze repositorybasis archiveren?"') && + repoBuilder.includes('onConfirm={archiveBlueprint}') && + repoBuilder.includes('title="Deze uitbreidingsbrief archiveren?"') && + repoBuilder.includes('onConfirm={archiveExpansion}') && + repoBuilder.includes('title="Dit deliverypakket archiveren?"') && + repoBuilder.includes('onConfirm={archiveDeliveryPackage}') && + repoBuilder.includes('title="Dit repositorywerkplan archiveren?"') && + repoBuilder.includes('onConfirm={archiveWorkPlan}') && + repoBuilder.includes('title="Deze repositorystatus archiveren?"') && + repoBuilder.includes('onConfirm={archiveStateSnapshot}'), +); +const sessionPacks = read('src/features/session-packs/pages/SessionPacks.tsx'); +pass('Session Packs exposes top next action for empty state', sessionPacks.includes('Create a Codex handoff before packaging a session.')); +pass('Session Packs archive action is confirmation-gated', sessionPacks.includes('Archive this session pack?') && sessionPacks.includes('AlertDialogAction onClick={archiveSelected}')); +const browserBridge = read('src/features/browser-bridge/pages/BrowserBridge.tsx'); +pass('Browser Bridge exposes top session-pack-required action', browserBridge.includes('Session pack required') && browserBridge.includes('Open Session Packs')); +pass('Browser Bridge archive action is confirmation-gated', browserBridge.includes('Archive this browser bridge session?') && browserBridge.includes('AlertDialogAction onClick={archiveSession}')); +const codexHandoffs = read('src/features/handoffs/pages/CodexHandoffs.tsx'); +pass('Codex handoff archive actions are confirmation-gated', codexHandoffs.includes('Archive this Codex handoff?') && codexHandoffs.includes('Archive this impact review?')); +const runtimeStateManager = read('src/features/operations/pages/RuntimeStateManager.tsx'); +pass('Runtime reset uses accessible AlertDialog instead of native confirm', runtimeStateManager.includes('AlertDialogTitle') && !runtimeStateManager.includes('window.confirm')); +pass('Compact runtime state manager hides import-only control', runtimeStateManager.includes('!compact ? (') && runtimeStateManager.includes('Import JSON')); +const confirmActionButton = read('src/shared/components/ConfirmActionButton.tsx'); +pass('Shared confirm action button uses accessible AlertDialog', confirmActionButton.includes('AlertDialogTitle') && confirmActionButton.includes('confirmLabel')); +const executionFeedback = read('src/features/execution-feedback/pages/ExecutionFeedback.tsx'); +pass('Execution Feedback archive action is confirmation-gated', executionFeedback.includes('Archive this execution feedback?')); +const sourceLibrary = read('src/features/source-library/pages/SourceLibrary.tsx'); +pass( + 'Source Library archive action is confirmation-gated', + sourceLibrary.includes('ConfirmActionButton') && + sourceLibrary.includes('title="Deze bron archiveren?"') && + sourceLibrary.includes('confirmLabel="Bron archiveren"') && + sourceLibrary.includes('onConfirm={() => archive(source.id)}'), +); +const projectHistory = read('src/features/history/pages/ProjectHistory.tsx'); +pass('Project History remove action is confirmation-gated', projectHistory.includes('Dit geschiedenisitem verbergen?')); +const runtimeGateEvidence = read('src/features/operations/pages/RuntimeGateEvidence.tsx'); +pass('Runtime evidence reset is confirmation-gated', runtimeGateEvidence.includes('Reset P2 evidence history?')); +const runtimeGateChecklist = read('src/features/operations/pages/RuntimeGateChecklist.tsx'); +pass('Runtime checklist reset is confirmation-gated', runtimeGateChecklist.includes('Reset P2 checklist?')); +const runtimeTodoBoard = read('src/features/operations/pages/RuntimeTodoBoard.tsx'); +pass('Runtime todo removal is confirmation-gated', runtimeTodoBoard.includes('Remove this runtime todo?')); +const packageJson = read('package.json'); +pass('Mega audit command is removed from package scripts', !packageJson.includes('audit:mega')); + +const failed = checks.filter((check) => !check.ok); +if (failed.length > 0) { + console.error('ProjectBrain visual polish audit: FAIL'); + for (const check of failed) console.error(`- ${check.name}${check.detail ? `: ${check.detail}` : ''}`); + process.exit(1); +} + +console.log('ProjectBrain visual polish audit: PASS'); +for (const check of checks) console.log(`- ${check.name}`); diff --git a/scripts/deployment-proof-state.mjs b/scripts/deployment-proof-state.mjs new file mode 100644 index 0000000..6265ef6 --- /dev/null +++ b/scripts/deployment-proof-state.mjs @@ -0,0 +1,16 @@ +// Unknown identities and evidence from different container rows never prove a deployment. +export const compareDeploymentCommits = (local, remote) => { + const isCommit = (value) => /^[a-f0-9]{7,40}$/i.test(value ?? ""); + if (!isCommit(local) || !isCommit(remote)) return "unknown"; + return local.toLowerCase() === remote.toLowerCase() ? "current" : "stale"; +}; + +export const isRunningContainer = (output, name, requiredPort) => output + .split(/\r?\n/) + .some((line) => { + const [actualName, ...fields] = line.trim().split(/\s+/); + const details = fields.join(" "); + return actualName === name && /^Up\b/.test(details) + && !/\((?:unhealthy|health: starting)\)/.test(details) + && (!requiredPort || details.includes(requiredPort)); + }); diff --git a/scripts/deployment-proof-state.test.mjs b/scripts/deployment-proof-state.test.mjs new file mode 100644 index 0000000..6be1e0b --- /dev/null +++ b/scripts/deployment-proof-state.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compareDeploymentCommits, isRunningContainer } from "./deployment-proof-state.mjs"; + +test("missing identities cannot be reported as current", () => { + for (const value of ["unknown", "", undefined, "not-a-sha"]) { + assert.equal(compareDeploymentCommits(value, value), "unknown"); + assert.equal(compareDeploymentCommits("9deb553", value), "unknown"); + } +}); +test("known revisions compare exactly", () => { + assert.equal(compareDeploymentCommits("9deb553", "9DEB553"), "current"); + assert.equal(compareDeploymentCommits("9deb553", "a415d3f"), "stale"); +}); +test("frontend evidence must belong to its own running container", () => { + const output = "projectbrain-frontend Exited 1\nother Up 1 hour 0.0.0.0:1209->80/tcp"; + assert.equal(isRunningContainer(output, "projectbrain-frontend", "1209->80/tcp"), false); +}); +test("reject similarly named or unhealthy containers", () => { + for (const row of ["projectbrain-api-old Up 1 hour", "projectbrain-api Up 1 hour (unhealthy)", "projectbrain-api Up 2 seconds (health: starting)"]) { + assert.equal(isRunningContainer(row, "projectbrain-api"), false); + } +}); +test("accept exact running containers with matching ports", () => { + assert.equal(isRunningContainer("projectbrain-frontend Up 1 hour (healthy) 0.0.0.0:1209->80/tcp\r\n", "projectbrain-frontend", "1209->80/tcp"), true); + assert.equal(isRunningContainer("projectbrain-api Up 1 hour", "projectbrain-api"), true); +}); diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs new file mode 100644 index 0000000..ea288e3 --- /dev/null +++ b/scripts/generate-openapi.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildOpenApiDocument } from "@projectbrain/contracts"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const target = resolve(root, "docs/openapi/projectbrain-v1.json"); +const generated = `${JSON.stringify(buildOpenApiDocument(), null, 2)}\n`; +if (process.argv.includes("--check")) { + const existing = await readFile(target, "utf8").catch(() => ""); + if (existing !== generated) { console.error("OpenAPI artifact is stale. Run pnpm docs:openapi."); process.exit(1); } + console.log("OpenAPI artifact matches canonical contracts."); +} else { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, generated); + console.log(`Wrote ${target}`); +} diff --git a/scripts/generate-release-metadata.mjs b/scripts/generate-release-metadata.mjs new file mode 100644 index 0000000..0a9b834 --- /dev/null +++ b/scripts/generate-release-metadata.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { resolve, relative } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const outputFlag = process.argv.indexOf("--output"); +const output = resolve(root, outputFlag >= 0 ? process.argv[outputFlag + 1] : "release-artifacts"); +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); +const command = (program, args) => execFileSync(program, args, { cwd: root, encoding: "utf8", windowsHide: true }).trim(); + +const pnpmOutput = process.env.npm_execpath + ? command(process.execPath, [process.env.npm_execpath, "list", "--prod", "--json", "--depth", "Infinity"]) + : command("pnpm", ["list", "--prod", "--json", "--depth", "Infinity"]); +const listed = JSON.parse(pnpmOutput); +const components = new Map(); +const collect = (dependencies = {}) => { + for (const [name, dependency] of Object.entries(dependencies)) { + if (dependency.version) components.set(`${name}@${dependency.version}`, { type: "library", name, version: dependency.version, purl: `pkg:npm/${encodeURIComponent(name)}@${dependency.version}` }); + collect(dependency.dependencies); + } +}; +for (const project of listed) collect(project.dependencies); +const packageJson = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); +const serialNumber = `urn:uuid:${randomUUID()}`; +const sbom = { + bomFormat: "CycloneDX", + specVersion: "1.6", + serialNumber, + version: 1, + metadata: { component: { type: "application", name: packageJson.name, version: packageJson.version }, tools: { components: [{ type: "application", name: "ProjectBrain release metadata generator", version: "1.0.0" }] } }, + components: [...components.values()].sort((left, right) => left.purl.localeCompare(right.purl)), +}; + +const artifactFiles = []; +const collectFiles = async (directory) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) await collectFiles(path); + else artifactFiles.push({ name: relative(root, path).replaceAll("\\", "/"), digest: { sha256: sha256(await readFile(path)) }, bytes: (await stat(path)).size }); + } +}; +await collectFiles(resolve(root, "dist")); +for (const name of ["Dockerfile.frontend", "Dockerfile.backend", "Dockerfile.unraid", "pnpm-lock.yaml"]) { + const path = resolve(root, name); + try { artifactFiles.push({ name, digest: { sha256: sha256(await readFile(path)) }, bytes: (await stat(path)).size }); } catch (error) { if (error.code !== "ENOENT") throw error; } +} +artifactFiles.sort((left, right) => left.name.localeCompare(right.name)); +const revision = command("git", ["rev-parse", "HEAD"]); +const provenance = { + predicateType: "https://slsa.dev/provenance/v1", + subject: artifactFiles, + predicate: { + buildDefinition: { buildType: "https://projectbrain.local/build/v1", externalParameters: { node: process.version, packageManager: packageJson.packageManager, revision }, resolvedDependencies: [{ uri: "git+local:projectbrain", digest: { sha1: revision } }] }, + runDetails: { builder: { id: "https://projectbrain.local/ci" }, metadata: { invocationId: process.env.GITHUB_RUN_ID ?? `local-${revision.slice(0, 12)}`, startedOn: new Date().toISOString(), sourceDirty: command("git", ["status", "--porcelain"]) !== "" } }, + }, + sbom: { file: "projectbrain.cdx.json", sha256: sha256(JSON.stringify(sbom)) }, +}; + +await mkdir(output, { recursive: true }); +await writeFile(resolve(output, "projectbrain.cdx.json"), `${JSON.stringify(sbom, null, 2)}\n`); +await writeFile(resolve(output, "projectbrain.provenance.json"), `${JSON.stringify(provenance, null, 2)}\n`); +console.log(`Generated ${components.size} component SBOM and provenance for ${artifactFiles.length} artifacts in ${output}.`); diff --git a/scripts/generate-validation-matrix.mjs b/scripts/generate-validation-matrix.mjs new file mode 100644 index 0000000..9779326 --- /dev/null +++ b/scripts/generate-validation-matrix.mjs @@ -0,0 +1,76 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = process.cwd(); +const packages = [ + { scope: "root", path: "package.json" }, + { scope: "backend", path: "backend/package.json" }, +]; + +const classify = (name, command) => { + const value = `${name} ${command}`.toLowerCase(); + if (value.includes("rollback")) return "rollback"; + if (value.includes("prisma migrate") || value.includes("db:deploy") || value.includes("db:migrate") || value.includes("db:status")) return "migration"; + if (value.includes("database") || value.includes("db:seed") || /^smoke:p[2-8]$/.test(name)) return "database integration"; + if (value.includes("deploy") || value.includes("docker") || value.includes("unraid")) return "deployment"; + if (value.includes("playwright") || value.includes("browser") || value.includes("clickthrough")) return "browser E2E"; + if (value.includes("axe") || value.includes("accessib")) return "accessibility"; + if (value.includes("audit") && (value.includes("security") || value.includes("secret") || value.includes("container"))) return "security"; + if (value.includes("backup") || value.includes("restore") || value.includes("recovery") || value.includes("freshness")) return "recovery"; + if (name.startsWith("smoke:")) return "API smoke"; + if (name.startsWith("test") || value.includes("vitest")) return "unit"; + if (name === "typecheck" || name === "lint" || name === "build" || name.startsWith("validate:")) return "static"; + if (name.startsWith("audit:") || name === "doctor") return "source guard"; + if (name.startsWith("release:")) return "deployment"; + return "static"; +}; + +const modeFor = (name, command, category) => { + const value = `${name} ${command}`.toLowerCase(); + if (category === "database integration" || category === "migration") return "real database"; + if (value.includes("fixture")) return "fixture"; + if (value.includes("real")) return "real API (data mode depends on runtime)"; + if (category === "source guard" || category === "static" || category === "security") return "source only"; + return "runtime-dependent"; +}; + +const rows = []; +for (const pkg of packages) { + const json = JSON.parse(readFileSync(resolve(root, pkg.path), "utf8")); + for (const [name, command] of Object.entries(json.scripts ?? {})) { + const category = classify(name, command); + rows.push({ scope: pkg.scope, name, command, category, mode: modeFor(name, command, category) }); + } +} + +rows.sort((a, b) => a.scope.localeCompare(b.scope) || a.name.localeCompare(b.name)); + +const escapeCell = (value) => String(value).replaceAll("|", "\\|").replaceAll("\n", " "); +const lines = [ + "# ProjectBrain Validation Matrix", + "", + `Generated: ${new Date().toISOString()}`, + "", + "This inventory classifies every package script. Classification describes what the command actually proves, not what its name claims. Results and artifacts remain `Not run in current matrix` until a categorized runner records them.", + "", + "## Evidence policy", + "", + "Mock, fixture and real-database evidence are never interchangeable. A source guard cannot close a runtime, database, recovery, deployment or rollback gate.", + "", + "| Scope | Script | Category | Mode/environment | Command | Latest result | Evidence | Commit |", + "| --- | --- | --- | --- | --- | --- | --- | --- |", + ...rows.map((row) => `| ${escapeCell(row.scope)} | \`${escapeCell(row.name)}\` | ${escapeCell(row.category)} | ${escapeCell(row.mode)} | \`${escapeCell(row.command)}\` | Not run in current matrix | — | — |`), + "", + "## Current release-evidence gaps", + "", + "The repository now contains unit, Fastify injection, disposable-PostgreSQL, Playwright/accessibility, recovery, secret and container-image gates. Script presence and local execution do not prove a release artifact.", + "", + "- The categorized Gitea workflow has not been observed on its remote runner for the candidate commit.", + "- Built OCI image scans, SBOM/provenance and archive digests are encoded in CI but no successful remote artifact run is attached yet.", + "- Clean-room target installation, target-host timed rollback and post-deploy telemetry remain unexecuted.", + "- Independent usability/screen-reader and operator-runbook executions remain unsigned.", + "", +]; + +writeFileSync(resolve(root, "docs/VALIDATION_MATRIX.md"), lines.join("\n"), "utf8"); +console.log(`Wrote docs/VALIDATION_MATRIX.md with ${rows.length} script classifications.`); diff --git a/scripts/legacy-deployment-migration/.gitignore b/scripts/legacy-deployment-migration/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/scripts/legacy-deployment-migration/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/legacy-deployment-migration/README.md b/scripts/legacy-deployment-migration/README.md new file mode 100644 index 0000000..ae23846 --- /dev/null +++ b/scripts/legacy-deployment-migration/README.md @@ -0,0 +1,206 @@ +# Controlled legacy deployment migration + +`plan.py` is a **pure planner**. `execute.py prepare` is an explicit opt-in, +build-only preparation command. Separate `cutover` and `recover` modes require +`--apply` and the exact verified preparation identity. Tests use fixtures/offline +Compose rendering. The first verified production migration is recorded below; +test results alone must never be presented as proof of a production cutover. + +Authoritative broker code: `Unraid-AppOps-Gateway/ops/gitea-deploy-broker/deploy.py`. +Production installs it separately at +`/mnt/pool/appdata/gitea-deploy-runners/controller/deploy.py`. Production projects +configuration is intentionally outside Git. Compare normalized source before changes. + +## Why standard retry/bootstrap cannot migrate these two + +VacatureRadar and LumaOps have healthy legacy containers but schema3 failed state +with no known live commit or artifacts. `capture_compose_rollback` rightly refuses +unattested rollback. `bootstrap-legacy` requires exact historical successful legacy +state and refuses schema3. Never invent a revision or a successful receipt. + +Standard preflight overlays live appdata, and standard migration cleanup removes +retained legacy containers. Neither is suitable for a rollback-retaining migration +with unknown original provenance. Use a reviewed separate one-shot transaction. + +## Required transaction design + +1. Pin/validate exact Gitea commit and tree. Prepare a separate clean build context; + external credentials remain outside it. Build before disrupting production. + Require immutable OCI source/revision/tree/version/time labels, not container + labels alone. Existing configurations do not enable these checks yet. +2. Acquire the same project then global locks as the broker. Revalidate original + container and immutable image IDs, healthy state, exact mount inventory, free + disk space, candidate network/ports, and absence of attempt targets. Protect the + original image with an immutable rollback reference. Persist a root-only journal + and original configuration snapshot without exposing secrets in output. +3. Stop the original, cold-copy and verify all writable data into new attempt-owned + paths. VacatureRadar runs PostgreSQL17 and Django migrations on startup: restarting + the old image on migrated data is NOT rollback. Its nested `local`, logs and media + bind mounts share one cold copy. LumaOps has bind mounts plus an anonymous `/config` + volume; include both with their nested destination semantics preserved. +4. Retain/rename the exact original container. Start only the candidate under a + distinct Compose project (`-managed`), preserving production name/ports. + The candidate mounts clones exclusively. A distinct project prevents Compose from + adopting/removing retained original containers or failing exact-container-set checks. +5. Verify immutable built image IDs, provenance and Docker health plus representative + HTTP behavior. Only then obtain the real broker receipt and commit broker config + (exact source, external env, explicit cloned data paths, new project identity, + OCI verification) and state. Keep the originals and cold-copy journal available. +6. On any failure before commit, remove only recorded attempt-created container IDs + after identity checks, restore broker configuration, and rename/restart the exact + original container with original untouched data. Verify old health. A failed restore + is reported as unknown/failed, never as healthy. Keep partial artifacts for recovery. + +Process-crash recovery needs a durable phase journal and idempotent identity checks; +exception handlers alone are insufficient. Source-tree, data, broker configuration, +runtime and receipt transitions must be separately recorded. Do not automatically +clean originals after success. Retention cleanup needs separate authorization. + +`build_plan` accepts a strict sanitized runtime projection: `name`, `containerId`, +`imageId`, `running`, `health`, `composeProject`, and `mounts` (type/source/destination/rw). +Never feed or print full Docker inspect/environment output. This planner validates +paths lexically; the executor also resolves roots, rejects symlinks/special files, +verifies backup checksums/ownership and revalidates state while locks are held. + +Run: `python -m unittest discover -s scripts/legacy-deployment-migration -p 'test_*.py'`. + +## Build-only preparation + +After the two tracked `docker-compose.migration.yml` files are reviewed and merged, +`execute.py prepare --container-id + --image-id --apply` prepares on the controller. +Without `--apply` it performs no work. It validates the reviewed broker artifact, +current branch head and original healthy container identity, then holds the normal +project/global locks while extracting an isolated exact source tree and building an +candidate image through the exact merged Compose configuration. OCI labels are +checked on its immutable image ID. Source, original runtime and mounts are rechecked +after build. Runtime environment is captured privately for Compose interpolation; +credentials remain outside the build context and are not supplied as build arguments. +Root-only journal/logs retain interrupted/failed attempts; a repeated +attempt refuses to overwrite them. No production configuration/state/data is written. + +The build-only journal is NOT a live receipt. `cutover` starts that same image using +Compose `up --no-build --pull never`, then normal broker verification binds the +observed runtime to its prepared immutable image ID. No rebuild occurs during downtime. +Only a genuine normal broker receipt can be committed. + +Tracked Compose override field contract (all required, no live-data defaults): + +- Shared: `MIGRATION_ATTEMPT`, `MIGRATION_IMAGE`, `MIGRATION_SOURCE_REVISION`, `MIGRATION_SOURCE_TREE`, + `MIGRATION_BUILD_ID`, `MIGRATION_BUILD_DATE`. +- VacatureRadar: `MIGRATION_LOCAL_ROOT`, `VACATURERADAR_ENV_FILE`. +- LumaOps: `MIGRATION_CONFIG_ROOT` (anonymous `/config` clone), + `MIGRATION_OPENRGB_ROOT`, `MIGRATION_LUMA_CONFIG_ROOT`, `MIGRATION_DATA_ROOT`, + `MIGRATION_LOGS_ROOT`, `MIGRATION_ENV_FILE`. + +Pass the protected external runtime env file as Compose `--env-file` as well, +preserving base-file interpolation values. Before cutover validate rendered absolute +mounts against the plan, reject originals, and verify `create_host_path:false` so a +missing copy cannot silently become an empty data directory. The normal broker +requires Compose files to exist in the committed tree, which is why these overrides +are tracked app files rather than a generated external override. + +## Cutover and recovery + +Use the same slug, revision, attempt and expected original IDs as preparation: +`execute.py cutover ... --apply`. Selected project configuration/state hashes, +source, image, runtime env, original IDs/health/mounts and rendered clone mounts/ports +are revalidated before stopping. Both normal deployment locks remain held throughout. + +The durable journal records intent before `stopping`, `copying`, `renaming`, +`deploying`, `verifying`, and `committing`. Cold copies use `cp -a --reflink=auto`, +filesystem sync and SHA256/ownership/mode/xattr/hardlink verification. The original +is retained under a distinct name and immutable image tag. Broker config/state and +original container configuration are saved root-only. The candidate never mounts +original data. The new broker project uses dynamic revision/tree/build placeholders +so subsequent normal deployments do not reuse frozen provenance labels. + +An ordinary failure attempts verified recovery automatically. After abrupt process +termination use `execute.py recover ... --apply`: it accepts only the saved original +or candidate config/state, checks rollback snapshot integrity, and removes only the +exact owned candidate (nonce, Compose project, recorded ID where available). It +restores original config/state, canonical name and original untouched data, then +verifies original immutable image/mounts, Docker health and HTTP readiness. Candidate +data, logs and all originals are retained. Unrelated containers/config/state or +changed original data block automatic recovery rather than be overwritten/deleted. + +Recovery after the final `committed` journal marker reverifies production and resumes activation; it +does not roll back potentially newer application writes. Recovery before that marker +restores the original, including a crash after receipt/config write but before final +journal commit. Failed recovery remains explicitly `recovery_failed`, never healthy. + +Independent review and actual prepared-image/maintenance-window checks are required +before first invocation. Health paths were probed read-only and returned HTTP 200 on +2026-09-09: VacatureRadar port1226 `/health/ready/`, LumaOps port1223 `/health/ready`. +These endpoints supplement, not replace, immutable image and normal receipt checks. + +VacatureRadar's reviewed source must contain both the supervised Celery startup hold +and first-position HTTP maintenance middleware. The helper writes an attempt-bound +`.migration-worker-hold` only into the verified cold clone before candidate start. +Ordinary traffic receives 503; exact GET/HEAD readiness/liveness probes remain available. +After durable commit, activation journals intent, removes only the matching marker, +fsyncs its directory, and records completion. Activation failure stays committed: +explicit recovery resumes release and never rolls back after workers can run. +A missing marker without durable activation intent fails closed. + +Both tracked overrides require `MIGRATION_NETWORK`, bound to the exact inspected +original `_default` network. Different/additional original networks fail closed; +the rendered candidate must retain that network. Network/device +compatibility and app-specific migration behavior still require operator review; +the helper is not a generalized transaction system for external services. + +LumaOps has no application maintenance gate in this migration. Its candidate can +serve requests and interact with RGB hardware before the receipt is committed. +Cold-data rollback restores the original application/container/data, but cannot +undo external hardware effects or preserve candidate writes from that interval. +Perform its cutover in the approved maintenance window with no concurrent user +changes; do not describe this as a zero-effect or zero-data-loss transaction. + +## Handoff safety boundary (2026-09-09) + +The guarded broker artifact expected by `execute.py` has normalized SHA256 +`b5a0467f53acba5c783781a22bf81cf99f8b96ae99535460ad6236fac038330d`. +This reviewed broker revision was installed atomically on 2026-09-09 after +AppOps PR30 passed both validations and merged as `219184267972c596e7b61bbb29f0fab1b6472c9b`. +The previous controller is retained as `deploy.py.before-single-runtime-rollback-20260909`. +The helper refuses to run until the host artifact matches. Candidate devices, capabilities, read-only root, +privilege, security options and tmpfs must match the inspected original exactly. +It rejects stale selected-project configuration after acquiring the project lock. +Preparation also records selected-project configuration and original state hashes; +cutover compares both before making any production change. + +Simulated failure tests cover interruption after original stop, after rename and +after receipt/config write before journal commit. Negative adapter tests cover +unrelated candidate nonce/project/IDs and binding broker verification to the prepared +immutable image. Offline Compose tests verify effective clone mounts and missing-input +failures. These tests do not claim a live migration or an end-to-end Docker rollback. + +The protected-main approval/release gate for the app overrides and other prerequisite +branches must be satisfied before selecting a preparation SHA. Never run preparation +against an unmerged workaround or claim these local tests prove a live migration. + +## Verified production outcome, 2026-09-09 + +VacatureRadar was prepared and committed from merged revision +`38e87678e1c5b6aa6de2cb49e49366fce8a3cbf2`, image +`sha256:c410c5ec26deb0cbd6a2992d4741e6c356f537d087de7fade74f214a747cca5a`. +The protected journal is under the controller's +`legacy-migrations/vacatureradar/legacy-20260909-a/` directory and records +`committed`, `activationIntent:true`, `activated:true`. The original container +`VacatureRadar-legacy-legacy-20260909-a` remains stopped with its original data. +Docker health, HTTP readiness, the login page and running Celery processes were +checked after activation. Normal Gitea deployment run9748 succeeded against the +same verified revision. No post-commit rollback was attempted. + +The initial helper's Docker JSON template incorrectly grouped `index` arguments. +Preparation rejected this before production changes. Revision `4d1fc0d` fixed it, +with a regression test and successful read-only qualification against real Docker. +The actual cutover used that reviewed helper revision. Three additional Linux +storage tests used isolated temporary fixtures, not application data. + +LumaOps has **not** been migrated. Its original runtime has `AUTH_ENABLED=false` +and no admin token; the merged target requires authentication on its production +network binding. Preparation stopped before building or changing production. +Provisioning secure login needs an explicit operator decision; never disable the +target's authentication guard or describe the old runtime as a verified new build. +Failed preparation artifacts are retained; no LumaOps cutover journal exists. diff --git a/scripts/legacy-deployment-migration/execute.py b/scripts/legacy-deployment-migration/execute.py new file mode 100644 index 0000000..88041e6 --- /dev/null +++ b/scripts/legacy-deployment-migration/execute.py @@ -0,0 +1,221 @@ +"""Explicit fixed-target preparation, journaled cutover and crash recovery. + +Run on the reviewed Unraid controller host. Builds a verified exact-source candidate +Preparation leaves live config/state/containers/data untouched. Cutover is a +separate --apply operation requiring a reviewed, verified preparation. Originals +and failed candidate data are retained; recover never undoes a committed migration. +""" + +import argparse +from contextlib import ExitStack +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import subprocess + +from plan import ATTEMPT, ID, SHA, TARGETS + + +BROKER_PATH = Path("/mnt/pool/appdata/gitea-deploy-runners/controller/deploy.py") +BROKER_SHA = "b5a0467f53acba5c783781a22bf81cf99f8b96ae99535460ad6236fac038330d" +JOURNAL_ROOT = Path("/mnt/pool/appdata/gitea-deploy-runners/legacy-migrations") +DOCKERFILES = {"vacatureradar": "Dockerfile.unraid", "lumaops": "Dockerfile"} + + +def durable_json(path, payload): + temporary = path.with_suffix(".tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as stream: + json.dump(payload, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def require_real_child(path, parent): + resolved = path.resolve() + root = parent.resolve() + if path.is_symlink() or resolved == root or not resolved.is_relative_to(root): + raise ValueError("Candidate path is not a private child of the fixed root") + return resolved + + +def checked(command, *, log=None, timeout=30, env=None, cwd=None): + # Do not inherit application credentials into builds or print raw subprocess output. + environment = {key: os.environ[key] for key in ("PATH", "HOME", "DOCKER_CONFIG") if key in os.environ} + result = subprocess.run(command, env=environment if env is None else env, cwd=cwd, stdout=log or subprocess.PIPE, + stderr=log or subprocess.PIPE, text=log is None, + timeout=timeout, check=False) + if result.returncode: + raise RuntimeError("Bounded preparation command failed; inspect the protected attempt log") + return result.stdout if log is None else "" + + +def docker_json_template(fields): + # Go templates otherwise pass index/map/key as three separate json args. + return "\n".join("{{json (" + field + ")}}" for field in fields) + + +def original_identity(name, runner=checked): + fields = [".Id", ".Image", ".State.Running", ".State.Health.Status", + 'index .Config.Labels "com.docker.compose.project"'] + template = docker_json_template(fields) + values = runner(["docker", "inspect", "--format", template, name]).splitlines() + if len(values) != len(fields): + raise ValueError("Unexpected sanitized container inspection") + return dict(zip(("containerId", "imageId", "running", "health", "composeProject"), + (json.loads(value) for value in values))) + + +def assert_original(observed, slug, container_id, image_id): + if observed != {"containerId": container_id, "imageId": image_id, + "running": True, "health": "healthy", "composeProject": slug}: + raise ValueError("Original runtime identity/health drift; preparation refused") + + +def mount_inventory(target, runner=checked): + raw = json.loads(runner(["docker", "inspect", "--format", "{{json .Mounts}}", target])) + return sorted(({"type": item["Type"], "source": item["Source"], + "destination": item["Destination"], "rw": item["RW"]} for item in raw), + key=lambda item: item["destination"]) + + +def expected_labels(metadata): + return { + "org.opencontainers.image.source": metadata["repository"], + "org.opencontainers.image.revision": metadata["revision"], + "org.opencontainers.image.source-tree": metadata["tree"], + "org.opencontainers.image.version": metadata["id"], + "org.opencontainers.image.created": metadata["builtAt"], + } + + +def verify_image(image, metadata, runner=checked): + image_id = runner(["docker", "image", "inspect", "--format", "{{.Id}}", image]).strip() + if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id): + raise ValueError("Candidate immutable image identity is invalid") + for key, expected in expected_labels(metadata).items(): + actual = runner(["docker", "image", "inspect", "--format", + '{{index .Config.Labels "' + key + '"}}', image_id]).strip() + if actual != expected: + raise ValueError("Candidate immutable provenance did not verify") + return image_id + + +def prepare(broker, slug, revision, attempt, container_id, image_id, runner=checked): + if (slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt) + or not ID.fullmatch(container_id) or not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id)): + raise ValueError("Invalid fixed target, commit, attempt or expected runtime identity") + repository = f"jens/{slug}" + original_name = TARGETS[slug][0] + workspace = require_real_child(JOURNAL_ROOT / slug / attempt, JOURNAL_ROOT) + source = require_real_child(broker.EXACT_SOURCE_ROOT / f"{slug}-migration-{attempt}", broker.EXACT_SOURCE_ROOT) + with ExitStack() as locks: + locks.enter_context(broker.exclusive_lock(broker.STATE_ROOT / "locks" / f"{slug}.lock", "migration project")) + locks.enter_context(broker.exclusive_lock(broker.GLOBAL_DEPLOY_LOCK, "migration preparation build")) + # Read config only after lock acquisition, unlike starting another broker process. + project = dict(broker.load_projects()[repository]) + repo = broker.repository_path(repository, project) + broker.validate_commit(repo, str(project["branch"]), revision) + head = broker.output(["git", f"--git-dir={repo}", "rev-parse", f"refs/heads/{project['branch']}"]) + if head != revision: + raise ValueError("Migration candidate must be the current configured branch head") + assert_original(original_identity(original_name, runner), slug, container_id, image_id) + if workspace.exists() or source.exists(): + raise ValueError("Attempt artifacts already exist; retain them and review before a new attempt") + metadata = broker.deployment_metadata(repo, revision, repository) + if not broker.git_path_exists(repo, revision, "docker-compose.migration.yml"): + raise ValueError("Candidate commit does not contain the reviewed migration Compose contract") + if not broker.git_path_exists(repo, revision, DOCKERFILES[slug]): + raise ValueError("Candidate Dockerfile missing") + workspace.mkdir(parents=True, mode=0o700) + workspace.chmod(0o700) + journal_path = workspace / "prepare.json" + journal = {"schema": "legacy-prepare.v1", "phase": "preparing", "repository": repository, + "revision": revision, "metadata": metadata, "source": str(source), + "originalContainerId": container_id, "originalImageId": image_id, + "originalMounts": mount_inventory(container_id, runner), + "projectConfigSha256": hashlib.sha256(json.dumps(project, sort_keys=True).encode()).hexdigest(), + "stateSha256": hashlib.sha256((broker.STATE_ROOT / f"{slug}.json").read_bytes()).hexdigest(), + "originalProvenance": "unknown-legacy", "productionMutated": False} + durable_json(journal_path, journal) + try: + broker.sync_source(repo, revision, source, slug, exact=True) + if not broker.exact_source_matches_revision(repo, revision, source): + raise ValueError("Candidate source is not the exact committed tree") + from transaction import prepare_compose + candidate, command, cwd, environment, plan, nonce = prepare_compose( + broker, slug, revision, attempt, project, journal, workspace, runner) + image = environment["MIGRATION_IMAGE"] + journal.update(candidateProject=candidate, plan=plan, nonce=nonce) + journal["runtimeEnvSha256"] = hashlib.sha256((workspace / "runtime.env").read_bytes()).hexdigest() + journal.update(phase="building", candidateImage=image) + durable_json(journal_path, journal) + descriptor = os.open(workspace / "build.log", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as log: + runner(command, cwd=cwd, env=environment, log=log, timeout=3600) + log.flush() + os.fsync(log.fileno()) + immutable = verify_image(image, metadata, runner) + if not broker.exact_source_matches_revision(repo, revision, source): + raise ValueError("Candidate source changed during build") + assert_original(original_identity(original_name, runner), slug, container_id, image_id) + if mount_inventory(container_id, runner) != journal["originalMounts"]: + raise ValueError("Original mount inventory changed during preparation") + journal.update(phase="build_verified", candidateImageId=immutable) + durable_json(journal_path, journal) + return {"phase": journal["phase"], "repository": repository, "revision": revision, + "candidateImageId": immutable, "journal": str(journal_path), + "productionMutated": False, "cutoverAvailable": True} + except Exception: + journal["phase"] = "preparation_failed" + durable_json(journal_path, journal) + raise + + +def load_broker(): + if hashlib.sha256(BROKER_PATH.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != BROKER_SHA: + raise ValueError("Live broker source differs from the reviewed artifact") + spec = importlib.util.spec_from_file_location("reviewed_deploy_broker", BROKER_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=["prepare", "cutover", "recover"]) + parser.add_argument("slug", choices=sorted(TARGETS)) + parser.add_argument("revision") + parser.add_argument("attempt") + parser.add_argument("--container-id", required=True) + parser.add_argument("--image-id", required=True) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + if not args.apply: + print(json.dumps({"apply": False, "mode": args.mode, "productionMutated": False})) + return + if not hasattr(os, "geteuid") or os.geteuid() != 0: + raise SystemExit("Run only as root on the configured Unraid controller host") + try: + broker = load_broker() + if args.mode == "prepare": + result = prepare(broker, args.slug, args.revision, args.attempt, args.container_id, args.image_id) + else: + from transaction import operate + result = operate(broker, args.mode, args.slug, args.revision, args.attempt, args.container_id, args.image_id) + except Exception: + raise SystemExit("Operation failed. Inspect the protected attempt journal; do not assume production or rollback succeeded. Use explicit recover for an unfinished cutover.") from None + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/scripts/legacy-deployment-migration/plan.py b/scripts/legacy-deployment-migration/plan.py new file mode 100644 index 0000000..d9c18ac --- /dev/null +++ b/scripts/legacy-deployment-migration/plan.py @@ -0,0 +1,103 @@ +"""Pure, fail-closed migration planning; this module cannot mutate Docker or files. + +Accept only the sanitized inspect projection documented in README.md. A plan is +not deployment authorization, a snapshot, a receipt, or proof of successful backup. +""" + +import re +from pathlib import PurePosixPath + + +TARGETS = { + "vacatureradar": ("VacatureRadar", "/mnt/pool/appdata/VacatureRadar"), + "lumaops": ("lumaops", "/mnt/pool/appdata/lumaops"), +} +SHA = re.compile(r"[0-9a-f]{40}") +ID = re.compile(r"[0-9a-f]{64}") +ATTEMPT = re.compile(r"[a-z0-9][a-z0-9-]{7,47}") + + +def _under(path, parent): + value = PurePosixPath(path) + return ( + value.is_absolute() + and ".." not in value.parts + and value != PurePosixPath(parent) + and value.is_relative_to(parent) + ) + + +def build_plan(slug, revision, attempt, observed): + """Bind one target to retained runtime identity and isolated candidate data.""" + if slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt): + raise ValueError("Invalid fixed target, full revision, or attempt identifier") + expected_name, appdata = TARGETS[slug] + keys = {"name", "containerId", "imageId", "running", "health", "composeProject", "mounts"} + if set(observed) != keys: + raise ValueError("Expected only the sanitized runtime projection") + if ( + observed["name"] != expected_name + or observed["composeProject"] != slug + or not ID.fullmatch(observed["containerId"]) + or not re.fullmatch(r"sha256:[0-9a-f]{64}", observed["imageId"]) + or observed["running"] is not True + or observed["health"] != "healthy" + ): + raise ValueError("Legacy runtime identity or health does not match") + mounts = observed["mounts"] + if not mounts or len(mounts) > 16: + raise ValueError("Expected bounded persistent mounts") + destinations = set() + sources = set() + for mount in mounts: + if set(mount) != {"type", "source", "destination", "rw"}: + raise ValueError("Unexpected mount fields") + destination = mount["destination"] + if not _under(destination, "/") or destination in destinations: + raise ValueError("Invalid or duplicate mount destination") + destinations.add(destination) + source = mount["source"] + if mount["type"] == "bind": + valid = _under(source, appdata) + elif mount["type"] == "volume": + valid = bool(re.fullmatch(r"/var/lib/docker/volumes/[0-9a-f]{64}/_data", source)) + else: + valid = False + if not valid or mount["rw"] is not True: + raise ValueError("Unreviewed source, mount type, or access mode") + sources.add(source) + # Clone a parent only once; preserve nested bind destinations against the + # corresponding cloned subdirectory, not unrelated independent snapshots. + roots = sorted(source for source in sources if not any( + other != source and _under(source, other) for other in sources + )) + candidate_root = f"/mnt/pool/appdata/gitea-deploy-runners/legacy-migrations/{slug}/{attempt}" + clones = {source: f"{candidate_root}/data/{index}" for index, source in enumerate(roots)} + candidate_mounts = [] + for mount in mounts: + root = next(source for source in roots if mount["source"] == source or _under(mount["source"], source)) + relative = PurePosixPath(mount["source"]).relative_to(root) + candidate_mounts.append({ + "source": str(PurePosixPath(clones[root]) / relative), + "destination": mount["destination"], + "type": "bind", "rw": True, + }) + return { + "schema": "legacy-migration-plan.v1", "executable": False, + "repository": f"jens/{slug}", "candidateRevision": revision, + "originalProvenance": "unknown-legacy", "originalRevision": None, + "originalContainerId": observed["containerId"], "originalImageId": observed["imageId"], + "originalName": expected_name, "retainedName": f"{expected_name}-legacy-{attempt}", + "candidateComposeProject": f"{slug}-managed", + "workspace": candidate_root, + "coldCopyRoots": [{"original": root, "candidate": clones[root]} for root in roots], + "candidateMounts": candidate_mounts, + "requiredGates": [ + "exact-commit-and-tree-validated", "candidate-built-before-cutover", + "immutable-oci-provenance-verified", "project-and-global-locks-held", + "original-identity-revalidated", "legacy-stopped-before-data-copy", + "cold-copy-checksums-verified", "original-data-never-mounted-by-candidate", + "candidate-container-ids-recorded", "docker-and-http-health-verified", + "real-broker-receipt-verified", "broker-config-and-state-committed", + ], + } diff --git a/scripts/legacy-deployment-migration/storage.py b/scripts/legacy-deployment-migration/storage.py new file mode 100644 index 0000000..2d6ab6f --- /dev/null +++ b/scripts/legacy-deployment-migration/storage.py @@ -0,0 +1,96 @@ +"""Private durable journal and fail-closed cold-copy verification primitives.""" + +import hashlib +import json +import os +from pathlib import Path +import stat +import tempfile + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def private_write(path, data): + """Unique temporary files let explicit recovery proceed after process crashes.""" + path = Path(path) + if path.is_symlink(): + raise ValueError("Refusing a symlink journal/configuration target") + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent) + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def private_json(path, value): + private_write(path, json.dumps(value, sort_keys=True).encode()) + + +def dotenv(entries): + """Do not guess escaping for unsupported environment values; never log them.""" + import re + result = {} + for entry in entries: + key, separator, value = entry.partition("=") + if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key): + raise ValueError("Unsupported runtime environment entry") + if key in result or any(character in value for character in "\x00\n\r'\\"): + raise ValueError("Runtime environment needs reviewed dotenv escaping") + result[key] = value + return "".join(f"{key}='{value}'\n" for key, value in sorted(result.items())).encode() + + +def tree_manifest(root): + """Hash content plus ownership/modes/xattrs; reject symlinks and special files. + + Preserving an unknown symlink could let the candidate write original data. + Hardlink equivalence groups are compared without depending on inode numbers. + """ + root = Path(root) + if root.is_symlink() or not root.is_dir(): + raise ValueError("Cold-copy root must be a real directory") + records = {} + inodes = {} + for path in [root, *sorted(root.rglob("*"))]: + info = path.lstat() + relative = str(path.relative_to(root)) + if not stat.S_ISREG(info.st_mode) and not stat.S_ISDIR(info.st_mode): + raise ValueError("Cold-copy tree contains an unreviewed symlink/special file") + record = {"mode": stat.S_IMODE(info.st_mode), "uid": info.st_uid, "gid": info.st_gid} + if stat.S_ISREG(info.st_mode): + hasher = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + hasher.update(block) + record.update(kind="file", size=info.st_size, sha256=hasher.hexdigest()) + group = (info.st_dev, info.st_ino) + record["hardlink"] = inodes.setdefault(group, relative) + else: + record["kind"] = "directory" + if hasattr(os, "listxattr"): + record["xattrs"] = {name: digest(os.getxattr(path, name)) for name in sorted(os.listxattr(path))} + records[relative] = record + return records + + +def clone_cold(source, target, runner): + source, target = Path(source), Path(target) + if target.exists() or target.is_symlink(): + raise ValueError("Cold-copy target already exists") + before = tree_manifest(source) + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + # Destination does not exist: cp -a copies root metadata as well as children. + runner(["cp", "-a", "--reflink=auto", "--", str(source), str(target)], timeout=3600) + runner(["sync", "-f", "--", str(target)], timeout=3600) + copied = tree_manifest(target) + if before != copied or before != tree_manifest(source): + raise ValueError("Cold copy content/metadata does not match unchanged original") + return digest(json.dumps(before, sort_keys=True).encode()) diff --git a/scripts/legacy-deployment-migration/test_compose_contracts.py b/scripts/legacy-deployment-migration/test_compose_contracts.py new file mode 100644 index 0000000..7f2bdf4 --- /dev/null +++ b/scripts/legacy-deployment-migration/test_compose_contracts.py @@ -0,0 +1,84 @@ +"""Offline Compose rendering; never builds images or starts containers.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import unittest + + +PROJECTS = Path(__file__).resolve().parents[3] +CASES = { + "VacatureRadar": ("app", ["docker-compose.unraid.yml"], { + "MIGRATION_LOCAL_ROOT": "/migration-fixture/local", + "VACATURERADAR_ENV_FILE": "/migration-fixture/runtime.env", + }, {"/app/local", "/app/media", "/app/logs"}), + "LumaOps": ("lumaops", ["docker-compose.yml", "docker-compose.unraid.yml"], { + "MIGRATION_CONFIG_ROOT": "/migration-fixture/config", + "MIGRATION_OPENRGB_ROOT": "/migration-fixture/openrgb", + "MIGRATION_LUMA_CONFIG_ROOT": "/migration-fixture/luma-config", + "MIGRATION_DATA_ROOT": "/migration-fixture/data", + "MIGRATION_LOGS_ROOT": "/migration-fixture/logs", + "MIGRATION_ENV_FILE": "/migration-fixture/runtime.env", + }, {"/config", "/config/openrgb", "/config/lumaops", "/data", "/logs"}), +} + + +@unittest.skipUnless(shutil.which("docker"), "Docker Compose CLI is required; no daemon used") +class ComposeTests(unittest.TestCase): + def render(self, name, missing=None): + _, files, variables, _ = CASES[name] + environment = dict(os.environ) + for key in list(environment): + if key.startswith("MIGRATION_") or key.startswith("VACATURERADAR_"): + environment.pop(key) + environment.update({ + "MIGRATION_IMAGE": "migration-fixture:candidate", + "MIGRATION_NETWORK": name.lower() + "_default", + "MIGRATION_ATTEMPT": "fixture-migration-attempt", + "MIGRATION_SOURCE_REVISION": "a" * 40, + "MIGRATION_SOURCE_TREE": "b" * 40, + "MIGRATION_BUILD_ID": "a" * 12, + "MIGRATION_BUILD_DATE": "2026-09-09T00:00:00.000Z", + "LUMAOPS_ADMIN_TOKEN": "fixture-only-not-a-production-token-000000", + **variables, + }) + if missing: + environment.pop(missing) + command = ["docker", "compose", "--env-file", os.devnull] + for file in [*files, "docker-compose.migration.yml"]: + command.extend(["-f", file]) + command.extend(["config", "--no-env-resolution", "--no-path-resolution", "--format", "json"]) + return subprocess.run(command, cwd=PROJECTS / name, env=environment, + capture_output=True, text=True, timeout=20) + + def test_merged_mounts_are_exclusively_explicit_clones(self): + for name, (service, _, _, destinations) in CASES.items(): + with self.subTest(name=name): + result = self.render(name) + self.assertEqual(result.returncode, 0, "Fixture Compose rendering failed") + config = json.loads(result.stdout) + runtime = config["services"][service] + self.assertEqual(runtime["network_mode"], name.lower() + "_default") + self.assertEqual(config["name"], name.lower() + "-managed") + self.assertEqual({m["target"] for m in runtime["volumes"]}, destinations) + for mount in runtime["volumes"]: + self.assertEqual(mount["type"], "bind") + self.assertTrue(mount["source"].startswith("/migration-fixture/")) + self.assertFalse(mount["bind"]["create_host_path"]) + labels = runtime["build"]["labels"] + self.assertEqual(labels["org.opencontainers.image.revision"], "a" * 40) + self.assertEqual(labels["org.opencontainers.image.source-tree"], "b" * 40) + self.assertEqual(labels["org.opencontainers.image.source"], "jens/" + name.lower()) + + def test_missing_required_inputs_fail_closed(self): + for name, (_, _, variables, _) in CASES.items(): + for variable in [*variables, "MIGRATION_NETWORK", "MIGRATION_IMAGE", "MIGRATION_ATTEMPT", "MIGRATION_SOURCE_REVISION", + "MIGRATION_SOURCE_TREE", "MIGRATION_BUILD_ID", "MIGRATION_BUILD_DATE"]: + with self.subTest(name=name, variable=variable): + self.assertNotEqual(self.render(name, variable).returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/legacy-deployment-migration/test_execute.py b/scripts/legacy-deployment-migration/test_execute.py new file mode 100644 index 0000000..61ba4a4 --- /dev/null +++ b/scripts/legacy-deployment-migration/test_execute.py @@ -0,0 +1,49 @@ +import unittest + +from execute import assert_original, docker_json_template, expected_labels, original_identity, verify_image + + +class ExecuteTests(unittest.TestCase): + def test_template_groups_function_arguments_before_json_encoding(self): + self.assertEqual( + docker_json_template(['.Id', 'index .Config.Labels "com.docker.compose.project"']), + '{{json (.Id)}}\n{{json (index .Config.Labels "com.docker.compose.project")}}', + ) + + def test_inspection_never_requests_environment(self): + calls = [] + + def runner(command): + calls.append(command) + return '"' + "a" * 64 + '"\n"sha256:' + "b" * 64 + '"\ntrue\n"healthy"\n"lumaops"' + + observed = original_identity("lumaops", runner) + assert_original(observed, "lumaops", "a" * 64, "sha256:" + "b" * 64) + self.assertNotIn(".Config.Env", str(calls)) + self.assertEqual(calls[0][:2], ["docker", "inspect"]) + + def test_runtime_drift_blocks(self): + with self.assertRaises(ValueError): + assert_original({}, "lumaops", "a" * 64, "sha256:" + "b" * 64) + + def test_image_labels_are_checked_on_immutable_id(self): + metadata = {"repository": "jens/lumaops", "revision": "a" * 40, + "tree": "b" * 40, "id": "a" * 12, "builtAt": "2026-09-09T00:00:00.000Z"} + labels = expected_labels(metadata) + calls = [] + + def runner(command): + calls.append(command) + if command[-2] == "{{.Id}}": + return "sha256:" + "c" * 64 + key = command[-2].split('"')[1] + return labels[key] + + self.assertEqual(verify_image("candidate", metadata, runner), "sha256:" + "c" * 64) + self.assertTrue(all(command[-1] == "sha256:" + "c" * 64 for command in calls[1:])) + with self.assertRaises(ValueError): + verify_image("candidate", metadata, lambda command: "invalid") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/legacy-deployment-migration/test_plan.py b/scripts/legacy-deployment-migration/test_plan.py new file mode 100644 index 0000000..aab1e8b --- /dev/null +++ b/scripts/legacy-deployment-migration/test_plan.py @@ -0,0 +1,61 @@ +import unittest + +from plan import build_plan + + +def observation(): + return { + "name": "VacatureRadar", "containerId": "a" * 64, + "imageId": "sha256:" + "b" * 64, "running": True, + "health": "healthy", "composeProject": "vacatureradar", + "mounts": [ + {"type": "bind", "source": "/mnt/pool/appdata/VacatureRadar/local", + "destination": "/app/local", "rw": True}, + {"type": "bind", "source": "/mnt/pool/appdata/VacatureRadar/local/logs", + "destination": "/app/logs", "rw": True}, + ], + } + + +class PlanTests(unittest.TestCase): + def test_nested_mounts_share_one_cold_copy(self): + plan = build_plan("vacatureradar", "c" * 40, "migration-20260909", observation()) + self.assertEqual(len(plan["coldCopyRoots"]), 1) + self.assertTrue(plan["candidateMounts"][1]["source"].endswith("/data/0/logs")) + self.assertFalse(plan["executable"]) + self.assertIsNone(plan["originalRevision"]) + self.assertEqual(plan["originalProvenance"], "unknown-legacy") + self.assertNotEqual(plan["candidateComposeProject"], "vacatureradar") + + def test_rejects_runtime_drift_and_unsafe_paths(self): + for key, value in [("name", "other"), ("containerId", "short"), + ("running", False), ("health", "starting")]: + observed = observation() + observed[key] = value + with self.subTest(key=key), self.assertRaises(ValueError): + build_plan("vacatureradar", "c" * 40, "migration-20260909", observed) + for source in ["/", "/mnt/pool/appdata", "/mnt/pool/appdata/VacatureRadar/../other"]: + observed = observation() + observed["mounts"][0]["source"] = source + with self.subTest(source=source), self.assertRaises(ValueError): + build_plan("vacatureradar", "c" * 40, "migration-20260909", observed) + + def test_rejects_secret_bearing_full_inspect(self): + observed = observation() + observed["Config"] = {"Env": ["fixture-only"]} + with self.assertRaises(ValueError): + build_plan("vacatureradar", "c" * 40, "migration-20260909", observed) + + def test_anonymous_volume_is_cloned_not_reused(self): + observed = observation() + observed.update(name="lumaops", composeProject="lumaops", mounts=[ + {"type": "volume", "source": "/var/lib/docker/volumes/" + "d" * 64 + "/_data", + "destination": "/config", "rw": True}, + ]) + plan = build_plan("lumaops", "c" * 40, "migration-20260909", observed) + self.assertEqual(plan["candidateMounts"][0]["type"], "bind") + self.assertNotIn("/var/lib/docker/volumes/", plan["candidateMounts"][0]["source"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/legacy-deployment-migration/test_storage.py b/scripts/legacy-deployment-migration/test_storage.py new file mode 100644 index 0000000..7bb6643 --- /dev/null +++ b/scripts/legacy-deployment-migration/test_storage.py @@ -0,0 +1,62 @@ +"""Linux filesystem qualification with isolated temporary data, never appdata.""" +import os +from pathlib import Path +import stat +import subprocess +import tempfile +import unittest + +from storage import clone_cold, private_write, tree_manifest + + +@unittest.skipUnless(os.name == "posix", "Linux ownership/fsync qualification") +class StorageTests(unittest.TestCase): + def test_private_replacement_is_durable_and_private(self): + with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory: + path = Path(directory) / "journal.json" + private_write(path, b"first") + private_write(path, b"second") + self.assertEqual(path.read_bytes(), b"second") + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + target = Path(directory) / "linked" + target.symlink_to(path) + with self.assertRaises(ValueError): + private_write(target, b"must not replace") + self.assertEqual(path.read_bytes(), b"second") + + def test_real_cold_copy_preserves_content_modes_and_hardlinks(self): + with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory: + source = Path(directory) / "original" + source.mkdir(mode=0o750) + payload = source / "database" + payload.write_bytes(b"representative fixture data") + payload.chmod(0o640) + os.link(payload, source / "hardlink") + destination = Path(directory) / "candidate" + + def runner(command, **kwargs): + subprocess.run(command, check=True, capture_output=True, **kwargs) + + checksum = clone_cold(source, destination, runner) + self.assertEqual(tree_manifest(source), tree_manifest(destination)) + self.assertEqual(len(checksum), 64) + self.assertNotEqual(payload.stat().st_ino, (destination / "database").stat().st_ino) + (destination / "database").write_bytes(b"candidate mutation") + self.assertEqual(payload.read_bytes(), b"representative fixture data") + with self.assertRaises(ValueError): + clone_cold(source, destination, runner) + + def test_special_files_are_rejected_before_copy(self): + with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory: + root = Path(directory) + (root / "link").symlink_to("/tmp") + with self.assertRaises(ValueError): + tree_manifest(root) + (root / "link").unlink() + os.mkfifo(root / "fifo") + with self.assertRaises(ValueError): + tree_manifest(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/legacy-deployment-migration/test_transaction.py b/scripts/legacy-deployment-migration/test_transaction.py new file mode 100644 index 0000000..96f018e --- /dev/null +++ b/scripts/legacy-deployment-migration/test_transaction.py @@ -0,0 +1,289 @@ +"""No Docker/network calls: exercise crash phases and fail-closed recovery gates.""" + +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from storage import clone_cold, digest, dotenv, tree_manifest +from transaction import (Production, STEPS, candidate_project, + recover_transaction, run_transaction, validate_runtime_safety) + + +class Crash(BaseException): + pass + + +class FakeOperations: + def __init__(self, fail=None, crash=False): + self.journal = {"phase": "snapshotted"} + self.fail, self.crash = fail, crash + self.original_running, self.renamed, self.candidate = True, False, False + self.state, self.config = "original", "original" + self.clones = False + self.foreign = False + self.saved = [] + + def save(self, phase): + self.saved.append(phase) + self.journal["phase"] = phase + + def event(self, name): + if self.fail == name: + raise Crash() if self.crash else ValueError("fixture failure") + + def stopping(self): + self.original_running = False + self.event("after_stop") + + def copying(self): + assert not self.original_running + self.clones = True + self.event("after_copy") + + def renaming(self): + self.renamed = True + self.event("after_rename") + + def deploying(self): + self.candidate = True + self.event("after_deploy") + + def verifying(self): + self.event("during_verify") + + def committing(self): + self.state = "candidate" + self.event("after_receipt") + self.config = "candidate" + self.event("after_config") + + def recover(self): + if self.foreign: + raise ValueError("foreign canonical container") + self.candidate = False + self.state = self.config = "original" + self.renamed = False + self.original_running = True + + def verify_committed(self): + assert self.state == self.config == "candidate" + + def original(self, healthy=False): + assert self.original_running + + def http_health(self): + pass + + def activate(self): + assert self.journal["phase"] == "committed" + self.event("during_activation") + self.journal["activated"] = True + + +class TransactionTests(unittest.TestCase): + def test_runtime_safety_parity_rejects_each_security_or_device_drift(self): + host = {"Devices": [{"PathOnHost": "/dev/example", "PathInContainer": "/dev/example", "CgroupPermissions": "rwm"}], + "CapAdd": ["CAP_CHOWN"], "CapDrop": ["ALL"], "ReadonlyRootfs": True, + "SecurityOpt": ["no-new-privileges:true"], "Tmpfs": {"/tmp": "size=128m"}} + service = {"devices": [{"source": "/dev/example", "target": "/dev/example", "permissions": "rwm"}], + "cap_add": ["CHOWN"], "cap_drop": ["ALL"], "read_only": True, + "security_opt": ["no-new-privileges:true"], "tmpfs": ["/tmp:size=128m"]} + validate_runtime_safety(host, service) + for key, value in (("devices", []), ("cap_add", []), ("cap_drop", []), ("read_only", False), + ("privileged", True), ("security_opt", []), ("tmpfs", [])): + with self.subTest(key=key), self.assertRaises(ValueError): + validate_runtime_safety(host, {**service, key: value}) + + def test_activation_failure_never_rolls_back_and_recovery_resumes(self): + ops = FakeOperations(fail="during_activation") + with self.assertRaises(ValueError): + run_transaction(ops) + self.assertEqual(ops.journal["phase"], "committed") + self.assertTrue(ops.candidate) + self.assertFalse(ops.original_running) + ops.fail = None + self.assertEqual(recover_transaction(ops), "committed") + self.assertTrue(ops.journal["activated"]) + + def test_real_marker_release_requires_matching_nonce_and_resumes_after_unlink(self): + with tempfile.TemporaryDirectory() as temporary: + ops = Production.__new__(Production) + ops.workspace = Path(temporary).resolve() + root = ops.workspace / "local" + root.mkdir() + ops.slug = "vacatureradar" + ops.plan = {"candidateMounts": [{"destination": "/app/local", "source": str(root)}]} + ops.journal = {"phase": "committed", "nonce": "correct", "candidateContainerId": "candidate"} + ops.verify_committed = lambda: None + ops.assert_candidate = lambda value: None + ops.save = lambda phase: None + marker = ops.hold_path() + marker.write_bytes(b"wrong") + with patch("transaction.private_read", side_effect=lambda path: path.read_bytes()): + with self.assertRaises(ValueError): + ops.activate() + self.assertTrue(marker.exists()) + marker.write_bytes(ops.hold_bytes()) + with patch("transaction.os.open", return_value=123), patch("transaction.os.fsync"), patch("transaction.os.close"): + ops.activate() + self.assertFalse(marker.exists()) + self.assertTrue(ops.journal["activationIntent"]) + ops.activate() + self.assertTrue(ops.journal["activated"]) + ops.journal.pop("activationIntent") + with self.assertRaises(ValueError): + ops.activate() + + def test_intent_precedes_each_successful_phase(self): + ops = FakeOperations() + run_transaction(ops) + self.assertEqual(ops.saved, [*STEPS, "committed"]) + self.assertFalse(ops.original_running) + self.assertTrue(ops.renamed and ops.clones and ops.candidate) + self.assertEqual(recover_transaction(ops), "committed") + self.assertTrue(ops.candidate, "Recovery cannot discard writes after a committed migration") + + def test_each_failure_restores_original_and_keeps_clones(self): + for failure in ("after_stop", "after_copy", "after_rename", "after_deploy", + "during_verify", "after_receipt", "after_config"): + with self.subTest(failure=failure): + ops = FakeOperations(fail=failure) + with self.assertRaisesRegex(RuntimeError, "original runtime restored"): + run_transaction(ops) + self.assertTrue(ops.original_running) + self.assertEqual(ops.state, "original") + self.assertEqual(ops.config, "original") + self.assertFalse(ops.candidate) + self.assertEqual(ops.clones, failure != "after_stop") + self.assertEqual(recover_transaction(ops), "rolled_back") + + def test_abrupt_crash_after_stop_rename_and_receipt_requires_explicit_recovery(self): + for failure in ("after_stop", "after_rename", "after_receipt", "after_config"): + with self.subTest(failure=failure): + ops = FakeOperations(fail=failure, crash=True) + with self.assertRaises(Crash): + run_transaction(ops) + self.assertNotEqual(ops.journal["phase"], "committed") + self.assertEqual(recover_transaction(ops), "rolled_back") + self.assertTrue(ops.original_running) + + def test_foreign_runtime_blocks_automatic_recovery(self): + ops = FakeOperations(fail="during_verify") + ops.foreign = True + with self.assertRaisesRegex(RuntimeError, "requires explicit recovery"): + run_transaction(ops) + self.assertEqual(ops.journal["phase"], "recovery_failed") + self.assertTrue(ops.candidate) + + def test_production_candidate_gate_rejects_wrong_nonce_project_and_replaced_id(self): + ops = Production.__new__(Production) + ops.prepared = {"originalContainerId": "original"} + ops.plan = {"candidateComposeProject": "lumaops-managed"} + ops.journal = {"nonce": "expected", "candidateContainerId": "candidate"} + ops.runner = object() + for fields in (["candidate", "image", "lumaops-managed", "wrong"], + ["candidate", "image", "foreign-project", "expected"], + ["original", "image", "lumaops-managed", "expected"], + ["replacement", "image", "lumaops-managed", "expected"]): + with self.subTest(fields=fields), patch("transaction.inspect_fields", return_value=fields): + with self.assertRaises(ValueError): + ops.assert_candidate(fields[0]) + + def test_future_broker_metadata_remains_dynamic(self): + original = {"source_dir": "/old", "compose_files": ["docker-compose.unraid.yml"], + "clean_untracked_paths": ["old"], "deploy_script": "old.sh"} + prepared = {"source": "/exact/new"} + plan = {"repository": "jens/vacatureradar", "candidateComposeProject": "vacatureradar-managed", + "candidateMounts": [{"source": "/clone/local", "destination": "/app/local"}]} + project = candidate_project(original, prepared, plan, "nonce", Path("/private/runtime.env")) + self.assertTrue(project["exact_source"] and project["verify_oci_revision"]) + self.assertNotIn("deploy_script", project) + self.assertEqual(project["environment"]["MIGRATION_SOURCE_REVISION"], "{revision}") + self.assertEqual(project["environment"]["MIGRATION_SOURCE_TREE"], "{tree}") + self.assertEqual(project["environment"]["MIGRATION_BUILD_ID"], "{build_id}") + self.assertEqual(project["environment"]["MIGRATION_BUILD_DATE"], "{build_date}") + + def test_dotenv_is_literal_and_rejects_unsupported_escaping(self): + self.assertEqual(dotenv(["KEY=has $literal #value"]), b"KEY='has $literal #value'\n") + for entries in (["KEY=one", "KEY=two"], ["BAD KEY=value"], ["KEY=has'quote"], ["KEY=line\nbreak"]): + with self.assertRaises(ValueError): + dotenv(entries) + + def test_manifest_detects_content_or_mode_change(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + file = root / "data" + file.write_bytes(b"fixture-only") + before = digest(json.dumps(tree_manifest(root), sort_keys=True).encode()) + file.write_bytes(b"changed") + after = digest(json.dumps(tree_manifest(root), sort_keys=True).encode()) + self.assertNotEqual(before, after) + + def test_cold_copy_uses_archive_mode_and_rejects_metadata_mismatch(self): + with tempfile.TemporaryDirectory() as temporary: + source, target = Path(temporary) / "source", Path(temporary) / "target" + source.mkdir() + calls = [] + identical = {".": {"mode": 448, "uid": 999, "gid": 999}} + with patch("storage.tree_manifest", return_value=identical): + clone_cold(source, target, lambda command, **kwargs: calls.append(command)) + self.assertEqual(calls[0][:4], ["cp", "-a", "--reflink=auto", "--"]) + with patch("storage.tree_manifest", side_effect=[identical, {}]): + with self.assertRaisesRegex(ValueError, "content/metadata"): + clone_cold(source, target, lambda *args, **kwargs: None) + + def test_real_recovery_adapter_refuses_foreign_container_before_remove(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + config, state = b"{}", b'{"status":"failed"}' + for name, value in [("original-projects.json", config), ("original-state.json", state), + ("projects.json", config), ("state.json", state)]: + (workspace / name).write_bytes(value) + ops = Production.__new__(Production) + ops.workspace = workspace + ops.broker = type("Broker", (), {"CONFIG_PATH": workspace / "projects.json"})() + ops.state_path = workspace / "state.json" + ops.prepared = {"originalContainerId": "original"} + ops.plan = {"originalName": "lumaops", "candidateComposeProject": "lumaops-managed"} + ops.journal = {"originalConfigSha256": digest(config), "candidateConfigSha256": "other", + "originalStateSha256": digest(state), "nonce": "expected"} + ops.original = lambda **kwargs: {"running": False} + ops.canonical_id = lambda name: "foreign" + calls = [] + ops.runner = lambda command, **kwargs: calls.append(command) + with patch("transaction.inspect_fields", return_value=["foreign", "image", "lumaops-managed", "wrong"]): + with self.assertRaisesRegex(ValueError, "unrelated"): + ops.recover() + self.assertEqual(calls, []) + + def test_verification_binds_normal_broker_receipt_to_prepared_image(self): + ops = Production.__new__(Production) + ops.journal = {"candidateContainerId": "candidate"} + ops.slug = "lumaops" + ops.plan = {"originalName": "lumaops", "candidateMounts": []} + ops.prepared = {"metadata": {"fixture": "metadata"}, "candidateImageId": "immutable", "revision": "rev"} + ops.project, ops.repository, ops.runner = {}, "jens/lumaops", None + ops.assert_candidate = lambda value: None + ops.http_health = lambda: None + ops.save = lambda value: None + seen = [] + + class Broker: + def verify_deployment(self, project, metadata, **kwargs): + seen.append(kwargs) + return {"fixture": "real-broker-receipt"} + + def validate_recorded_receipt(self, *args): + return None + + ops.broker = Broker() + with patch("transaction.mounts", return_value=[]), patch("transaction.inspect_fields", return_value=[{"lumaops_default": {}}]): + ops.verifying() + self.assertEqual(seen, [{"expected_image_ids": {"lumaops": "immutable"}}]) + self.assertEqual(ops.journal["receipt"], {"fixture": "real-broker-receipt"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/legacy-deployment-migration/transaction.py b/scripts/legacy-deployment-migration/transaction.py new file mode 100644 index 0000000..749d7d4 --- /dev/null +++ b/scripts/legacy-deployment-migration/transaction.py @@ -0,0 +1,517 @@ +"""Journaled legacy cutover through normal broker functions; fixed two targets only. + +No provenance is written for the original runtime. Recovery retains every data +clone and original image/container. Completed migrations are never automatically +rolled back: new writes may have reached candidate data after commit. +""" + +from contextlib import ExitStack +import json +import os +from pathlib import Path +import secrets +import shutil +import time +from urllib.request import urlopen + +from execute import (JOURNAL_ROOT, assert_original, checked, docker_json_template, mount_inventory, original_identity, + require_real_child, verify_image) +from plan import ATTEMPT, SHA, TARGETS, build_plan +from storage import clone_cold, digest, dotenv, private_json, private_write, tree_manifest + + +STEPS = ("stopping", "copying", "renaming", "deploying", "verifying", "committing") + + +def run_transaction(ops): + """Write intent before every side effect, allowing recovery after abrupt exit.""" + try: + for phase in STEPS: + ops.save(phase) + getattr(ops, phase)() + ops.save("committed") + except Exception: + try: + ops.recover() + ops.save("rolled_back") + except Exception: + ops.save("recovery_failed") + raise RuntimeError("Migration failed and rollback requires explicit recovery") from None + raise RuntimeError("Migration failed; original runtime restored and candidate data retained") from None + # Outside rollback scope: releasing workers permits new external effects/data. + ops.activate() + + +def recover_transaction(ops): + if ops.journal["phase"] == "committed": + ops.verify_committed() + ops.activate() + return "committed" + if ops.journal["phase"] == "rolled_back": + ops.original(healthy=True) + ops.http_health() + return "rolled_back" + ops.recover() + ops.save("rolled_back") + return "rolled_back" + + +def inspect_fields(runner, target, expressions): + template = docker_json_template(expressions) + return [json.loads(line) for line in runner(["docker", "inspect", "--format", template, target]).splitlines()] + + +def mounts(runner, target): + raw = inspect_fields(runner, target, [".Mounts"])[0] + return sorted(({"type": item["Type"], "source": item["Source"], + "destination": item["Destination"], "rw": item["RW"]} for item in raw), + key=lambda item: item["destination"]) + + +def candidate_project(original, prepared, plan, nonce, env_path): + slug = plan["repository"].split("/")[1] + by_target = {item["destination"]: item["source"] for item in plan["candidateMounts"]} + names = ({"MIGRATION_LOCAL_ROOT": "/app/local"} if slug == "vacatureradar" else { + "MIGRATION_CONFIG_ROOT": "/config", "MIGRATION_OPENRGB_ROOT": "/config/openrgb", + "MIGRATION_LUMA_CONFIG_ROOT": "/config/lumaops", "MIGRATION_DATA_ROOT": "/data", + "MIGRATION_LOGS_ROOT": "/logs"}) + environment = {**dict(original.get("environment", {})), + **{key: by_target[target] for key, target in names.items()}, + "MIGRATION_ATTEMPT": nonce, "MIGRATION_IMAGE": f"gitea-managed/{slug}:{{revision}}", + "MIGRATION_NETWORK": slug + "_default", + "MIGRATION_SOURCE_REVISION": "{revision}", "MIGRATION_SOURCE_TREE": "{tree}", + "MIGRATION_BUILD_ID": "{build_id}", "MIGRATION_BUILD_DATE": "{build_date}", + "MIGRATION_ENV_FILE": str(env_path), "VACATURERADAR_ENV_FILE": str(env_path)} + result = dict(original) + for key in ("deploy_script", "archive_deploy_script", "manual", "clean_untracked_paths", "legacy_containers"): + result.pop(key, None) + result.update(source_dir=prepared["source"], compose_project=plan["candidateComposeProject"], + exact_source=True, verify_oci_revision=True, env_source=str(env_path), + environment=environment, + compose_files=[*original["compose_files"], "docker-compose.migration.yml"]) + return result + + +def render_contract(broker, project, prepared, plan, runner): + cwd, command = broker.compose_command(project) + environment = broker.deployment_environment(project, prepared["revision"], plan["repository"], prepared["metadata"]) + rendered = json.loads(runner(command + ["config", "--format", "json"], cwd=cwd, env=environment)) + services = rendered.get("services", {}) + if len(services) != 1: + raise ValueError("Migration supports exactly one reviewed application container") + service = next(iter(services.values())) + if service.get("container_name") != plan["originalName"]: + raise ValueError("Candidate canonical name differs from the plan") + network = plan["repository"].split("/")[1] + "_default" + original_networks = inspect_fields(runner, prepared["originalContainerId"], [".NetworkSettings.Networks"])[0] + if set(original_networks) != {network} or service.get("network_mode") != network: + raise ValueError("Candidate network must exactly preserve the reviewed original network") + if prepared.get("originalNetwork") not in {None, network}: + raise ValueError("Original network changed since preparation") + host = inspect_fields(runner, prepared["originalContainerId"], [".HostConfig"])[0] + validate_runtime_safety(host, service) + expected = {item["destination"]: item["source"] for item in plan["candidateMounts"]} + observed = {} + for item in service.get("volumes", []): + if item.get("type") != "bind" or item.get("read_only") or item.get("bind", {}).get("create_host_path") is not False: + raise ValueError("Candidate contains an unreviewed writable mount") + observed[item["target"]] = item["source"] + if observed != expected or len(observed) != len(service.get("volumes", [])): + raise ValueError("Rendered candidate mounts do not exactly match cold clones") + bindings = inspect_fields(runner, prepared["originalContainerId"], [".HostConfig.PortBindings"])[0] + old_ports = sorted((port, item.get("HostIp") or "0.0.0.0", str(item["HostPort"])) + for port, items in bindings.items() for item in items or []) + new_ports = sorted((f"{item['target']}/{item.get('protocol', 'tcp')}", item.get("host_ip") or "0.0.0.0", str(item["published"])) + for item in service.get("ports", [])) + if old_ports != new_ports: + raise ValueError("Candidate published ports differ from the original runtime") + if service.get("labels", {}).get("io.itworx.migration-attempt") != environment["MIGRATION_ATTEMPT"]: + raise ValueError("Candidate ownership correlation label is absent") + return cwd, command, environment + + +def validate_runtime_safety(host, service): + """Compare nonsecret hardware/security bindings; unsupported shapes fail closed.""" + old_devices = sorted((item["PathOnHost"], item["PathInContainer"], item["CgroupPermissions"]) + for item in host.get("Devices") or []) + new_devices = sorted((item["source"], item["target"], item.get("permissions", "rwm")) + for item in service.get("devices", [])) + if old_devices != new_devices or host.get("DeviceRequests") or host.get("DeviceCgroupRules"): + raise ValueError("Candidate device contract differs or is unsupported") + for actual, desired in (("CapAdd", "cap_add"), ("CapDrop", "cap_drop")): + normalize = lambda values: sorted(value.upper().removeprefix("CAP_") for value in values or []) + if normalize(host.get(actual)) != normalize(service.get(desired)): + raise ValueError("Candidate capability contract differs") + for actual, desired in (("ReadonlyRootfs", "read_only"), ("Privileged", "privileged")): + if bool(host.get(actual)) != bool(service.get(desired)): + raise ValueError("Candidate filesystem/privilege contract differs") + if sorted(host.get("SecurityOpt") or []) != sorted(service.get("security_opt") or []): + raise ValueError("Candidate security options differ") + new_tmpfs = {} + for item in service.get("tmpfs") or []: + target, separator, options = item.partition(":") + if target in new_tmpfs: + raise ValueError("Duplicate candidate tmpfs target") + new_tmpfs[target] = options if separator else "" + if (host.get("Tmpfs") or {}) != new_tmpfs: + raise ValueError("Candidate tmpfs contract differs") + + +def prepare_compose(broker, slug, revision, attempt, original, prepared, workspace, runner): + if slug == "vacatureradar": + source = Path(prepared["source"]) + required = { + "scripts/migration_worker_gate.py": (".migration-worker-hold", "while check():"), + "deployment/unraid/supervisord.conf": ("migration_worker_gate.py worker", "migration_worker_gate.py scheduler"), + "config/settings.py": ("apps.core.middleware.MigrationMaintenanceMiddleware",), + "apps/core/middleware.py": ("class MigrationMaintenanceMiddleware", "migration_maintenance"), + } + for filename, fragments in required.items(): + content = (source / filename).read_text() + if any(fragment not in content for fragment in fragments): + raise ValueError("Exact source lacks the reviewed maintenance contract") + observed = {"name": TARGETS[slug][0], **original_identity(prepared["originalContainerId"], runner), + "mounts": prepared["originalMounts"]} + plan = build_plan(slug, revision, attempt, observed) + if Path(plan["workspace"]).resolve() != workspace: + raise ValueError("Plan workspace differs from the protected preparation directory") + nonce = secrets.token_hex(16) + env_path = workspace / "runtime.env" + entries = inspect_fields(runner, prepared["originalContainerId"], [".Config.Env"])[0] + private_write(env_path, dotenv(entries)) + prepared["originalNetwork"] = slug + "_default" + candidate = candidate_project(original, prepared, plan, nonce, env_path) + repo = broker.repository_path(plan["repository"], original) + broker.validate_project_config(repo, candidate, revision) + cwd, command, environment = render_contract(broker, candidate, prepared, plan, runner) + return candidate, command + ["build"], cwd, environment, plan, nonce + + +class Production: + def __init__(self, broker, slug, prepared, workspace, journal, runner=checked): + self.broker, self.slug, self.prepared = broker, slug, prepared + self.workspace, self.journal, self.runner = workspace, journal, runner + self.repository = f"jens/{slug}" + self.plan = journal["plan"] + self.project = dict(journal["candidateProject"]) + self.project.update(_repository_name=self.repository, + _repository_path=journal["repositoryPath"], + _deployment_metadata=prepared["metadata"]) + self.state_path = broker.STATE_ROOT / f"{slug}.json" + + def save(self, phase): + self.journal["phase"] = phase + private_json(self.workspace / "cutover.json", self.journal) + + def canonical_id(self, name): + result = self.runner(["docker", "container", "ls", "--all", "--quiet", "--no-trunc", + "--filter", f"name=^/{name}$"]).strip().splitlines() + if len(result) > 1: + raise ValueError("Ambiguous container identity") + return result[0] if result else None + + def original(self, healthy=False): + container_id = self.prepared["originalContainerId"] + observed = original_identity(container_id, self.runner) + if observed["imageId"] != self.prepared["originalImageId"] or observed["composeProject"] != self.slug: + raise ValueError("Original immutable runtime identity changed") + if mounts(self.runner, container_id) != self.journal["originalMounts"]: + raise ValueError("Original mounts changed") + networks = inspect_fields(self.runner, container_id, [".NetworkSettings.Networks"])[0] + if set(networks) != {self.slug + "_default"}: + raise ValueError("Original network changed") + if healthy: + assert_original(observed, self.slug, container_id, self.prepared["originalImageId"]) + return observed + + def stopping(self): + self.original(healthy=True) + self.runner(["docker", "stop", "--time", "60", self.prepared["originalContainerId"]], timeout=90) + if self.original()["running"]: + raise ValueError("Original did not stop") + + def copying(self): + if self.original()["running"]: + raise ValueError("Cold copy requires stopped original") + checksums = {} + for item in self.plan["coldCopyRoots"]: + original = Path(item["original"]) + if original.is_symlink() or original.resolve() != original: + raise ValueError("Unreviewed symlink in original cold-copy root") + target = require_real_child(Path(item["candidate"]), self.workspace) + checksums[item["original"]] = clone_cold(original, target, self.runner) + self.journal["coldCopyChecksums"] = checksums + self.save("copying") + if self.slug == "vacatureradar": + marker = self.hold_path() + if marker.exists() or marker.is_symlink(): + raise ValueError("Cold clone already contains a maintenance hold") + private_write(marker, self.hold_bytes()) + + def hold_path(self): + roots = [item["source"] for item in self.plan["candidateMounts"] + if item["destination"] == "/app/local"] + if len(roots) != 1: + raise ValueError("Maintenance hold requires one exact cloned local root") + return require_real_child(Path(roots[0]) / ".migration-worker-hold", self.workspace) + + def hold_bytes(self): + return ("legacy-migration.v1:" + self.journal["nonce"] + "\n").encode() + + def verify_hold(self): + if self.slug == "vacatureradar" and private_read(self.hold_path()) != self.hold_bytes(): + raise ValueError("Maintenance hold identity does not match this attempt") + + def activate(self): + if self.journal.get("phase") != "committed": + raise ValueError("Activation requires a durable committed transaction") + self.verify_committed() + self.assert_candidate(self.journal["candidateContainerId"]) + if self.slug == "vacatureradar": + marker = self.hold_path() + if marker.exists() or marker.is_symlink(): + self.verify_hold() + self.journal["activationIntent"] = True + self.save("committed") + marker.unlink() + descriptor = os.open(marker.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + elif not self.journal.get("activationIntent"): + raise ValueError("Maintenance hold disappeared before activation intent") + self.journal["activated"] = True + self.save("committed") + + def renaming(self): + if self.canonical_id(self.plan["retainedName"]): + raise ValueError("Retained container name is already occupied") + if self.canonical_id(self.plan["originalName"]) != self.prepared["originalContainerId"]: + raise ValueError("Canonical container identity changed") + self.runner(["docker", "rename", self.prepared["originalContainerId"], self.plan["retainedName"]]) + + def deploying(self): + self.verify_hold() + if self.canonical_id(self.plan["originalName"]): + raise ValueError("Canonical name was claimed before candidate deployment") + # True first deploy relative to the distinct managed Compose project. + if self.broker.capture_compose_rollback(self.project) is not None: + raise ValueError("Candidate project is not a first deployment") + self.broker.revalidate_deployment_candidate(Path(self.journal["repositoryPath"]), self.project, + self.prepared["revision"], self.repository, self.prepared["metadata"]) + cwd, command, environment = render_contract(self.broker, self.project, self.prepared, self.plan, self.runner) + image_id = verify_image(environment["MIGRATION_IMAGE"], self.prepared["metadata"], self.runner) + if image_id != self.prepared["candidateImageId"]: + raise ValueError("Prepared image tag changed before candidate start") + self.runner(command + ["up", "--detach", "--no-build", "--pull", "never"], + cwd=cwd, env=environment, timeout=180) + container_id = self.canonical_id(self.plan["originalName"]) + if not container_id: + raise ValueError("Candidate deployment produced no canonical container") + self.assert_candidate(container_id) + self.journal["candidateContainerId"] = container_id + self.save("deploying") + + def assert_candidate(self, container_id): + values = inspect_fields(self.runner, container_id, [".Id", ".Image", + 'index .Config.Labels "com.docker.compose.project"', + 'index .Config.Labels "io.itworx.migration-attempt"']) + if (values[0] != container_id or container_id == self.prepared["originalContainerId"] + or values[2] != self.plan["candidateComposeProject"] or values[3] != self.journal["nonce"]): + raise ValueError("Refusing an unrelated candidate container") + recorded = self.journal.get("candidateContainerId") + if recorded and recorded != container_id: + raise ValueError("Recorded candidate container was replaced") + + def http_health(self): + path = "http://127.0.0.1:1226/health/ready/" if self.slug == "vacatureradar" else "http://127.0.0.1:1223/health/ready" + with urlopen(path, timeout=10) as response: + if response.status != 200 or response.geturl() != path: + raise ValueError("Representative readiness probe failed") + + def verifying(self): + self.verify_hold() + self.assert_candidate(self.journal["candidateContainerId"]) + expected = sorted(self.plan["candidateMounts"], key=lambda item: item["destination"]) + if mounts(self.runner, self.journal["candidateContainerId"]) != expected: + raise ValueError("Candidate mounts differ from the isolated cold-copy plan") + networks = inspect_fields(self.runner, self.journal["candidateContainerId"], [".NetworkSettings.Networks"])[0] + if set(networks) != {self.slug + "_default"}: + raise ValueError("Candidate runtime network differs from the original") + receipt = self.broker.verify_deployment(self.project, self.prepared["metadata"], + expected_image_ids={self.plan["originalName"]: self.prepared["candidateImageId"]}) + self.broker.validate_recorded_receipt(self.project, receipt, self.repository, self.prepared["revision"]) + self.http_health() + self.journal["receipt"] = receipt + self.save("verifying") + + def committing(self): + self.broker.write_state(self.repository, self.project, self.prepared["revision"], "success", + "Controlled legacy migration passed immutable image and readiness verification", + live_receipt=self.journal["receipt"], attempt_metadata=self.prepared["metadata"]) + # Never alter another project's configuration. Both deployment locks held. + current = self.broker.CONFIG_PATH.read_bytes() + if digest(current) != self.journal["originalConfigSha256"]: + raise ValueError("Broker configuration changed during cutover") + private_write(self.broker.CONFIG_PATH, (self.workspace / "candidate-projects.json").read_bytes()) + self.verify_committed() + + def verify_committed(self): + if digest(self.broker.CONFIG_PATH.read_bytes()) != self.journal["candidateConfigSha256"]: + raise ValueError("Committed broker configuration no longer matches") + receipt = self.broker.verified_current_receipt(self.repository, self.project, + self.prepared["revision"], self.prepared["metadata"]["tree"]) + if receipt is None: + raise ValueError("Committed runtime receipt does not reverify") + self.http_health() + + def recover(self): + if self.journal.get("phase") == "committed" or self.journal.get("activationIntent"): + raise ValueError("Committed activation must never roll back") + for filename, key in (("original-projects.json", "originalConfigSha256"), + ("original-state.json", "originalStateSha256")): + if digest((self.workspace / filename).read_bytes()) != self.journal[key]: + raise ValueError("Protected rollback snapshot integrity failed") + config = self.broker.CONFIG_PATH.read_bytes() + if digest(config) not in {self.journal["originalConfigSha256"], self.journal["candidateConfigSha256"]}: + raise ValueError("Unrelated broker configuration change blocks automatic recovery") + state = self.state_path.read_bytes() + if digest(state) != self.journal["originalStateSha256"]: + payload = json.loads(state) + if payload.get("live_receipt") != self.journal.get("receipt") or not self.journal.get("receipt"): + raise ValueError("Unrelated deployment state blocks automatic recovery") + self.original() + if not self.journal.get("originalRestartIntent"): + for source, expected in self.journal.get("coldCopyChecksums", {}).items(): + if digest(json.dumps(tree_manifest(source), sort_keys=True).encode()) != expected: + raise ValueError("Original data changed after verified cold copy") + container_id = self.canonical_id(self.plan["originalName"]) + if container_id and container_id != self.prepared["originalContainerId"]: + self.assert_candidate(container_id) + # Persist discovered ownership before removing a crash-created container. + self.journal["candidateContainerId"] = container_id + self.save("recovering") + self.runner(["docker", "rm", "--force", container_id], timeout=90) + if self.canonical_id(self.plan["originalName"]) not in {None, self.prepared["originalContainerId"]}: + raise ValueError("Canonical name is occupied by unrelated runtime") + current_name = inspect_fields(self.runner, self.prepared["originalContainerId"], [".Name"])[0].lstrip("/") + if current_name not in {self.plan["originalName"], self.plan["retainedName"]}: + raise ValueError("Original runtime was renamed by another operator") + if current_name != self.plan["originalName"]: + self.runner(["docker", "rename", self.prepared["originalContainerId"], self.plan["originalName"]]) + private_write(self.broker.CONFIG_PATH, (self.workspace / "original-projects.json").read_bytes()) + private_write(self.state_path, (self.workspace / "original-state.json").read_bytes()) + self.journal["originalRestartIntent"] = True + self.save("restoring_original") + if not self.original()["running"]: + self.runner(["docker", "start", self.prepared["originalContainerId"]], timeout=90) + deadline = time.monotonic() + 240 + while time.monotonic() < deadline: + try: + self.original(healthy=True) + self.http_health() + return + except (ValueError, OSError): + time.sleep(3) + raise ValueError("Original runtime recovery health did not verify") + + +def private_read(path): + import os + import stat + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & 0o077: + raise ValueError("Expected a root-only regular transaction file") + return path.read_bytes() + + +def operate(broker, mode, slug, revision, attempt, container_id, image_id, runner=checked): + if mode not in {"cutover", "recover"} or slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt): + raise ValueError("Invalid fixed migration operation") + workspace = require_real_child(JOURNAL_ROOT / slug / attempt, JOURNAL_ROOT) + repository = f"jens/{slug}" + with ExitStack() as locks: + locks.enter_context(broker.exclusive_lock(broker.STATE_ROOT / "locks" / f"{slug}.lock", "migration project")) + locks.enter_context(broker.exclusive_lock(broker.GLOBAL_DEPLOY_LOCK, "migration production")) + prepared_bytes = private_read(workspace / "prepare.json") + prepared = json.loads(prepared_bytes) + if (prepared.get("phase") != "build_verified" or prepared.get("repository") != repository + or prepared.get("revision") != revision or prepared.get("originalContainerId") != container_id + or prepared.get("originalImageId") != image_id): + raise ValueError("Preparation identity does not match the requested operation") + journal_path = workspace / "cutover.json" + if mode == "recover": + journal = json.loads(private_read(journal_path)) + if journal.get("preparedSha256") != digest(prepared_bytes): + raise ValueError("Prepared artifact changed after transaction snapshot") + ops = Production(broker, slug, prepared, workspace, journal, runner) + phase = recover_transaction(ops) + return {"phase": phase, "repository": repository, "candidateDataRetained": True} + if journal_path.exists(): + raise ValueError("Transaction already exists; use explicit recover, never repeat cutover") + projects_bytes = broker.CONFIG_PATH.read_bytes() + projects = json.loads(projects_bytes) + original = projects[repository] + state_bytes = (broker.STATE_ROOT / f"{slug}.json").read_bytes() + if (digest(json.dumps(original, sort_keys=True).encode()) != prepared.get("projectConfigSha256") + or digest(state_bytes) != prepared.get("stateSha256")): + raise ValueError("Original broker config/state changed since preparation") + if broker.state_live_revision(json.loads(state_bytes)): + raise ValueError("This transaction only supports unattested legacy first deployments") + repo = broker.repository_path(repository, original) + broker.validate_commit(repo, str(original["branch"]), revision) + if broker.output(["git", f"--git-dir={repo}", "rev-parse", f"refs/heads/{original['branch']}"]) != revision: + raise ValueError("Prepared revision is no longer the configured branch head") + source = require_real_child(Path(prepared["source"]), broker.EXACT_SOURCE_ROOT) + if not broker.exact_source_matches_revision(repo, revision, source): + raise ValueError("Prepared source changed before cutover") + if verify_image(prepared["candidateImage"], prepared["metadata"], runner) != prepared["candidateImageId"]: + raise ValueError("Prepared immutable image changed") + assert_original(original_identity(container_id, runner), slug, container_id, image_id) + if mount_inventory(container_id, runner) != prepared["originalMounts"]: + raise ValueError("Original mounts changed after preparation") + if digest(private_read(workspace / "runtime.env")) != prepared["runtimeEnvSha256"]: + raise ValueError("Private runtime environment changed after preparation") + plan = build_plan(slug, revision, attempt, {"name": TARGETS[slug][0], + **original_identity(container_id, runner), "mounts": prepared["originalMounts"]}) + if plan != prepared["plan"]: + raise ValueError("Cold-copy plan changed after preparation") + candidate = candidate_project(original, prepared, plan, prepared["nonce"], workspace / "runtime.env") + if candidate != prepared["candidateProject"]: + raise ValueError("Candidate project differs from the build configuration") + broker.validate_project_config(repo, candidate, revision) + render_contract(broker, candidate, prepared, plan, runner) + required = 8 * 1024**3 + for item in plan["coldCopyRoots"]: + root = Path(item["original"]) + if root.is_symlink() or root.resolve() != root or not root.is_dir(): + raise ValueError("Cold-copy source is not an unchanged real directory") + required += sum(path.stat().st_size for path in root.rglob("*") if path.is_file()) + if Path(item["candidate"]).exists(): + raise ValueError("Cold-copy target already exists") + if shutil.disk_usage(workspace).free < required: + raise ValueError("Insufficient space for retained cold clones plus safety reserve") + updated = {**projects, repository: candidate} + candidate_bytes = json.dumps(updated, sort_keys=True).encode() + private_write(workspace / "original-projects.json", projects_bytes) + private_write(workspace / "original-state.json", state_bytes) + private_write(workspace / "candidate-projects.json", candidate_bytes) + # Configuration may contain secrets: stored privately, never in user output. + snapshot = inspect_fields(runner, container_id, [".Config", ".HostConfig", ".NetworkSettings.Networks"]) + private_json(workspace / "original-container.json", snapshot) + journal = {"schema": "legacy-cutover.v1", "phase": "snapshotted", "plan": plan, + "preparedSha256": digest(prepared_bytes), "candidateProject": candidate, + "repositoryPath": str(repo), "nonce": prepared["nonce"], "originalMounts": prepared["originalMounts"], + "originalConfigSha256": digest(projects_bytes), "originalStateSha256": digest(state_bytes), + "candidateConfigSha256": digest(candidate_bytes)} + ops = Production(broker, slug, prepared, workspace, journal, runner) + ops.http_health() + if ops.canonical_id(plan["retainedName"]) or broker.compose_project_container_names(plan["candidateComposeProject"]): + raise ValueError("Attempt runtime/project is already occupied") + runner(["docker", "image", "tag", image_id, f"gitea-legacy-rollback/{slug}:{prepared['nonce']}"]) + ops.save("snapshotted") + run_transaction(ops) + return {"phase": "committed", "repository": repository, "revision": revision, + "originalContainerRetained": True, "originalDataRetained": True, + "receiptVerified": True, "journal": str(journal_path)} diff --git a/scripts/projectbrain-csi-audit.mjs b/scripts/projectbrain-csi-audit.mjs new file mode 100644 index 0000000..1052fc0 --- /dev/null +++ b/scripts/projectbrain-csi-audit.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const args = new Set(process.argv.slice(2)); +const asJson = args.has("--json"); +const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); +const exists = (file) => fs.existsSync(path.join(root, file)); +const normalizePath = (file) => file.replaceAll(path.sep, "/"); +const walk = (dir, filter = () => true) => { + const out = []; + const base = path.join(root, dir); + if (!fs.existsSync(base)) return out; + const stack = [base]; + while (stack.length) { + const current = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + if (!["node_modules", "dist", ".git", ".next", "coverage"].includes(entry.name)) stack.push(full); + } else if (filter(full)) { + out.push(normalizePath(path.relative(root, full))); + } + } + } + return out.sort(); +}; + +const sourceFiles = walk("src", (file) => /\.(ts|tsx)$/.test(file)); +const backendFiles = walk("backend/src", (file) => /\.(ts|tsx)$/.test(file)); +const docs = walk("docs", (file) => file.endsWith(".md")); +const routeText = exists("src/app/routes.tsx") ? read("src/app/routes.tsx") : ""; +const allTextFiles = [...sourceFiles, ...backendFiles].filter((file) => exists(file)); +const implementationFiles = allTextFiles.filter((file) => ![ + "src/shared/lib/mock-data.ts", + "src/shared/lib/projectAudit.ts", +].includes(file)); +const countPattern = (files, pattern) => files.reduce((count, file) => count + ((read(file).match(pattern) ?? []).length), 0); +const filesWith = (files, pattern) => files.filter((file) => pattern.test(read(file))); +const firstMatches = (files, pattern, limit = 20) => { + const out = []; + for (const file of files) { + const lines = read(file).split(/\r?\n/); + for (const [index, line] of lines.entries()) { + if (pattern.test(line)) out.push({ file, line: index + 1, text: line.trim().slice(0, 180) }); + pattern.lastIndex = 0; + if (out.length >= limit) return out; + } + } + return out; +}; + +const rawConsoleMatches = firstMatches(implementationFiles, /console\./g, 50); +const containedConsoleMatches = rawConsoleMatches.filter((match) => normalizePath(match.file) === "src/shared/lib/appLogger.ts"); +const uncontainedConsoleMatches = rawConsoleMatches.filter((match) => normalizePath(match.file) !== "src/shared/lib/appLogger.ts"); +const directMockDataFiles = filesWith(implementationFiles, /mockData/).filter((file) => file !== "src/shared/lib/projectbrainSelectors.ts"); +const anyCastMatches = firstMatches(implementationFiles, /\bas any\b/g, 50); +const localhostMatches = firstMatches(implementationFiles, /localhost|127\.0\.0\.1/g, 50); +const localStorageMatches = firstMatches(implementationFiles, /localStorage/g, 50); +const auditCatalogText = exists("src/shared/lib/projectAudit.ts") ? read("src/shared/lib/projectAudit.ts") : ""; +const queuedAuditItems = (auditCatalogText.match(/queued_for_/g) ?? []).length; +const v59AuditItems = (auditCatalogText.match(/implemented_in_v59/g) ?? []).length; + +const report = { + generatedAt: new Date().toISOString(), + totals: { + frontendFiles: sourceFiles.length, + backendFiles: backendFiles.length, + docs: docs.length, + routes: (routeText.match(/ 20 && + report.signals.uncontainedConsoleUsage === 0 && + report.signals.directMockDataFiles <= 1 && + report.signals.anyCasts <= 3 && + report.signals.localhostReferences === 0 && + report.signals.queuedAuditItems === 0 && + report.gates.v59Docs; + +if (asJson) { + console.log(JSON.stringify({ pass, ...report }, null, 2)); +} else { + console.log(`ProjectBrain CSI audit: ${pass ? "PASS" : "WARN"}`); + console.log(`Frontend files: ${report.totals.frontendFiles}`); + console.log(`Backend files: ${report.totals.backendFiles}`); + console.log(`Docs: ${report.totals.docs}`); + console.log(`Routes: ${report.totals.routes}`); + console.log(`TODO/FIXME/HACK/XXX markers: ${report.signals.todoFixme}`); + console.log(`Implementation files with direct mockData usage: ${report.signals.directMockDataFiles}`); + console.log(`console.* usages: ${report.signals.consoleUsage} (${report.signals.uncontainedConsoleUsage} uncontained)`); + console.log(`localStorage usages: ${report.signals.localStorageUsage}`); + console.log(`'as any' casts: ${report.signals.anyCasts}`); + console.log(`localhost references: ${report.signals.localhostReferences}`); + console.log(`queued audit items: ${report.signals.queuedAuditItems}`); + console.log(`V59 audit completions: ${report.signals.v59AuditItems}`); + console.log(`Audit Center route: ${report.gates.auditCenterRoute ? "yes" : "no"}`); + console.log(`Audit catalog: ${report.gates.auditCatalog ? "yes" : "no"}`); + console.log(`Audit docs: ${report.gates.auditDocs ? "yes" : "no"}`); + console.log(`App logger: ${report.gates.appLogger ? "yes" : "no"}`); + console.log(`V59 docs: ${report.gates.v59Docs ? "yes" : "no"}`); + if (!pass) { + if (directMockDataFiles.length) console.log(`Direct mockData files: ${directMockDataFiles.join(", ")}`); + if (uncontainedConsoleMatches.length) console.log(`Uncontained console usage: ${uncontainedConsoleMatches.map((m) => `${m.file}:${m.line}`).join(", ")}`); + } +} + +if (!pass) process.exitCode = 1; diff --git a/scripts/projectbrain-doctor.mjs b/scripts/projectbrain-doctor.mjs new file mode 100644 index 0000000..3cf4c88 --- /dev/null +++ b/scripts/projectbrain-doctor.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = process.cwd(); +const errors = []; +const localArtifacts = []; +const info = []; +const showLocalArtifacts = + process.env.PROJECTBRAIN_AUDIT_VERBOSE_LOCAL_ARTIFACTS === "1"; + +const requireFile = (path, label = path) => { + if (!existsSync(join(root, path))) errors.push(`Missing ${label}: ${path}`); +}; + +const requireText = (path, text, label = text) => { + const fullPath = join(root, path); + if (!existsSync(fullPath)) { + errors.push(`Missing file for text check: ${path}`); + return; + } + const content = readFileSync(fullPath, "utf8"); + if (!content.includes(text)) errors.push(`Missing ${label} in ${path}`); +}; + +const warnIfExists = (path, label = path) => { + if (existsSync(join(root, path))) localArtifacts.push(`Generated or legacy artifact present: ${label}`); +}; + +const rootPackage = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const backendPackage = JSON.parse(readFileSync(join(root, "backend/package.json"), "utf8")); + +const parseWorkspaceAllowBuilds = (workspaceYaml) => { + const allowed = new Set(); + const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:"); + if (allowBuildsIndex === -1) return allowed; + const lines = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1); + for (const line of lines) { + if (/^[^\s#]/.test(line)) break; + const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/); + if (match && match[2] === "true") allowed.add(match[1].trim()); + } + return allowed; +}; +const workspaceAllowBuilds = existsSync(join(root, "pnpm-workspace.yaml")) + ? parseWorkspaceAllowBuilds(readFileSync(join(root, "pnpm-workspace.yaml"), "utf8")) + : new Set(); + +if (rootPackage.packageManager !== "pnpm@11.5.2") { + errors.push("Root package.json must pin packageManager to pnpm@11.5.2 so Docker/Corepack does not float to a different pnpm release."); +} +const requiredPnpmBuiltDeps = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"]; +for (const dependency of requiredPnpmBuiltDeps) { + if (!workspaceAllowBuilds.has(dependency)) { + errors.push(`pnpm-workspace.yaml allowBuilds must approve ${dependency} for Docker builds with pnpm 11.`); + } +} + +console.log("ProjectBrain doctor"); +console.log("==================="); + +[ + "README.md", + ".env.example", + "backend/.env.example", + "docs/PROJECTBRAIN_DOCUMENTATION_INDEX.md", + "docs/FULL_VERSION_ROADMAP.md", + "docs/VERTICAL_SLICE_CORE_FLOW_PLAN.md", + "docs/PROJECTBRAIN_STATUS_AND_GATES.md", + "docs/P2_LOCAL_RUNTIME_VERIFICATION.md", + "docs/RUNTIME_ROUND_5_BACKEND_HEALTH_AND_SUITE.md", + "docs/PROJECTBRAIN_RUNTIME_COMMANDS.md", + "backend/scripts/smoke-p2.mjs", + "backend/scripts/smoke-fixture-api.mjs", + "backend/scripts/smoke-real-read-pilot.mjs", + "backend/scripts/smoke-vertical-slice.mjs", + "backend/scripts/smoke-runtime-suite.mjs", + "backend/scripts/docker-entrypoint.sh", + "scripts/check-database-readiness.mjs", + "public/site.webmanifest", + "public/favicon.svg", + "public/favicon.ico", +].forEach((file) => requireFile(file)); + +[ + ["validate:all", "root validation"], + ["audit:release", "release hygiene audit"], + ["audit:runtime", "runtime readiness audit"], + ["audit:database", "database readiness audit"], + ["smoke:backend:fixtures", "fixture smoke"], + ["smoke:backend:real-read-pilot", "real-read pilot smoke"], + ["smoke:backend:vertical-slice", "vertical slice smoke"], + ["smoke:backend:runtime-suite", "runtime suite smoke"], + ["smoke:backend:database-suite", "database suite smoke"], + ["smoke:backend:brainstorms", "Brainstorm CPU smoke"], + ["smoke:backend:initiatives", "Initiative smoke"], +].forEach(([script, label]) => { + if (!rootPackage.scripts?.[script]) errors.push(`Missing ${label} script: ${script}`); +}); + +[ + ["smoke:p2", "P2 database smoke"], + ["smoke:fixtures", "fixture smoke"], + ["smoke:real-read-pilot", "real read smoke"], + ["smoke:vertical-slice", "vertical slice smoke"], + ["smoke:runtime-suite", "runtime suite smoke"], + ["smoke:database-suite", "database suite smoke"], + ["smoke:brainstorms", "Brainstorm CPU smoke"], + ["smoke:initiatives", "Initiative smoke"], + ["db:deploy", "Prisma migrate deploy"], + ["db:status", "Prisma migration status"], + ["db:validate", "Prisma schema validate"], +].forEach(([script, label]) => { + if (!backendPackage.scripts?.[script]) errors.push(`Missing backend ${label} script: ${script}`); +}); + +requireText("index.html", "ProjectBrain", "ProjectBrain title"); +requireText("index.html", "ProjectBrain", "ProjectBrain browser metadata"); +requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Backend health", "backend health card"); +requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Database readiness", "database readiness card"); +requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Vertical slice smoke", "vertical slice smoke card"); +requireText("src/shared/api/realClient.ts", "getHealth", "real backend health client"); +requireText("src/shared/api/realClient.ts", "getSystemReadiness", "real backend system readiness client"); +requireText("src/shared/hooks/useProjectData.ts", "useBackendHealthData", "backend health hook"); +requireText("src/shared/hooks/useProjectData.ts", "useBackendSystemReadinessData", "backend system readiness hook"); +requireText("docs/PROJECTBRAIN_STATUS_AND_GATES.md", "P2", "P2 gate documentation"); + +[ + "node_modules", + "backend/node_modules", + "dist", + "backend/dist", + "checkpoints", + "src/App.css", + "src/globals.css", +].forEach((path) => warnIfExists(path)); + +info.push(`Package manager: ${rootPackage.packageManager ?? "not pinned"}`); +info.push(`Approved pnpm workspace allowBuilds: ${Array.from(workspaceAllowBuilds).sort().join(", ") || "none"}`); +info.push(`Frontend package: ${rootPackage.name}@${rootPackage.version}`); +info.push(`Backend package: ${backendPackage.name}@${backendPackage.version}`); +info.push("Recommended local command order:"); +info.push(" 1. pnpm install"); +info.push(" 2. pnpm run audit:database"); +info.push(" 3. pnpm run validate:all"); +info.push(" 4. pnpm run audit:all"); +info.push(" 5. docker compose up -d --build"); +info.push(" 6. pnpm run smoke:backend:system-readiness"); +info.push(" 7. pnpm run smoke:backend:runtime-suite && pnpm --filter projectbrain-api run smoke:blueprints"); +info.push(" 8. pnpm run smoke:backend:database-suite"); + +for (const line of info) console.log(`INFO ${line}`); +if (showLocalArtifacts) { + for (const line of localArtifacts) console.log(`INFO ${line}`); +} +for (const line of errors) console.error(`FAIL ${line}`); + +if (errors.length) { + console.error(`\nFAIL: ${errors.length} blocking doctor issue(s) found.`); + process.exit(1); +} + +console.log("\nPASS: ProjectBrain doctor found no blocking runtime/release scaffold issues."); diff --git a/scripts/release-command-bundle.mjs b/scripts/release-command-bundle.mjs new file mode 100644 index 0000000..a290782 --- /dev/null +++ b/scripts/release-command-bundle.mjs @@ -0,0 +1,72 @@ +import { spawnSync } from "node:child_process"; + +const baseUrl = process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209"; +const projectId = process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog"; +const archiveOut = process.env.PROJECTBRAIN_PROOF_OUT; +const skipSmokes = process.argv.includes("--skip-smokes") || process.env.PROJECTBRAIN_RELEASE_SKIP_SMOKES === "1"; +const includeDeploymentProof = process.argv.includes("--deployment-proof") || process.env.PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF === "1"; +const includeDossier = process.argv.includes("--dossier") || process.env.PROJECTBRAIN_RELEASE_DOSSIER === "1"; + +const run = (label, command, args, env = {}) => { + console.log(`\n[release-bundle] ${label}`); + const result = spawnSync(command, args, { + env: { ...process.env, ...env }, + shell: process.platform === "win32", + stdio: "inherit", + }); + + if (result.status !== 0) { + throw new Error(`${label} failed with exit code ${result.status}`); + } +}; + +try { + console.log("ProjectBrain release command bundle"); + console.log(`Base URL: ${baseUrl}`); + console.log(`Project: ${projectId}`); + console.log(`Smoke mode: ${skipSmokes ? "skipped" : "enabled"}`); + console.log(`Deployment proof: ${includeDeploymentProof ? "enabled" : "skipped"}`); + console.log(`Evidence dossier: ${includeDossier ? "enabled" : "skipped"}`); + + const proofEnv = { + PROJECTBRAIN_SMOKE_URL: baseUrl, + PROJECTBRAIN_SMOKE_PROJECT: projectId, + PROJECTBRAIN_PROOF_WRITE: "1", + PROJECTBRAIN_PROOF_RUN_SMOKES: skipSmokes ? "0" : "1", + }; + if (archiveOut) proofEnv.PROJECTBRAIN_PROOF_OUT = archiveOut; + + run("Create release proof snapshot", "pnpm", ["run", "release:proof-report"], proofEnv); + run("Index release proof snapshots", "pnpm", ["run", "release:proof-index", "--", "--limit=5"], { + PROJECTBRAIN_SMOKE_PROJECT: projectId, + }); + run("Verify release proof freshness", "pnpm", ["run", "release:proof-freshness"], { + PROJECTBRAIN_SMOKE_PROJECT: projectId, + }); + run("Create release signoff file", "pnpm", ["run", "release:signoff-file"], { + PROJECTBRAIN_SMOKE_PROJECT: projectId, + }); + if (includeDeploymentProof) { + run("Create remote deployment proof", "pnpm", ["run", "release:deployment-proof"], { + PROJECTBRAIN_DEPLOY_URL: baseUrl, + PROJECTBRAIN_DEPLOY_PROOF_WRITE: "1", + }); + } + if (includeDossier) { + run("Create release evidence dossier", "pnpm", ["run", "release:dossier"], { + PROJECTBRAIN_SMOKE_PROJECT: projectId, + }); + } + + console.log("\nRelease command bundle passed."); + console.log(includeDossier + ? "Release proof is fresh, signoff and deployment proof passed, and a release evidence dossier was written." + : includeDeploymentProof + ? "Release proof is fresh, a signoff file was written and remote deployment proof passed." + : "Release proof is fresh for the current commit and a signoff file was written."); +} catch (error) { + console.error("\nRelease command bundle failed:"); + console.error(error instanceof Error ? error.message : String(error)); + console.error("\nNext step: rerun with live URL and smokes enabled, then inspect the proof report above."); + process.exitCode = 1; +} diff --git a/scripts/release-deployment-proof.mjs b/scripts/release-deployment-proof.mjs new file mode 100644 index 0000000..3e85bc6 --- /dev/null +++ b/scripts/release-deployment-proof.mjs @@ -0,0 +1,151 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { compareDeploymentCommits, isRunningContainer } from "./deployment-proof-state.mjs"; + +const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209"; +const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1"; +const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain"; +const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy"); +const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_DEPLOY_PROOF_WRITE === "1"; +const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out=")); +const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_DEPLOY_PROOF_OUT; + +const run = (command, args) => spawnSync(command, args, { + encoding: "utf8", + shell: false, + timeout: 10_000, +}); + +const getGitCommit = () => { + const result = run("git", ["rev-parse", "--short", "HEAD"]); + return result.status === 0 ? result.stdout.trim() : "unknown"; +}; + +const createDefaultProofPath = (commit) => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `checkpoints/deployment-proof/projectbrain-deployment-proof-${commit}-${stamp}.md`; +}; + +const checkHttp = async (urlPath) => { + try { + const response = await fetch(`${baseUrl}${urlPath}`, { signal: AbortSignal.timeout(10_000) }); + const body = await response.text(); + + return { + ok: response.ok, + status: String(response.status), + body: body.slice(0, 260).replace(/\s+/g, " ").trim(), + }; + } catch (error) { + return { + ok: false, + status: "unreachable", + body: error instanceof Error ? error.message : String(error), + }; + } +}; + +const runSsh = (remoteCommand) => { + if (!fs.existsSync(sshKey)) { + return { + ok: false, + status: "missing-key", + stdout: "", + stderr: `SSH key not found: ${sshKey}`, + }; + } + + const result = spawnSync("ssh", [ + "-i", + sshKey, + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + sshTarget, + remoteCommand, + ], { + encoding: "utf8", + shell: false, + timeout: 20_000, + }); + + return { + ok: result.status === 0, + status: String(result.status), + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() || result.error?.message || "", + }; +}; + +const currentCommit = getGitCommit(); +const health = await checkHttp("/api/health"); +const shellProof = runSsh([ + `echo deployed_commit=$(git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown)`, + "echo containers_begin", + "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep projectbrain || true", + "echo containers_end", +].join(" && ")); + +const deployedCommitMatch = shellProof.stdout.match(/^deployed_commit=(.+)$/m); +const deployedCommit = deployedCommitMatch?.[1]?.trim() ?? "unknown"; +const containerBlock = shellProof.stdout.match(/containers_begin\n([\s\S]*?)\n?containers_end/)?.[1]?.trim() ?? ""; +const hasFrontend = isRunningContainer(containerBlock, "projectbrain-frontend", "1209->80/tcp"); +const hasApi = isRunningContainer(containerBlock, "projectbrain-api"); +const hasPostgres = isRunningContainer(containerBlock, "projectbrain-postgres"); +const commitFreshness = compareDeploymentCommits(currentCommit, deployedCommit); +const verdict = health.ok && shellProof.ok && commitFreshness === "current" && hasFrontend && hasApi && hasPostgres + ? "PASS" + : "ATTENTION REQUIRED"; +const proofPath = explicitOutputPath || createDefaultProofPath(currentCommit); + +const lines = [ + "# ProjectBrain Remote Deployment Proof", + "", + `Generated: ${new Date().toISOString()}`, + `Local commit: ${currentCommit}`, + `Deployment URL: ${baseUrl}`, + `SSH target: ${sshTarget}`, + `Deployment path: ${deployPath}`, + `Remote commit: ${deployedCommit}`, + `Commit freshness: ${commitFreshness}`, + `Verdict: ${verdict}`, + "", + "## HTTP Health", + "", + `- Status: ${health.status}`, + `- Result: ${health.ok ? "reachable" : "not reachable"}`, + `- Body: ${health.body}`, + "", + "## Remote Runtime", + "", + `- SSH result: ${shellProof.ok ? "reachable" : shellProof.status}`, + `- Frontend container: ${hasFrontend ? "up" : "not proven"}`, + `- API container: ${hasApi ? "up" : "not proven"}`, + `- Postgres container: ${hasPostgres ? "up" : "not proven"}`, + "", + "```text", + containerBlock || shellProof.stderr || "No remote container output captured.", + "```", + "", + "## Operator Commands", + "", + "```bash", + "PROJECTBRAIN_DEPLOY_PROOF_WRITE=1 pnpm run release:deployment-proof", + "PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 pnpm run release:bundle", + "```", +].join("\n"); + +console.log(lines); + +if (shouldWrite) { + fs.mkdirSync(path.dirname(proofPath), { recursive: true }); + fs.writeFileSync(proofPath, `${lines}\n`, "utf8"); + console.log(`\nRemote deployment proof written to ${proofPath}`); +} + +if (verdict !== "PASS") { + process.exitCode = 1; +} diff --git a/scripts/release-evidence-dossier.mjs b/scripts/release-evidence-dossier.mjs new file mode 100644 index 0000000..2d163d7 --- /dev/null +++ b/scripts/release-evidence-dossier.mjs @@ -0,0 +1,152 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof"; +const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff"; +const deploymentProofDir = process.env.PROJECTBRAIN_DEPLOY_PROOF_ARCHIVE_DIR ?? "checkpoints/deployment-proof"; +const dossierOut = process.env.PROJECTBRAIN_RELEASE_DOSSIER_OUT; + +const run = (command, args) => spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", +}); + +const getGitCommit = () => { + const result = run("git", ["rev-parse", "--short", "HEAD"]); + return result.status === 0 ? result.stdout.trim() : "unknown"; +}; + +const parseField = (text, label) => { + const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); + return match?.[1]?.trim() ?? "unknown"; +}; + +const displayPath = (filePath) => filePath.split(path.sep).join("/"); + +const listMarkdownFiles = (archiveDir) => { + if (!fs.existsSync(archiveDir)) return []; + + return fs.readdirSync(archiveDir) + .filter((fileName) => fileName.endsWith(".md")) + .map((fileName) => path.join(archiveDir, fileName)) + .filter((filePath) => fs.statSync(filePath).isFile()); +}; + +const sortNewestFirst = (items) => items.sort((a, b) => { + const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated); + if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta; + return b.fileName.localeCompare(a.fileName); +}); + +const parseArtifact = (filePath, type) => { + const text = fs.readFileSync(filePath, "utf8"); + const generated = parseField(text, "Generated"); + const commit = type === "deployment" + ? parseField(text, "Local commit") + : parseField(text, "Commit"); + + return { + type, + text, + filePath, + displayPath: displayPath(filePath), + fileName: path.basename(filePath), + generated, + commit, + verdict: parseField(text, "Verdict"), + freshness: type === "deployment" + ? parseField(text, "Commit freshness") + : parseField(text, "Freshness"), + }; +}; + +const findLatestCurrentArtifact = (archiveDir, type, currentCommit) => { + const artifacts = sortNewestFirst(listMarkdownFiles(archiveDir).map((filePath) => parseArtifact(filePath, type))); + + return artifacts.find((artifact) => { + if (artifact.commit !== currentCommit) return false; + if (type === "proof") return artifact.verdict === "PASS"; + if (type === "signoff") return artifact.freshness === "current"; + if (type === "deployment") return artifact.verdict === "PASS" && artifact.freshness === "current"; + return false; + }); +}; + +const indentArtifact = (artifact) => artifact.text + .split("\n") + .map((line) => line ? `> ${line}` : ">") + .join("\n"); + +const createDefaultDossierPath = (commit) => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `checkpoints/release-dossier/projectbrain-release-dossier-${commit}-${stamp}.md`; +}; + +const currentCommit = getGitCommit(); +const proof = findLatestCurrentArtifact(proofDir, "proof", currentCommit); +const signoff = findLatestCurrentArtifact(signoffDir, "signoff", currentCommit); +const deployment = findLatestCurrentArtifact(deploymentProofDir, "deployment", currentCommit); +const missing = [ + proof ? null : "current passing release proof", + signoff ? null : "current release signoff", + deployment ? null : "current passing deployment proof", +].filter(Boolean); + +if (missing.length) { + console.error("Cannot create release evidence dossier: missing required current artifacts."); + for (const item of missing) console.error(`- ${item}`); + console.error("Run: PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 pnpm run release:bundle"); + process.exit(1); +} + +const dossierPath = dossierOut || createDefaultDossierPath(currentCommit); +const dossier = [ + "# ProjectBrain Release Evidence Dossier", + "", + `Generated: ${new Date().toISOString()}`, + `Commit: ${currentCommit}`, + `Verdict: PASS`, + "", + "## Included Artifacts", + "", + "| Artifact | Generated | Source |", + "| --- | --- | --- |", + `| Release proof | ${proof.generated} | ${proof.displayPath} |`, + `| Operator signoff | ${signoff.generated} | ${signoff.displayPath} |`, + `| Deployment proof | ${deployment.generated} | ${deployment.displayPath} |`, + "", + "## Operator Summary", + "", + "- Release operator smoke suite passed for the current commit.", + "- Release proof freshness is current.", + "- Operator signoff file exists for the current commit.", + "- Remote deployment proof passed for the current commit.", + "- Tower health and ProjectBrain containers were proven by the deployment proof.", + "", + "## Release Proof", + "", + indentArtifact(proof), + "", + "## Operator Signoff", + "", + indentArtifact(signoff), + "", + "## Remote Deployment Proof", + "", + indentArtifact(deployment), + "", + "## Tower Commands", + "", + "```bash", + "cd /mnt/pool/appdata/brain", + "docker compose build --no-cache frontend", + "docker compose up -d", + "```", +].join("\n"); + +fs.mkdirSync(path.dirname(dossierPath), { recursive: true }); +fs.writeFileSync(dossierPath, `${dossier}\n`, "utf8"); + +console.log(dossier); +console.log(`\nRelease evidence dossier written to ${dossierPath}`); diff --git a/scripts/release-evidence-retention.mjs b/scripts/release-evidence-retention.mjs new file mode 100644 index 0000000..f6b9cc1 --- /dev/null +++ b/scripts/release-evidence-retention.mjs @@ -0,0 +1,155 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof"; +const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff"; +const keepArg = process.argv.find((arg) => arg.startsWith("--keep=")); +const keep = Number.parseInt(keepArg?.slice("--keep=".length) ?? process.env.PROJECTBRAIN_EVIDENCE_KEEP ?? "20", 10); +const shouldPrune = process.argv.includes("--prune"); +const confirmed = process.argv.includes("--yes") || process.env.PROJECTBRAIN_EVIDENCE_PRUNE_CONFIRM === "1"; + +const run = (command, args) => spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", +}); + +const getGitCommit = () => { + const result = run("git", ["rev-parse", "--short", "HEAD"]); + return result.status === 0 ? result.stdout.trim() : "unknown"; +}; + +const parseField = (text, label) => { + const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); + return match?.[1]?.trim() ?? "unknown"; +}; + +const displayPath = (filePath) => filePath.split(path.sep).join("/"); + +const safeKeep = Number.isFinite(keep) && keep > 0 ? keep : 20; + +const listMarkdownFiles = (archiveDir, type) => { + if (!fs.existsSync(archiveDir)) return []; + + return fs.readdirSync(archiveDir) + .filter((fileName) => fileName.endsWith(".md")) + .map((fileName) => path.join(archiveDir, fileName)) + .filter((filePath) => fs.statSync(filePath).isFile()) + .map((filePath) => { + const text = fs.readFileSync(filePath, "utf8"); + const stat = fs.statSync(filePath); + const generated = parseField(text, "Generated"); + + return { + type, + filePath, + displayPath: displayPath(filePath), + fileName: path.basename(filePath), + generated, + modifiedAt: stat.mtime.toISOString(), + commit: parseField(text, "Commit"), + verdict: parseField(text, "Verdict"), + freshness: parseField(text, "Freshness"), + }; + }); +}; + +const sortNewestFirst = (items) => items.sort((a, b) => { + const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated); + if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta; + + const modifiedDelta = Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt); + if (Number.isFinite(modifiedDelta) && modifiedDelta !== 0) return modifiedDelta; + + return b.fileName.localeCompare(a.fileName); +}); + +const createArchive = (archiveDir, type) => { + const items = sortNewestFirst(listMarkdownFiles(archiveDir, type)); + const keepers = items.slice(0, safeKeep); + const candidates = items.slice(safeKeep); + + return { + type, + archiveDir, + items, + keepers, + candidates, + }; +}; + +const deleteCandidate = (candidate, archiveDir) => { + const resolvedArchiveDir = path.resolve(archiveDir); + const resolvedFile = path.resolve(candidate.filePath); + + if (!resolvedFile.startsWith(`${resolvedArchiveDir}${path.sep}`)) { + throw new Error(`Refusing to delete outside archive: ${candidate.displayPath}`); + } + + if (!candidate.fileName.endsWith(".md")) { + throw new Error(`Refusing to delete non-markdown artifact: ${candidate.displayPath}`); + } + + fs.unlinkSync(candidate.filePath); +}; + +const currentCommit = getGitCommit(); +const archives = [ + createArchive(proofDir, "proof"), + createArchive(signoffDir, "signoff"), +]; +const candidates = archives.flatMap((archive) => archive.candidates.map((candidate) => ({ + ...candidate, + archiveDir: archive.archiveDir, +}))); + +const lines = [ + "# ProjectBrain Release Evidence Retention", + "", + `Current commit: ${currentCommit}`, + `Mode: ${shouldPrune ? confirmed ? "prune" : "dry-run prune" : "list"}`, + `Keep newest per archive: ${safeKeep}`, + "", + "| Archive | Type | Snapshots | Kept | Prune candidates |", + "| --- | --- | --- | --- | --- |", +]; + +for (const archive of archives) { + lines.push(`| ${archive.archiveDir} | ${archive.type} | ${archive.items.length} | ${archive.keepers.length} | ${archive.candidates.length} |`); +} + +lines.push(""); + +if (!candidates.length) { + lines.push("No release evidence cleanup candidates found."); +} else { + lines.push("## Cleanup Candidates"); + lines.push(""); + lines.push("| Generated | Type | Commit | Verdict/Freshness | File |"); + lines.push("| --- | --- | --- | --- | --- |"); + + for (const candidate of candidates) { + const state = candidate.type === "proof" ? candidate.verdict : candidate.freshness; + lines.push(`| ${candidate.generated} | ${candidate.type} | ${candidate.commit} | ${state} | ${candidate.displayPath} |`); + } +} + +if (shouldPrune && !confirmed) { + lines.push(""); + lines.push("Dry-run only. To delete cleanup candidates, rerun with:"); + lines.push(""); + lines.push("```bash"); + lines.push("pnpm run release:evidence-prune -- --yes"); + lines.push("```"); +} + +console.log(lines.join("\n")); + +if (shouldPrune && confirmed) { + for (const candidate of candidates) { + deleteCandidate(candidate, candidate.archiveDir); + } + + console.log(""); + console.log(`Deleted ${candidates.length} release evidence artifact${candidates.length === 1 ? "" : "s"}.`); +} diff --git a/scripts/release-operator-proof-report.mjs b/scripts/release-operator-proof-report.mjs new file mode 100644 index 0000000..679a9dd --- /dev/null +++ b/scripts/release-operator-proof-report.mjs @@ -0,0 +1,183 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const baseUrl = process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:5173"; +const projectId = process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog"; +const shouldRunSmokes = process.argv.includes("--run-smokes") || process.env.PROJECTBRAIN_PROOF_RUN_SMOKES === "1"; +const hasExplicitBaseUrl = Boolean(process.env.PROJECTBRAIN_SMOKE_URL); +const shouldWriteReport = process.argv.includes("--write") || process.env.PROJECTBRAIN_PROOF_WRITE === "1"; +const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out=")); +const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_PROOF_OUT; + +const evidenceRows = [ + { + id: "RC63", + label: "Release evidence lane click-through", + script: "smoke:rc63-release-evidence-lane", + proves: "Release evidence routes are reachable from the operator lane.", + }, + { + id: "RC60", + label: "Review decision receipt recovery", + script: "smoke:rc60-review-decision-loop", + proves: "Review decisions can be recovered and inspected from history.", + }, + { + id: "RC73", + label: "Release evidence capture assist", + script: "smoke:rc73-release-evidence-capture-assist", + proves: "Runtime evidence capture assists the operator instead of leaving proof manual-only.", + }, + { + id: "RC88", + label: "Release Center cockpit", + script: "smoke:rc88-release-center", + proves: "Release Center renders the cockpit, archives, quality and operator actions.", + }, + { + id: "RC99", + label: "Release checklist click-through", + script: "smoke:rc99-release-checklist-clickthrough", + proves: "Checklist links land on exact target sections and expose return paths.", + }, +]; + +const run = (command, args, options = {}) => spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", + ...options, +}); + +const readPackage = () => { + const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); + return packageJson.scripts ?? {}; +}; + +const getGitCommit = () => { + const result = run("git", ["rev-parse", "--short", "HEAD"]); + return result.status === 0 ? result.stdout.trim() : "unknown"; +}; + +const createDefaultReportPath = (commit) => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `checkpoints/release-proof/projectbrain-release-proof-${commit}-${stamp}.md`; +}; + +const checkLocalConfiguration = () => { + const scripts = readPackage(); + const missing = []; + + if (!scripts["smoke:release-operator"]) missing.push("smoke:release-operator"); + if (!scripts["release:proof-report"]) missing.push("release:proof-report"); + for (const row of evidenceRows) { + if (!scripts[row.script]) missing.push(row.script); + } + + return missing; +}; + +const checkLiveHealth = async () => { + try { + const response = await fetch(`${baseUrl}/api/health`); + const body = await response.text(); + return { + ok: response.ok, + status: response.status, + body: body.slice(0, 220), + }; + } catch (error) { + return { + ok: false, + status: "unreachable", + body: error instanceof Error ? error.message : String(error), + }; + } +}; + +const runOperatorSmoke = () => { + const result = run("pnpm", ["run", "smoke:release-operator"], { + stdio: "inherit", + env: process.env, + }); + + return result.status === 0; +}; + +const formatTable = (rows) => { + const header = "| Gate | Smoke | Proof |\n| --- | --- | --- |"; + const body = rows.map((row) => `| ${row.id} | \`${row.script}\` | ${row.proves} |`).join("\n"); + return `${header}\n${body}`; +}; + +const localMissing = checkLocalConfiguration(); +let smokePassed = null; + +if (shouldRunSmokes) { + console.log("Running release operator smoke suite before generating proof report...\n"); + smokePassed = runOperatorSmoke(); +} + +const liveHealth = await checkLiveHealth(); +const gitCommit = getGitCommit(); +const localConfigOk = localMissing.length === 0; +const healthGateOk = liveHealth.ok || !hasExplicitBaseUrl; +const verdict = localConfigOk && healthGateOk && (smokePassed !== false) + ? (smokePassed === true ? "PASS" : "CONFIGURED - SMOKE NOT RUN") + : "ATTENTION REQUIRED"; +const reportPath = explicitOutputPath || createDefaultReportPath(gitCommit); + +const report = [ + "# ProjectBrain Release Operator Proof Report", + "", + `Generated: ${new Date().toISOString()}`, + `Commit: ${gitCommit}`, + `Base URL: ${baseUrl}`, + `Project: ${projectId}`, + `Verdict: ${verdict}`, + "", + "## Live Health", + "", + `- Status: ${liveHealth.status}`, + `- Result: ${liveHealth.ok ? "reachable" : hasExplicitBaseUrl ? "not reachable" : "not reachable in local default mode"}`, + `- Body: ${liveHealth.body}`, + "", + "## Proof Coverage", + "", + formatTable(evidenceRows), + "", + "## Local Configuration", + "", + localConfigOk + ? "- Required release smoke scripts are registered." + : `- Missing scripts: ${localMissing.map((name) => `\`${name}\``).join(", ")}`, + `- Smoke execution: ${smokePassed === true ? "passed" : smokePassed === false ? "failed" : "not run"}`, + "", + "## Operator Commands", + "", + "```bash", + "pnpm run release:proof-report", + "pnpm run release:proof-report -- --write", + "PROJECTBRAIN_PROOF_RUN_SMOKES=1 pnpm run release:proof-report", + "PROJECTBRAIN_PROOF_RUN_SMOKES=1 PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report", + "PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 pnpm run smoke:release-operator", + "```", + "", + "## Remaining Manual Gates", + "", + "- Confirm the intended release commit/tag.", + "- Confirm any environment-specific deployment notes.", + "- Confirm operator signoff after the smoke suite is green.", +].join("\n"); + +console.log(report); + +if (shouldWriteReport) { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${report}\n`, "utf8"); + console.log(`\nRelease proof report written to ${reportPath}`); +} + +if (!localConfigOk || !healthGateOk || smokePassed === false) { + process.exitCode = 1; +} diff --git a/scripts/release-proof-freshness.mjs b/scripts/release-proof-freshness.mjs new file mode 100644 index 0000000..8136ba6 --- /dev/null +++ b/scripts/release-proof-freshness.mjs @@ -0,0 +1,90 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof"; +const allowStale = process.argv.includes("--allow-stale") || process.env.PROJECTBRAIN_PROOF_ALLOW_STALE === "1"; + +const run = (command, args) => spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", +}); + +const getGitCommit = () => { + const result = run("git", ["rev-parse", "--short", "HEAD"]); + return result.status === 0 ? result.stdout.trim() : "unknown"; +}; + +const parseField = (text, label) => { + const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); + return match?.[1]?.trim() ?? "unknown"; +}; + +const displayPath = (filePath) => filePath.split(path.sep).join("/"); + +const parseProof = (filePath) => { + const text = fs.readFileSync(filePath, "utf8"); + return { + filePath, + displayPath: displayPath(filePath), + fileName: path.basename(filePath), + generated: parseField(text, "Generated"), + commit: parseField(text, "Commit"), + project: parseField(text, "Project"), + verdict: parseField(text, "Verdict"), + smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown", + }; +}; + +const listProofFiles = () => { + if (!fs.existsSync(archiveDir)) return []; + + return fs.readdirSync(archiveDir) + .filter((fileName) => fileName.endsWith(".md")) + .map((fileName) => path.join(archiveDir, fileName)) + .filter((filePath) => fs.statSync(filePath).isFile()); +}; + +const currentCommit = getGitCommit(); +const proofs = listProofFiles() + .map(parseProof) + .sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName)); +const latestPassing = proofs.find((proof) => proof.verdict === "PASS"); +const freshness = !latestPassing + ? "missing" + : latestPassing.commit === currentCommit + ? "current" + : "stale"; + +const lines = [ + "# ProjectBrain Release Proof Freshness", + "", + `Archive: ${archiveDir}`, + `Current commit: ${currentCommit}`, + `Latest passing proof: ${latestPassing ? latestPassing.displayPath : "none"}`, + `Proof commit: ${latestPassing?.commit ?? "none"}`, + `Freshness: ${freshness}`, + `Strict mode: ${allowStale ? "disabled" : "enabled"}`, + "", +]; + +if (latestPassing) { + lines.push("| Generated | Verdict | Project | Smoke | File |"); + lines.push("| --- | --- | --- | --- | --- |"); + lines.push(`| ${latestPassing.generated} | ${latestPassing.verdict} | ${latestPassing.project} | ${latestPassing.smokeExecution} | ${latestPassing.displayPath} |`); + lines.push(""); +} + +if (freshness !== "current") { + lines.push("Create a fresh passing proof for the current commit with:"); + lines.push(""); + lines.push("```bash"); + lines.push("PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 PROJECTBRAIN_PROOF_RUN_SMOKES=1 PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report"); + lines.push("```"); +} + +console.log(lines.join("\n")); + +if (!allowStale && freshness !== "current") { + process.exitCode = 1; +} diff --git a/scripts/release-proof-index.mjs b/scripts/release-proof-index.mjs new file mode 100644 index 0000000..ccd6ccf --- /dev/null +++ b/scripts/release-proof-index.mjs @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import path from "node:path"; + +const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof"; +const limitArg = process.argv.find((arg) => arg.startsWith("--limit=")); +const limit = Number.parseInt(limitArg?.slice("--limit=".length) ?? process.env.PROJECTBRAIN_PROOF_INDEX_LIMIT ?? "10", 10); + +const parseField = (text, label) => { + const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); + return match?.[1]?.trim() ?? "unknown"; +}; + +const displayPath = (filePath) => filePath.split(path.sep).join("/"); + +const parseProof = (filePath) => { + const text = fs.readFileSync(filePath, "utf8"); + const stat = fs.statSync(filePath); + + return { + filePath, + displayPath: displayPath(filePath), + fileName: path.basename(filePath), + modifiedAt: stat.mtime.toISOString(), + generated: parseField(text, "Generated"), + commit: parseField(text, "Commit"), + baseUrl: parseField(text, "Base URL"), + project: parseField(text, "Project"), + verdict: parseField(text, "Verdict"), + smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown", + }; +}; + +const listProofFiles = () => { + if (!fs.existsSync(archiveDir)) return []; + + return fs.readdirSync(archiveDir) + .filter((fileName) => fileName.endsWith(".md")) + .map((fileName) => path.join(archiveDir, fileName)) + .filter((filePath) => fs.statSync(filePath).isFile()); +}; + +const proofs = listProofFiles() + .map(parseProof) + .sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName)); + +const latestPassing = proofs.find((proof) => proof.verdict === "PASS"); +const selectedProofs = proofs.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 10); + +const lines = [ + "# ProjectBrain Release Proof Index", + "", + `Archive: ${archiveDir}`, + `Proof snapshots: ${proofs.length}`, + `Latest passing proof: ${latestPassing ? latestPassing.displayPath : "none"}`, + "", +]; + +if (!proofs.length) { + lines.push("No release proof snapshots found."); + lines.push(""); + lines.push("Create one with:"); + lines.push(""); + lines.push("```bash"); + lines.push("PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report"); + lines.push("```"); +} else { + lines.push("| Generated | Verdict | Commit | Project | Smoke | File |"); + lines.push("| --- | --- | --- | --- | --- | --- |"); + for (const proof of selectedProofs) { + lines.push(`| ${proof.generated} | ${proof.verdict} | ${proof.commit} | ${proof.project} | ${proof.smokeExecution} | ${proof.displayPath} |`); + } +} + +console.log(lines.join("\n")); diff --git a/scripts/release-rollback-drill.mjs b/scripts/release-rollback-drill.mjs new file mode 100644 index 0000000..b4a28d9 --- /dev/null +++ b/scripts/release-rollback-drill.mjs @@ -0,0 +1,227 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209"; +const gitRemote = process.env.PROJECTBRAIN_RELEASE_REMOTE ?? "gitea"; +const gitBranch = process.env.PROJECTBRAIN_RELEASE_BRANCH ?? "main"; +const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1"; +const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain"; +const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy"); +const requestedTag = process.env.PROJECTBRAIN_ROLLBACK_TAG; +const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_ROLLBACK_DRILL_WRITE === "1"; +const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out=")); +const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_ROLLBACK_DRILL_OUT; + +const run = (command, args, options = {}) => spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", + ...options, +}); + +const git = (args) => { + const result = spawnSync("git", args, { + encoding: "utf8", + shell: false, + }); + return { + ok: result.status === 0, + value: result.stdout.trim(), + error: result.stderr.trim(), + }; +}; + +const runSsh = (remoteCommand) => { + if (!fs.existsSync(sshKey)) { + return { + ok: false, + stdout: "", + stderr: `SSH key not found: ${sshKey}`, + }; + } + + const result = spawnSync("ssh", [ + "-i", + sshKey, + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + sshTarget, + remoteCommand, + ], { + encoding: "utf8", + shell: false, + }); + + return { + ok: result.status === 0, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +}; + +const checkHttp = async (urlPath) => { + try { + const response = await fetch(`${baseUrl}${urlPath}`); + const body = await response.text(); + + return { + ok: response.ok, + status: String(response.status), + body: body.slice(0, 220).replace(/\s+/g, " ").trim(), + }; + } catch (error) { + return { + ok: false, + status: "unreachable", + body: error instanceof Error ? error.message : String(error), + }; + } +}; + +const latestReleaseTag = () => { + const result = git(["tag", "--list", "projectbrain-rc-*", "--sort=-creatordate"]); + return result.value.split(/\r?\n/).find(Boolean) ?? ""; +}; + +const createDefaultReportPath = (tagName) => { + const safeTag = tagName.replace(/[^a-zA-Z0-9._-]/g, "-"); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `checkpoints/rollback-drill/projectbrain-rollback-drill-${safeTag}-${stamp}.md`; +}; + +run("git", ["fetch", "--tags", gitRemote]); + +const tagName = requestedTag || latestReleaseTag(); +const currentCommit = git(["rev-parse", "--short", "HEAD"]).value || "unknown"; +const tagCommit = tagName ? git(["rev-parse", "--short", `${tagName}^{commit}`]) : { ok: false, value: "", error: "No release tag found." }; +const tagFullCommit = tagName ? git(["rev-parse", `${tagName}^{commit}`]) : { ok: false, value: "", error: "No release tag found." }; +const remoteTag = tagName ? run("git", ["ls-remote", "--tags", gitRemote, tagName]) : { status: 1, stdout: "", stderr: "No release tag found." }; +const status = git(["status", "--porcelain"]); +const health = await checkHttp("/api/health"); +const tower = tagName + ? runSsh([ + `git -C ${deployPath} fetch --tags ${gitRemote}`, + `echo tower_head=$(git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown)`, + `echo tower_tag=$(git -C ${deployPath} rev-parse --short ${tagName}^{commit} 2>/dev/null || echo unknown)`, + "echo containers_begin", + "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep projectbrain || true", + "echo containers_end", + ].join(" && ")) + : { ok: false, stdout: "", stderr: "No release tag found." }; + +const towerHead = tower.stdout.match(/^tower_head=(.+)$/m)?.[1]?.trim() ?? "unknown"; +const towerTag = tower.stdout.match(/^tower_tag=(.+)$/m)?.[1]?.trim() ?? "unknown"; +const containerBlock = tower.stdout.match(/containers_begin\n([\s\S]*?)\n?containers_end/)?.[1]?.trim() ?? ""; +const hasFrontend = containerBlock.includes("projectbrain-frontend") && containerBlock.includes("1209->80/tcp"); +const hasApi = containerBlock.includes("projectbrain-api") && /projectbrain-api\s+Up/.test(containerBlock); +const hasPostgres = containerBlock.includes("projectbrain-postgres") && /projectbrain-postgres\s+Up/.test(containerBlock); +const reportPath = createDefaultReportPath(tagName || "missing-tag"); +const outputPath = explicitOutputPath || reportPath; + +const gates = [ + { + label: "Release tag selected", + ok: Boolean(tagName), + detail: tagName || "missing", + }, + { + label: "Local release tag resolves", + ok: Boolean(tagName) && tagCommit.ok, + detail: tagCommit.value || tagCommit.error || "missing", + }, + { + label: "Gitea release tag exists", + ok: remoteTag.status === 0 && remoteTag.stdout.includes(`refs/tags/${tagName}`), + detail: remoteTag.status === 0 ? "present" : remoteTag.stderr.trim(), + }, + { + label: "Tower release tag resolves", + ok: tower.ok && towerTag === tagCommit.value, + detail: `${towerTag} tower / ${tagCommit.value || "unknown"} local`, + }, + { + label: "Tower HEAD captured", + ok: tower.ok && towerHead !== "unknown", + detail: `${towerHead} tower / ${tagCommit.value || "unknown"} rollback target`, + }, + { + label: "Tracked worktree clean", + ok: status.ok && status.value.trim() === "", + detail: status.value.trim() || "clean", + }, + { + label: "Live health reachable", + ok: health.ok, + detail: `${health.status} ${health.body}`, + }, + { + label: "ProjectBrain containers running", + ok: hasFrontend && hasApi && hasPostgres, + detail: `frontend=${hasFrontend ? "up" : "missing"} api=${hasApi ? "up" : "missing"} postgres=${hasPostgres ? "up" : "missing"}`, + }, +]; + +const passed = gates.every((gate) => gate.ok); +const rollbackCommand = `ssh -i ~/.ssh/widefrog_unraid_deploy ${sshTarget} "cd ${deployPath} && git fetch --tags ${gitRemote} && git switch --detach ${tagName} && docker compose build --no-cache frontend && docker compose up -d"`; +const restoreMainCommand = `ssh -i ~/.ssh/widefrog_unraid_deploy ${sshTarget} "cd ${deployPath} && git switch ${gitBranch} && git pull --ff-only && docker compose build --no-cache frontend && docker compose up -d"`; +const lines = [ + "# ProjectBrain Rollback Drill", + "", + `Generated: ${new Date().toISOString()}`, + `Selected tag: ${tagName || "missing"}`, + `Tag commit: ${tagCommit.value || "unknown"}`, + `Current commit: ${currentCommit}`, + `Tower HEAD: ${towerHead}`, + `Deployment URL: ${baseUrl}`, + `Verdict: ${passed ? "PASS" : "ATTENTION REQUIRED"}`, + "", + "## Gates", + "", + "| Gate | Result | Detail |", + "| --- | --- | --- |", + ...gates.map((gate) => `| ${gate.label} | ${gate.ok ? "PASS" : "FAIL"} | ${String(gate.detail).replace(/\|/g, "\\|")} |`), + "", + "## Container Snapshot", + "", + "```text", + containerBlock || tower.stderr || "No container output captured.", + "```", + "", + "## Rollback Commands", + "", + "These commands are printed for operator review. This drill does not execute checkout, rebuild or rollback.", + "", + "Rollback/redeploy selected tag:", + "", + "```bash", + rollbackCommand, + "```", + "", + "Restore main after drill:", + "", + "```bash", + restoreMainCommand, + "```", + "", + "## Local Verification", + "", + "```bash", + "pnpm run release:deployment-proof", + "pnpm run release:tag-gate", + "```", +].join("\n"); + +console.log(lines); + +if (shouldWrite) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${lines}\n`, "utf8"); + console.log(`\nRollback drill report written to ${outputPath}`); +} + +if (!passed) { + process.exitCode = 1; +} diff --git a/scripts/release-signoff-file.mjs b/scripts/release-signoff-file.mjs new file mode 100644 index 0000000..61d9fe8 --- /dev/null +++ b/scripts/release-signoff-file.mjs @@ -0,0 +1,111 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof"; +const signoffOut = process.env.PROJECTBRAIN_SIGNOFF_OUT; + +const run = (command, args) => spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", +}); + +const getGitCommit = () => { + const result = run("git", ["rev-parse", "--short", "HEAD"]); + return result.status === 0 ? result.stdout.trim() : "unknown"; +}; + +const parseField = (text, label) => { + const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); + return match?.[1]?.trim() ?? "unknown"; +}; + +const displayPath = (filePath) => filePath.split(path.sep).join("/"); + +const parseProof = (filePath) => { + const text = fs.readFileSync(filePath, "utf8"); + return { + filePath, + displayPath: displayPath(filePath), + fileName: path.basename(filePath), + generated: parseField(text, "Generated"), + commit: parseField(text, "Commit"), + baseUrl: parseField(text, "Base URL"), + project: parseField(text, "Project"), + verdict: parseField(text, "Verdict"), + smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown", + }; +}; + +const listProofFiles = () => { + if (!fs.existsSync(archiveDir)) return []; + + return fs.readdirSync(archiveDir) + .filter((fileName) => fileName.endsWith(".md")) + .map((fileName) => path.join(archiveDir, fileName)) + .filter((filePath) => fs.statSync(filePath).isFile()); +}; + +const createDefaultSignoffPath = (commit) => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `checkpoints/release-signoff/projectbrain-release-signoff-${commit}-${stamp}.md`; +}; + +const currentCommit = getGitCommit(); +const proofs = listProofFiles() + .map(parseProof) + .sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName)); +const latestPassing = proofs.find((proof) => proof.verdict === "PASS"); +const freshness = latestPassing?.commit === currentCommit ? "current" : latestPassing ? "stale" : "missing"; + +if (freshness !== "current") { + console.error("Cannot create release signoff file: latest passing proof is not current."); + console.error(`Current commit: ${currentCommit}`); + console.error(`Proof commit: ${latestPassing?.commit ?? "none"}`); + console.error("Run: PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 pnpm run release:bundle"); + process.exit(1); +} + +const signoffPath = signoffOut || createDefaultSignoffPath(currentCommit); +const signoff = [ + "# ProjectBrain Release Operator Signoff", + "", + `Generated: ${new Date().toISOString()}`, + `Commit: ${currentCommit}`, + `Project: ${latestPassing.project}`, + `Base URL: ${latestPassing.baseUrl}`, + `Proof snapshot: ${latestPassing.displayPath}`, + `Proof generated: ${latestPassing.generated}`, + `Proof verdict: ${latestPassing.verdict}`, + `Smoke execution: ${latestPassing.smokeExecution}`, + `Freshness: ${freshness}`, + "", + "## Technical Gates", + "", + "- [x] Release proof report exists for the current commit.", + "- [x] Release operator smoke suite passed.", + "- [x] Release proof freshness is current.", + "- [x] Proof snapshot is stored as a local ignored artifact.", + "", + "## Manual Operator Signoff", + "", + "- [ ] Confirm intended release commit/tag.", + "- [ ] Confirm deployment target and public URL.", + "- [ ] Confirm environment-specific notes are captured.", + "- [ ] Confirm rollback/restart command is known.", + "- [ ] Confirm final human go/no-go decision.", + "", + "## Tower Commands", + "", + "```bash", + "cd /mnt/pool/appdata/brain", + "docker compose build --no-cache frontend", + "docker compose up -d", + "```", +].join("\n"); + +fs.mkdirSync(path.dirname(signoffPath), { recursive: true }); +fs.writeFileSync(signoffPath, `${signoff}\n`, "utf8"); + +console.log(signoff); +console.log(`\nRelease signoff file written to ${signoffPath}`); diff --git a/scripts/release-tag-ready-gate.mjs b/scripts/release-tag-ready-gate.mjs new file mode 100644 index 0000000..4c15cff --- /dev/null +++ b/scripts/release-tag-ready-gate.mjs @@ -0,0 +1,256 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof"; +const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff"; +const deploymentProofDir = process.env.PROJECTBRAIN_DEPLOY_PROOF_ARCHIVE_DIR ?? "checkpoints/deployment-proof"; +const dossierDir = process.env.PROJECTBRAIN_RELEASE_DOSSIER_DIR ?? "checkpoints/release-dossier"; +const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209"; +const gitRemote = process.env.PROJECTBRAIN_RELEASE_REMOTE ?? "gitea"; +const gitBranch = process.env.PROJECTBRAIN_RELEASE_BRANCH ?? "main"; +const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1"; +const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain"; +const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy"); +const requestedTag = process.env.PROJECTBRAIN_RELEASE_TAG; +const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_TAG_GATE_WRITE === "1"; +const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out=")); +const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_TAG_GATE_OUT; + +const run = (command, args, options = {}) => spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", + ...options, +}); + +const getGitOutput = (args) => { + const result = run("git", args); + return { + ok: result.status === 0, + value: result.stdout.trim(), + error: result.stderr.trim(), + }; +}; + +const parseField = (text, label) => { + const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m")); + return match?.[1]?.trim() ?? "unknown"; +}; + +const displayPath = (filePath) => filePath.split(path.sep).join("/"); + +const listMarkdownFiles = (archiveDir) => { + if (!fs.existsSync(archiveDir)) return []; + + return fs.readdirSync(archiveDir) + .filter((fileName) => fileName.endsWith(".md")) + .map((fileName) => path.join(archiveDir, fileName)) + .filter((filePath) => fs.statSync(filePath).isFile()); +}; + +const sortNewestFirst = (items) => items.sort((a, b) => { + const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated); + if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta; + return b.fileName.localeCompare(a.fileName); +}); + +const parseArtifact = (filePath, type) => { + const text = fs.readFileSync(filePath, "utf8"); + const commit = type === "deployment" ? parseField(text, "Local commit") : parseField(text, "Commit"); + + return { + type, + filePath, + displayPath: displayPath(filePath), + fileName: path.basename(filePath), + generated: parseField(text, "Generated"), + commit, + verdict: parseField(text, "Verdict"), + freshness: type === "deployment" ? parseField(text, "Commit freshness") : parseField(text, "Freshness"), + }; +}; + +const findCurrentArtifact = (archiveDir, type, currentCommit) => { + const artifacts = sortNewestFirst(listMarkdownFiles(archiveDir).map((filePath) => parseArtifact(filePath, type))); + + return artifacts.find((artifact) => { + if (artifact.commit !== currentCommit) return false; + if (type === "proof") return artifact.verdict === "PASS"; + if (type === "signoff") return artifact.freshness === "current"; + if (type === "deployment") return artifact.verdict === "PASS" && artifact.freshness === "current"; + if (type === "dossier") return artifact.verdict === "PASS"; + return false; + }); +}; + +const checkHttp = async (urlPath) => { + try { + const response = await fetch(`${baseUrl}${urlPath}`); + const body = await response.text(); + + return { + ok: response.ok, + status: String(response.status), + body: body.slice(0, 220).replace(/\s+/g, " ").trim(), + }; + } catch (error) { + return { + ok: false, + status: "unreachable", + body: error instanceof Error ? error.message : String(error), + }; + } +}; + +const runSsh = (remoteCommand) => { + if (!fs.existsSync(sshKey)) { + return { + ok: false, + stdout: "", + stderr: `SSH key not found: ${sshKey}`, + }; + } + + const result = spawnSync("ssh", [ + "-i", + sshKey, + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + sshTarget, + remoteCommand, + ], { + encoding: "utf8", + shell: false, + }); + + return { + ok: result.status === 0, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +}; + +const createDefaultTagName = (commit) => { + const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, ""); + return `projectbrain-rc-${stamp}-${commit}`; +}; + +const createDefaultReportPath = (commit) => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `checkpoints/tag-gate/projectbrain-tag-ready-${commit}-${stamp}.md`; +}; + +const currentCommit = getGitOutput(["rev-parse", "--short", "HEAD"]).value || "unknown"; +const fullCurrentCommit = getGitOutput(["rev-parse", "HEAD"]).value || "unknown"; +const status = getGitOutput(["status", "--porcelain"]); +const fetchRemote = run("git", ["fetch", gitRemote, gitBranch]); +const remoteRef = `${gitRemote}/${gitBranch}`; +const remoteCommit = getGitOutput(["rev-parse", "--short", remoteRef]).value || "unknown"; +const remoteFullCommit = getGitOutput(["rev-parse", remoteRef]).value || "unknown"; +const health = await checkHttp("/api/health"); +const remoteShell = runSsh(`git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown`); +const towerCommit = remoteShell.stdout.trim() || "unknown"; +const tagName = requestedTag || createDefaultTagName(currentCommit); +const reportPath = explicitOutputPath || createDefaultReportPath(currentCommit); + +const proof = findCurrentArtifact(proofDir, "proof", currentCommit); +const signoff = findCurrentArtifact(signoffDir, "signoff", currentCommit); +const deployment = findCurrentArtifact(deploymentProofDir, "deployment", currentCommit); +const dossier = findCurrentArtifact(dossierDir, "dossier", currentCommit); + +const gates = [ + { + label: "Tracked worktree clean", + ok: status.ok && status.value.trim() === "", + detail: status.value.trim() || "clean", + }, + { + label: `Fetched ${gitRemote}/${gitBranch}`, + ok: fetchRemote.status === 0, + detail: fetchRemote.status === 0 ? "fetched" : fetchRemote.stderr.trim(), + }, + { + label: "Gitea remote matches local commit", + ok: fullCurrentCommit === remoteFullCommit, + detail: `${currentCommit} local / ${remoteCommit} remote`, + }, + { + label: "Tower deployment matches local commit", + ok: towerCommit === currentCommit, + detail: `${currentCommit} local / ${towerCommit} tower`, + }, + { + label: "Live health reachable", + ok: health.ok, + detail: `${health.status} ${health.body}`, + }, + { + label: "Current passing release proof exists", + ok: Boolean(proof), + detail: proof?.displayPath ?? "missing", + }, + { + label: "Current operator signoff exists", + ok: Boolean(signoff), + detail: signoff?.displayPath ?? "missing", + }, + { + label: "Current passing deployment proof exists", + ok: Boolean(deployment), + detail: deployment?.displayPath ?? "missing", + }, + { + label: "Current release evidence dossier exists", + ok: Boolean(dossier), + detail: dossier?.displayPath ?? "missing", + }, +]; + +const passed = gates.every((gate) => gate.ok); +const lines = [ + "# ProjectBrain Release Tag Ready Gate", + "", + `Generated: ${new Date().toISOString()}`, + `Commit: ${currentCommit}`, + `Remote: ${remoteRef}`, + `Tower URL: ${baseUrl}`, + `Suggested tag: ${tagName}`, + `Verdict: ${passed ? "PASS" : "ATTENTION REQUIRED"}`, + "", + "## Gates", + "", + "| Gate | Result | Detail |", + "| --- | --- | --- |", + ...gates.map((gate) => `| ${gate.label} | ${gate.ok ? "PASS" : "FAIL"} | ${String(gate.detail).replace(/\|/g, "\\|")} |`), + "", + "## Tag Commands", + "", + "Review the suggested tag name before running these commands:", + "", + "```bash", + `git tag -a ${tagName} -m "ProjectBrain release ${tagName}"`, + `git push ${gitRemote} ${tagName}`, + "```", + "", + "## Recovery", + "", + "```bash", + "PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 PROJECTBRAIN_RELEASE_DOSSIER=1 pnpm run release:bundle", + "pnpm run release:tag-gate", + "```", +].join("\n"); + +console.log(lines); + +if (shouldWrite) { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, `${lines}\n`, "utf8"); + console.log(`\nRelease tag ready report written to ${reportPath}`); +} + +if (!passed) { + process.exitCode = 1; +} diff --git a/scripts/release-user-notes.mjs b/scripts/release-user-notes.mjs new file mode 100644 index 0000000..24f52ae --- /dev/null +++ b/scripts/release-user-notes.mjs @@ -0,0 +1,150 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const outputPath = process.env.PROJECTBRAIN_USER_NOTES_OUT; +const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_USER_NOTES_WRITE === "1"; + +const run = (command, args) => spawnSync(command, args, { + encoding: "utf8", + shell: process.platform === "win32", +}); + +const git = (args) => { + const result = run("git", args); + return result.status === 0 ? result.stdout.trim() : "unknown"; +}; + +const latestReleaseTag = () => { + const result = run("git", ["tag", "--list", "projectbrain-rc-*", "--sort=-creatordate"]); + return result.status === 0 ? result.stdout.trim().split(/\r?\n/).find(Boolean) ?? "untagged" : "untagged"; +}; + +const createDefaultOutputPath = (tagName) => { + const safeTag = tagName.replace(/[^a-zA-Z0-9._-]/g, "-"); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return `checkpoints/user-release-notes/projectbrain-user-release-notes-${safeTag}-${stamp}.md`; +}; + +const commit = git(["rev-parse", "--short", "HEAD"]); +const tagName = process.env.PROJECTBRAIN_RELEASE_TAG ?? latestReleaseTag(); +const out = outputPath || createDefaultOutputPath(tagName); + +const notes = [ + "# ProjectBrain User Release Notes", + "", + `Generated: ${new Date().toISOString()}`, + `Release tag: ${tagName}`, + `Current commit: ${commit}`, + "", + "## What ProjectBrain Is For", + "", + "ProjectBrain is a project operating system for shaping, expanding and releasing software projects. It is optimized for turning ideas, existing repositories, source material and execution feedback into structured project memory, Codex-ready handoffs and release evidence.", + "", + "The current release is strongest as a local/operator-driven project builder. It combines project planning, repo shaping, session packaging, evidence review and release-readiness checks in one workspace.", + "", + "## Main User Flows", + "", + "### Start Or Open A Project", + "", + "- Use the Projects page to open an existing project.", + "- Use Start when creating or importing a new project direction.", + "- Existing projects remain the primary path for daily use; new-project setup is available when needed.", + "", + "### Shape A Repository", + "", + "- Use Repo Builder to inspect repository direction, gaps, state snapshots and delivery packages.", + "- Use skill-aware handoff content to make Codex work more repeatable.", + "- Use repo state and evidence instead of relying on memory from previous sessions.", + "", + "### Prepare Codex Work", + "", + "- Use Context Packs, Prompt Builder, Codex Handoffs and Session Packs to create a clear execution package.", + "- Use Browser Bridge when work must be copied into ChatGPT/Codex manually without API keys.", + "- Use Execution Feedback to bring results back into ProjectBrain as reviewable evidence.", + "", + "### Review And Close Work", + "", + "- Use Review Queue and History to inspect decisions, receipts and follow-up evidence.", + "- Use Build Loop and Runtime Readiness to track release-relevant validation.", + "- Use Release Center to collect proof before tagging or deploying.", + "", + "## Release And Operator Capabilities", + "", + "This release includes a complete local release-operator chain:", + "", + "- release smoke suite;", + "- proof report generation;", + "- proof freshness gate;", + "- operator signoff file;", + "- remote deployment proof;", + "- release evidence dossier;", + "- tag-ready gate;", + "- rollback drill.", + "", + "Useful commands:", + "", + "```bash", + "pnpm run release:bundle", + "PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 PROJECTBRAIN_RELEASE_DOSSIER=1 pnpm run release:bundle", + "pnpm run release:tag-gate", + "pnpm run release:rollback-drill", + "pnpm run release:user-notes", + "```", + "", + "## Deployment Notes", + "", + "The known live deployment target is:", + "", + "```text", + "http://127.0.0.1:1209", + "```", + "", + "Tower build/start commands:", + "", + "```bash", + "cd /mnt/pool/appdata/brain", + "docker compose build --no-cache frontend", + "docker compose up -d", + "```", + "", + "## What Remains Local Or Operator-Driven", + "", + "- Release evidence snapshots are local ignored files under `checkpoints/`.", + "- Several release commands are intended to run from the developer/operator workstation, not from inside the Unraid container.", + "- The rollback drill prints commands but does not execute a rollback.", + "- Tagging remains an explicit operator action.", + "", + "## Known Boundaries", + "", + "- This release does not claim full multi-user auth.", + "- This release does not replace all local/mock project surfaces with database-backed behavior.", + "- This release does not automate AI calls or require API keys.", + "- This release does not commit generated release evidence artifacts.", + "", + "## Recommended First Use", + "", + "1. Open an existing project from Projects.", + "2. Review Workspace and Repo Builder for current direction.", + "3. Generate or inspect a Session Pack before asking Codex to make changes.", + "4. Capture execution feedback after a coding session.", + "5. Use Release Center and Runtime Readiness before tagging.", + "", + "## Support Checklist", + "", + "When something looks wrong, collect:", + "", + "- current URL;", + "- project id;", + "- browser console error if present;", + "- latest `pnpm run release:deployment-proof` output;", + "- latest `pnpm run release:rollback-drill` output if deployment state is involved.", +].join("\n"); + +console.log(notes); + +if (shouldWrite) { + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, `${notes}\n`, "utf8"); + console.log(`\nUser release notes written to ${out}`); +} diff --git a/scripts/smoke-release-operator.mjs b/scripts/smoke-release-operator.mjs new file mode 100644 index 0000000..4dfdcce --- /dev/null +++ b/scripts/smoke-release-operator.mjs @@ -0,0 +1,50 @@ +import { spawnSync } from "node:child_process"; + +const steps = [ + { + label: "Release evidence lane click-through", + script: "scripts/smoke-rc63-release-evidence-lane.mjs", + }, + { + label: "Review decision receipt recovery", + script: "scripts/smoke-rc60-review-decision-loop.mjs", + }, + { + label: "Release evidence capture assist", + script: "scripts/smoke-rc73-release-evidence-capture-assist.mjs", + }, + { + label: "Release Center cockpit", + script: "scripts/smoke-rc88-release-center.mjs", + }, + { + label: "Release checklist click-through", + script: "scripts/smoke-rc99-release-checklist-clickthrough.mjs", + }, +]; + +const runStep = (step) => { + console.log(`\n[release-operator] ${step.label}`); + const result = spawnSync(process.execPath, [step.script], { + env: process.env, + shell: false, + detached: process.platform === "win32", + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${step.label} failed with exit code ${result.status}`); +}; + +try { + console.log("ProjectBrain release operator smoke suite"); + console.log(`Base URL: ${process.env.PROJECTBRAIN_SMOKE_URL ?? "default smoke URL"}`); + console.log(`Project: ${process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog"}`); + for (const step of steps) { + runStep(step); + } + console.log("\nRelease operator smoke suite passed."); +} catch (error) { + console.error("\nRelease operator smoke suite failed:"); + console.error(error.message); + process.exitCode = 1; +} diff --git a/scripts/stage-release-oci.sh b/scripts/stage-release-oci.sh new file mode 100644 index 0000000..ed28350 --- /dev/null +++ b/scripts/stage-release-oci.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 <40-character-commit-sha> " >&2 + exit 64 +fi + +RELEASE_SHA="$1" +ARTIFACT_DIR="${2%/}" +case "${RELEASE_SHA}" in + *[!0-9a-f]*|'') echo "Release SHA must contain exactly 40 lowercase hexadecimal characters." >&2; exit 64 ;; +esac +if [ "${#RELEASE_SHA}" -ne 40 ]; then + echo "Release SHA must contain exactly 40 lowercase hexadecimal characters." >&2 + exit 64 +fi + +API_ARCHIVE="${ARTIFACT_DIR}/projectbrain-api-${RELEASE_SHA}.tar" +WEB_ARCHIVE="${ARTIFACT_DIR}/projectbrain-web-${RELEASE_SHA}.tar" +DIGEST_FILE="${ARTIFACT_DIR}/archive-sha256.txt" +for path in "${API_ARCHIVE}" "${WEB_ARCHIVE}" "${DIGEST_FILE}"; do + if [ ! -f "${path}" ]; then echo "Missing release artifact: ${path}" >&2; exit 66; fi +done +for command in sha256sum skopeo docker curl; do + if ! command -v "${command}" >/dev/null 2>&1; then echo "Required command is unavailable: ${command}" >&2; exit 69; fi +done + +verify_digest() { + archive="$1" + filename="$(basename "${archive}")" + expected="$(awk -v file="${filename}" '$2 == file || $2 ~ ("/" file "$") { print $1; exit }' "${DIGEST_FILE}")" + actual="$(sha256sum "${archive}" | awk '{ print $1 }')" + if [ -z "${expected}" ] || [ "${actual}" != "${expected}" ]; then + echo "Digest mismatch for ${filename}." >&2 + exit 65 + fi + printf '%s' "${actual}" +} + +API_DIGEST="$(verify_digest "${API_ARCHIVE}")" +WEB_DIGEST="$(verify_digest "${WEB_ARCHIVE}")" +skopeo copy "oci-archive:${API_ARCHIVE}" "docker-daemon:projectbrain-api:${RELEASE_SHA}" +skopeo copy "oci-archive:${WEB_ARCHIVE}" "docker-daemon:projectbrain-web:${RELEASE_SHA}" + +API_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "projectbrain-api:${RELEASE_SHA}")" +WEB_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "projectbrain-web:${RELEASE_SHA}")" + +: "${POSTGRES_PASSWORD:?set a non-default POSTGRES_PASSWORD in the environment or .env}" +: "${PROJECTBRAIN_OWNER_PASSWORD:?set PROJECTBRAIN_OWNER_PASSWORD in the environment or .env}" +: "${PROJECTBRAIN_AUTH_SECRET:?set PROJECTBRAIN_AUTH_SECRET in the environment or .env}" +if [ "${POSTGRES_PASSWORD}" = "projectbrain" ]; then echo "Default PostgreSQL password is forbidden." >&2; exit 78; fi +if [ "${#PROJECTBRAIN_OWNER_PASSWORD}" -lt 12 ]; then echo "Owner password must contain at least 12 characters." >&2; exit 78; fi +if [ "${#PROJECTBRAIN_AUTH_SECRET}" -lt 32 ]; then echo "Auth secret must contain at least 32 characters." >&2; exit 78; fi + +export PROJECTBRAIN_RELEASE_SHA="${RELEASE_SHA}" +export PROJECTBRAIN_DEPLOY_SLOT="${PROJECTBRAIN_DEPLOY_SLOT:-stage}" +export PROJECTBRAIN_WEB_PORT="${PROJECTBRAIN_STAGE_WEB_PORT:-1210}" +COMPOSE_PROJECT="projectbrain-${PROJECTBRAIN_DEPLOY_SLOT}-${RELEASE_SHA%????????????????????????????????}" + +docker compose \ + --project-name "${COMPOSE_PROJECT}" \ + --file docker-compose.yml \ + --file docker-compose.release.yml \ + config --quiet +docker compose \ + --project-name "${COMPOSE_PROJECT}" \ + --file docker-compose.yml \ + --file docker-compose.release.yml \ + up --detach --no-build --pull never + +READINESS_URL="http://127.0.0.1:${PROJECTBRAIN_WEB_PORT}/api/system/readiness" +attempt=0 +until READINESS="$(curl --fail --silent --show-error "${READINESS_URL}")"; do + attempt=$((attempt + 1)) + if [ "${attempt}" -ge 30 ]; then echo "Staged release did not become ready: ${READINESS_URL}" >&2; exit 70; fi + sleep 2 +done + +EVIDENCE_FILE="${ARTIFACT_DIR}/staged-deployment-${RELEASE_SHA}.txt" +{ + echo "release_sha=${RELEASE_SHA}" + echo "compose_project=${COMPOSE_PROJECT}" + echo "web_port=${PROJECTBRAIN_WEB_PORT}" + echo "api_archive_sha256=${API_DIGEST}" + echo "web_archive_sha256=${WEB_DIGEST}" + echo "api_image_id=${API_IMAGE_ID}" + echo "web_image_id=${WEB_IMAGE_ID}" + echo "readiness_url=${READINESS_URL}" + echo "readiness=${READINESS}" + echo "verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} > "${EVIDENCE_FILE}" + +echo "Staged immutable release ${RELEASE_SHA} as ${COMPOSE_PROJECT}." +echo "Evidence: ${EVIDENCE_FILE}" diff --git a/scripts/unraid-single-entrypoint.sh b/scripts/unraid-single-entrypoint.sh new file mode 100644 index 0000000..c9712c9 --- /dev/null +++ b/scripts/unraid-single-entrypoint.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env sh +set -eu + +API_LOG="/tmp/projectbrain-api.log" +PG_LOG="/tmp/projectbrain-postgres.log" +ANALYSIS_WORKER_LOG="/tmp/projectbrain-analysis-worker.log" +PROJECTBRAIN_DATA_DIR="${PROJECTBRAIN_DATA_DIR:-/var/lib/projectbrain}" +PGDATA="${PROJECTBRAIN_POSTGRES_DATA_DIR:-${PROJECTBRAIN_DATA_DIR}/postgres}" +POSTGRES_DB="${POSTGRES_DB:-projectbrain}" +POSTGRES_USER="${POSTGRES_USER:-projectbrain}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" +if [ "${PROJECTBRAIN_USE_EXTERNAL_DATABASE:-false}" != "true" ]; then + : "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for embedded PostgreSQL}" + export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}?schema=public" +else + export DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required when PROJECTBRAIN_USE_EXTERNAL_DATABASE=true}" +fi + +mkdir -p "${PGDATA}" +mkdir -p /run/postgresql +chown -R postgres:postgres "${PROJECTBRAIN_DATA_DIR}" +chown postgres:postgres /run/postgresql + +if [ ! -s "${PGDATA}/PG_VERSION" ]; then + echo "[ProjectBrain] Initializing embedded PostgreSQL data directory..." + pwfile="/tmp/projectbrain-pgpass" + printf '%s' "${POSTGRES_PASSWORD}" > "${pwfile}" + chown postgres:postgres "${pwfile}" + su postgres -c "initdb -D '${PGDATA}' -U '${POSTGRES_USER}' --pwfile='${pwfile}' --auth-host=md5 --auth-local=trust" >/tmp/projectbrain-initdb.log 2>&1 || { + cat /tmp/projectbrain-initdb.log >&2 || true + exit 1 + } + rm -f "${pwfile}" +fi + +echo "[ProjectBrain] Starting embedded PostgreSQL..." +su postgres -c "pg_ctl -D '${PGDATA}' -o \"-c listen_addresses='127.0.0.1'\" -w start" > "${PG_LOG}" 2>&1 + +if ! su postgres -c "psql -U '${POSTGRES_USER}' -d postgres -tAc \"SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'\"" | grep -q 1; then + echo "[ProjectBrain] Creating database ${POSTGRES_DB}..." + su postgres -c "createdb -U '${POSTGRES_USER}' '${POSTGRES_DB}'" +fi + +cd /app/backend + +echo "[ProjectBrain] Starting API inside single-container runtime..." +NODE_OPTIONS="--max-old-space-size=${PROJECTBRAIN_API_HEAP_MB:-512}" sh scripts/docker-entrypoint.sh > "${API_LOG}" 2>&1 & +API_PID="$!" +ANALYSIS_WORKER_SUPERVISOR_PID="" +NGINX_PID="" + +cleanup() { + echo "[ProjectBrain] Stopping runtime..." + if [ -n "${ANALYSIS_WORKER_SUPERVISOR_PID}" ]; then + kill "${ANALYSIS_WORKER_SUPERVISOR_PID}" 2>/dev/null || true + fi + if [ -n "${NGINX_PID}" ]; then + kill "${NGINX_PID}" 2>/dev/null || true + fi + kill "${API_PID}" 2>/dev/null || true + su postgres -c "pg_ctl -D '${PGDATA}' -m fast stop" >/dev/null 2>&1 || true +} +trap cleanup INT TERM + +echo "[ProjectBrain] Waiting for API readiness..." +attempt=0 +until curl -fsS "http://127.0.0.1:4000/api/system/readiness" >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if ! kill -0 "${API_PID}" 2>/dev/null; then + echo "[ProjectBrain] API exited before readiness." >&2 + cat "${API_LOG}" >&2 || true + exit 1 + fi + if [ "${attempt}" -ge 60 ]; then + echo "[ProjectBrain] API did not become ready in time." >&2 + cat "${API_LOG}" >&2 || true + exit 1 + fi + sleep 1 +done + +run_analysis_worker() ( + child_pid="" + stop_worker() { + if [ -n "${child_pid}" ]; then + kill "${child_pid}" 2>/dev/null || true + wait "${child_pid}" 2>/dev/null || true + fi + exit 0 + } + trap stop_worker INT TERM + while true; do + echo "[ProjectBrain] Starting bounded repository-analysis worker..." >> "${ANALYSIS_WORKER_LOG}" + NODE_OPTIONS="--max-old-space-size=${PROJECTBRAIN_ANALYSIS_WORKER_HEAP_MB:-384}" node dist/workers/analysisWorker.js >> "${ANALYSIS_WORKER_LOG}" 2>&1 & + child_pid="$!" + echo 500 > "/proc/${child_pid}/oom_score_adj" 2>/dev/null || true + wait "${child_pid}" || true + child_pid="" + sleep 5 + done +) +run_analysis_worker & +ANALYSIS_WORKER_SUPERVISOR_PID="$!" + +echo "[ProjectBrain] API and analysis worker are ready. Starting nginx..." +nginx -g "daemon off;" & +NGINX_PID="$!" + +wait "${NGINX_PID}" diff --git a/scripts/verify-release-metadata.mjs b/scripts/verify-release-metadata.mjs new file mode 100644 index 0000000..add891a --- /dev/null +++ b/scripts/verify-release-metadata.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import process from "node:process"; + +const root = resolve(import.meta.dirname, ".."); +const outputFlag = process.argv.indexOf("--output"); +const output = resolve(root, outputFlag >= 0 ? process.argv[outputFlag + 1] : "release-artifacts"); +const sbomText = await readFile(resolve(output, "projectbrain.cdx.json"), "utf8"); +const provenance = JSON.parse(await readFile(resolve(output, "projectbrain.provenance.json"), "utf8")); +const sbom = JSON.parse(sbomText); +const canonicalSbom = `${JSON.stringify(sbom, null, 2)}\n`; +const digest = createHash("sha256").update(JSON.stringify(sbom)).digest("hex"); +const failures = []; +if (sbom.bomFormat !== "CycloneDX" || sbom.specVersion !== "1.6" || !Array.isArray(sbom.components) || sbom.components.length === 0) failures.push("SBOM is not a populated CycloneDX 1.6 document"); +if (sbomText !== canonicalSbom) failures.push("SBOM is not canonical pretty-printed JSON"); +if (provenance.predicateType !== "https://slsa.dev/provenance/v1" || !provenance.subject?.length) failures.push("Provenance has no SLSA v1 subjects"); +if (provenance.sbom?.sha256 !== digest) failures.push("Provenance SBOM digest does not match"); +if (failures.length) { console.error(failures.join("\n")); process.exit(1); } +console.log(`Release metadata verified: ${sbom.components.length} components and ${provenance.subject.length} subjects.`); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..98ad663 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,36 @@ +import { Toaster } from "@/components/ui/toaster"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import { AppRoutes } from "./app/routes"; +import { RuntimeErrorBoundary } from "@/shared/components/RuntimeErrorBoundary"; +import { AuthProvider, useAuth } from "@/shared/contexts/AuthContext"; +import { Login } from "@/features/auth/pages/Login"; + +const AuthenticatedApp = () => { + const { user, loading } = useAuth(); + if (loading) return
    ProjectBrain wordt veilig geladen…
    ; + if (!user) return ; + return ; +}; + +const queryClient = new QueryClient(); + +const App = () => ( + + + + + + + + + + + + + +); + +export default App; diff --git a/src/app/routes.tsx b/src/app/routes.tsx new file mode 100644 index 0000000..6c0e990 --- /dev/null +++ b/src/app/routes.tsx @@ -0,0 +1,269 @@ +import React from "react"; +import { Link, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom"; +import { ProjectProvider, useProject } from "@/shared/contexts/ProjectContext"; +import AppLayout from "@/components/layout/AppLayout"; +import Projects from "@/features/projects/pages/Projects"; +import GuidedStart from "@/features/start/pages/GuidedStart"; +import NotFound from "@/shared/pages/NotFound"; +import { Button } from "@/components/ui/button"; +import { projectBrainClientMode } from "@/shared/api/projectBrainClient"; +import { ProjectRouteFallback } from "@/shared/components/ProjectRouteFallback"; +import { resolveProjectResume } from "@/shared/lib/projectResume"; +import { projectRoutePath, projectRoutePattern } from "@/shared/lib/routeRegistry"; +import { resetApplicationScroll } from "./scrollReset"; + +const ProjectSetupFlow = React.lazy(() => import("@/features/projects/pages/FirstRunProjectWizard")); +const ProjectOverview = React.lazy(() => import("@/features/projects/pages/ProjectOverview")); +const Workspace = React.lazy(() => import("@/features/projects/pages/Workspace")); +const ProjectResults = React.lazy(() => import("@/features/projects/pages/ProjectResults")); +const ProjectDNA = React.lazy(() => import("@/features/projects/pages/ProjectDNA")); +const RuntimeReadiness = React.lazy(() => import("@/features/projects/pages/RuntimeReadiness")); +const RepositoryAnalysisJobs = React.lazy(() => import("@/features/operations/pages/RepositoryAnalysisJobs")); +const FindingInbox = React.lazy(() => import("@/features/operations/pages/FindingInbox")); +const Insights = React.lazy(() => import("@/features/insights/pages/Insights")); +const ContextPacks = React.lazy(() => import("@/features/context-packs/pages/ContextPacks")); +const FeatureMap = React.lazy(() => import("@/features/features/pages/FeatureMap")); +const FeatureDetail = React.lazy(() => import("@/features/features/pages/FeatureDetail")); +const AIReview = React.lazy(() => import("@/features/checks/pages/AIReview")); +const MemoryRules = React.lazy(() => import("@/features/memory/pages/MemoryRules")); +const PromptGenerator = React.lazy(() => import("@/features/prompts/pages/PromptGenerator")); +const BuildJournal = React.lazy(() => import("@/features/builds/pages/BuildJournal")); +const RegressionMemory = React.lazy(() => import("@/features/regression/pages/RegressionMemory")); +const Tasks = React.lazy(() => import("@/features/tasks/pages/Tasks")); +const Bugs = React.lazy(() => import("@/features/bugs/pages/Bugs")); +const Decisions = React.lazy(() => import("@/features/decisions/pages/Decisions")); +const Relationships = React.lazy(() => import("@/features/relationships/pages/RelationshipGraph")); +const KnowledgeVault = React.lazy(() => import("@/features/knowledge/pages/KnowledgeVault")); +const ArchitectureMap = React.lazy(() => import("@/features/architecture/pages/ArchitectureMap")); +const Snapshots = React.lazy(() => import("@/features/snapshots/pages/Snapshots")); +const SnapshotDetail = React.lazy(() => import("@/features/snapshots/pages/SnapshotDetail")); +const CaptureCenter = React.lazy(() => import("@/features/operations/pages/CaptureCenter")); +const ReviewQueue = React.lazy(() => import("@/features/operations/pages/ReviewQueue")); +const AIActionCenter = React.lazy(() => import("@/features/operations/pages/AIActionCenter")); +const Initiatives = React.lazy(() => import("@/features/operations/pages/Initiatives")); +const InitiativeDetail = React.lazy(() => import("@/features/operations/pages/InitiativeDetail")); +const DocumentationHub = React.lazy(() => import("@/features/operations/pages/DocumentationHub")); +const ExtraSources = React.lazy(() => import("@/features/source-library/pages/ExtraSources")); +const SourceLibrary = React.lazy(() => import("@/features/source-library/pages/SourceLibrary")); +const ProjectActivity = React.lazy(() => import("@/features/history/pages/ProjectActivity")); +const ProjectHistory = React.lazy(() => import("@/features/history/pages/ProjectHistory")); +const ReleaseCenter = React.lazy(() => import("@/features/release/pages/ReleaseCenter")); +const ExecutionCenter = React.lazy(() => import("@/features/execution/pages/ExecutionCenter")); +const ExecutionFeedback = React.lazy(() => import("@/features/execution-feedback/pages/ExecutionFeedback")); +const BuildLoopDashboard = React.lazy(() => import("@/features/build-loop/pages/BuildLoopDashboard")); +const AutoMode = React.lazy(() => import("@/features/autonomy/pages/AutoMode")); +const SessionPacks = React.lazy(() => import("@/features/session-packs/pages/SessionPacks")); +const BrowserBridge = React.lazy(() => import("@/features/browser-bridge/pages/BrowserBridge")); +const SkillLibrary = React.lazy(() => import("@/features/skills/pages/SkillLibrary")); +const CodexHandoffs = React.lazy(() => import("@/features/handoffs/pages/CodexHandoffs")); +const BlueprintEngine = React.lazy(() => import("@/features/blueprint/pages/BlueprintEngine")); +const RepoBuilder = React.lazy(() => import("@/features/repo-builder/pages/RepoBuilder")); +const DevelopmentWorkbench = React.lazy(() => import("@/features/development/pages/DevelopmentWorkbench")); +const ProjectAuditCenter = React.lazy(() => import("@/features/audit/pages/ProjectAuditCenter")); +const BrainstormLab = React.lazy(() => import("@/features/brainstorm/pages/BrainstormLab")); +const OperatingLog = React.lazy(() => import("@/features/operations/pages/OperatingLog")); +const Settings = React.lazy(() => import("@/features/settings/pages/Settings")); +const PortfolioHome = React.lazy(() => import("@/features/portfolio/pages/PortfolioHome")); +const PortfolioCommandCenter = React.lazy(() => import("@/features/portfolio/pages/PortfolioCommandCenter")); + +const RouteLoadingFallback = () => ( +
    +
    +
    +
    +
    +
    +
    +
    +

    Project laden…

    +
    +
    +
    +
    +
    +
    +
    +); + +const lazyElement = (page: React.ReactNode) => }>{page}; +const ProjectPageScope = ({ children }: { children: React.ReactNode }) => { + const { projectId } = useParams(); + return {lazyElement(children)}; +}; +const projectElement = (page: React.ReactNode) => {page}; + +const LocalLabBoundary = ({ children }: { children: React.ReactNode }) => { + const { projectId } = useParams(); + if (projectBrainClientMode !== "real") return children; + return
    +

    Deze technische labmodule is niet actief

    +

    Deze oudere expertmodule gebruikt nog lokale voorbeelddata. ProjectBrain blokkeert hier bewust opslagacties zodat er geen schijnbare wijzigingen ontstaan.

    +
    +
    ; +}; +const labElement = (page: React.ReactNode) => projectElement({page}); + +const ScrollToTop = () => { + const location = useLocation(); + React.useEffect(() => { + const frame = requestAnimationFrame(() => { + const main = document.querySelector('[data-projectbrain-main="true"]'); + let focusMain = false; + try { + focusMain = window.sessionStorage.getItem("projectbrain.route-visited.v1") === "true"; + window.sessionStorage.setItem("projectbrain.route-visited.v1", "true"); + } catch { + // A blocked session store should not prevent the page from loading. + } + resetApplicationScroll(main instanceof HTMLElement ? main : undefined, window, focusMain); + }); + return () => cancelAnimationFrame(frame); + }, [location.pathname]); + return null; +}; + +const ProjectRouteSync = () => { + const location = useLocation(); + const { activeProjectId, projects, setActiveProjectId } = useProject(); + const routeProjectId = React.useMemo(() => { + const match = location.pathname.match(/^\/projects\/([^/]+)/); + const projectId = match?.[1] ? decodeURIComponent(match[1]) : ""; + return projectId && projectId !== "new" ? projectId : ""; + }, [location.pathname]); + const routeProjectExists = React.useMemo(() => projects.some((project) => project.id === routeProjectId), [projects, routeProjectId]); + React.useEffect(() => { + if (routeProjectId && routeProjectExists && routeProjectId !== activeProjectId) setActiveProjectId(routeProjectId); + }, [activeProjectId, routeProjectExists, routeProjectId, setActiveProjectId]); + return null; +}; + +const ProjectHomeRedirect = () => { + const { projectId } = useParams(); + const { projects, projectDataLoading } = useProject(); + const project = projects.find((item) => item.id === projectId); + const [loading, setLoading] = React.useState(projectBrainClientMode === "real"); + const [route, setRoute] = React.useState(); + const loadGeneration = React.useRef(0); + + React.useEffect(() => { + if (projectDataLoading) return; + const generation = ++loadGeneration.current; + if (!projectId || !project) { + setLoading(false); + setRoute(undefined); + return; + } + if (projectBrainClientMode !== "real") { + setRoute(resolveProjectResume({ projectId, project }).route); + setLoading(false); + return; + } + + setLoading(true); + void Promise.allSettled([ + import("@/shared/api/realClient").then(({ realClient }) => realClient.initiatives.listFeatureDeliveries(projectId).then((response) => response.data)), + import("@/shared/api/autonomyApi").then(({ autonomyApi }) => autonomyApi.get(projectId)), + ]).then(([deliveryResult, autoResult]) => { + if (generation !== loadGeneration.current) return; + const deliveries = deliveryResult.status === "fulfilled" ? deliveryResult.value : []; + const autoState = autoResult.status === "fulfilled" ? autoResult.value : undefined; + const attentionCount = project.resourceCounts?.attentionItems ?? 0; + setRoute(resolveProjectResume({ + projectId, + project, + deliveries, + autoState, + attentionRequired: attentionCount > 0 ? true : undefined, + attentionDetail: attentionCount ? `${attentionCount} aandachtspunt${attentionCount === 1 ? " wacht" : "en wachten"} op afhandeling.` : undefined, + }).route); + setLoading(false); + }); + return () => { loadGeneration.current += 1; }; + }, [project, projectDataLoading, projectId]); + + if (projectDataLoading || loading) return ; + if (!project) return ; + return ; +}; + +const DefaultHomeRedirect = () => { + const { activeProjectId, projectDataLoading } = useProject(); + if (projectDataLoading) return ; + if (projectBrainClientMode === "real") return ; + if (activeProjectId) return ; + return ; +}; + +export const AppRoutes = () => ( + + + + + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + } /> + + +); diff --git a/src/app/scrollReset.test.ts b/src/app/scrollReset.test.ts new file mode 100644 index 0000000..c9e9673 --- /dev/null +++ b/src/app/scrollReset.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; +import { resetApplicationScroll } from "./scrollReset"; + +describe("application scroll reset", () => { + it("resets both the app scroller and viewport before moving focus", () => { + const calls: string[] = []; + const main = { + scrollTo: vi.fn(() => calls.push("main-scroll")), + focus: vi.fn(() => calls.push("main-focus")), + }; + const viewport = { + scrollTo: vi.fn(() => calls.push("viewport-scroll")), + }; + + resetApplicationScroll(main, viewport); + + expect(main.scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" }); + expect(viewport.scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" }); + expect(main.focus).toHaveBeenCalledWith({ preventScroll: true }); + expect(calls).toEqual(["main-scroll", "viewport-scroll", "main-focus"]); + }); + + it("still resets the viewport before the layout scroller exists", () => { + const viewport = { scrollTo: vi.fn() }; + + resetApplicationScroll(undefined, viewport); + + expect(viewport.scrollTo).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/scrollReset.ts b/src/app/scrollReset.ts new file mode 100644 index 0000000..546daf9 --- /dev/null +++ b/src/app/scrollReset.ts @@ -0,0 +1,19 @@ +type ScrollOptionsTarget = { + scrollTo: (options: ScrollToOptions) => void; +}; + +type FocusTarget = ScrollOptionsTarget & { + focus: (options?: FocusOptions) => void; +}; + +const topLeft: ScrollToOptions = { top: 0, left: 0, behavior: "auto" }; + +export const resetApplicationScroll = ( + main: FocusTarget | undefined, + viewport: ScrollOptionsTarget, + focusMain = true, +) => { + main?.scrollTo(topLeft); + viewport.scrollTo(topLeft); + if (focusMain) main?.focus({ preventScroll: true }); +}; diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..45dadd8 --- /dev/null +++ b/src/components/layout/AppLayout.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from 'react'; +import Sidebar from './Sidebar'; +import Header from './Header'; + +interface AppLayoutProps { + children: React.ReactNode; +} + +const AppLayout = ({ children }: AppLayoutProps) => { + return ( +
    + ); +}; + +export default AppLayout; diff --git a/src/components/layout/CommandMenu.tsx b/src/components/layout/CommandMenu.tsx new file mode 100644 index 0000000..4c5957c --- /dev/null +++ b/src/components/layout/CommandMenu.tsx @@ -0,0 +1,74 @@ +"use client"; + +import * as React from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { Activity, ArrowRight, Bot, CheckSquare, FileText, History, ListTodo, RefreshCw, Search, ShieldAlert, Sparkles } from "lucide-react"; +import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } from "@/components/ui/command"; +import { useProject } from "@/shared/contexts/ProjectContext"; +import { getNavigationGroups, getProjectIdFromPath, type NavigationItem } from "@/shared/lib/navigation"; +import { getCommandResultKey, rankCommandResults, rankNavigationItems } from "./commandMenuSearch"; +import { getProjectOperatingStep } from "@/shared/lib/projectOperatingStep"; + +const NavigationCommandItem = ({ item, onSelect }: { item: NavigationItem; onSelect: (path: string) => void }) => { + const Icon = item.icon; + return onSelect(item.path)}>
    {item.label}{item.description}
    ; +}; + +export function CommandMenu() { + const [open, setOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(""); + const navigate = useNavigate(); + const location = useLocation(); + const { projects, searchProjectResults, searchGlobalResults } = useProject(); + const navigationProjectId = getProjectIdFromPath(location.pathname); + const isSearching = searchQuery.trim().length > 0; + const navigationGroups = React.useMemo(() => getNavigationGroups(navigationProjectId), [navigationProjectId]); + const routeProject = React.useMemo(() => projects.find((project) => project.id === navigationProjectId), [navigationProjectId, projects]); + const projectNameById = React.useMemo(() => new Map(projects.map((project) => [project.id, project.name])), [projects]); + const nextStep = React.useMemo(() => routeProject ? getProjectOperatingStep(routeProject) : undefined, [routeProject]); + const projectResults = React.useMemo(() => open && isSearching && navigationProjectId ? searchProjectResults(navigationProjectId) : [], [isSearching, navigationProjectId, open, searchProjectResults]); + const allGlobalResults = React.useMemo(() => open && isSearching ? searchGlobalResults() : [], [isSearching, open, searchGlobalResults]); + const activeProjectResultKeys = React.useMemo(() => new Set(projectResults.map(getCommandResultKey)), [projectResults]); + const rankedProjectResults = React.useMemo(() => rankCommandResults(projectResults, searchQuery, 16), [projectResults, searchQuery]); + const globalResults = React.useMemo(() => rankCommandResults(allGlobalResults, searchQuery, 10, activeProjectResultKeys), [activeProjectResultKeys, allGlobalResults, searchQuery]); + const navigationSearchItems = React.useMemo(() => { + if (!isSearching) return []; + const uniqueItems = navigationGroups + .flatMap((group) => group.items) + .filter((item, index, items) => items.findIndex((candidate) => candidate.path === item.path) === index); + return rankNavigationItems(uniqueItems, searchQuery, 8); + }, [isSearching, navigationGroups, searchQuery]); + + React.useEffect(() => { + const down = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); setOpen((value) => !value); } + }; + window.addEventListener("keydown", down, { capture: true }); + return () => window.removeEventListener("keydown", down, { capture: true }); + }, []); + + const runCommand = (command: () => void) => { setOpen(false); setSearchQuery(""); command(); }; + const navigateTo = (path: string) => runCommand(() => navigate(path)); + const handleOpenChange = (value: boolean) => { setOpen(value); if (!value) setSearchQuery(""); }; + const searchPlaceholder = navigationProjectId ? "Zoek op repo, review, contextpack, historiek, bron of skill…" : "Zoek project, pagina, instelling of route…"; + const navigationSearchGroup = navigationSearchItems.length ? {navigationSearchItems.map((item) => )} : null; + + return <> + + + + + Geen resultaten gevonden. + {!isSearching ? <> + {nextStep ? navigateTo(nextStep.route)}>
    {nextStep.label}{nextStep.description}
    : null} + {navigationProjectId ? <> navigateTo(`/projects/${navigationProjectId}`)}>Projectoverzicht navigateTo(`/projects/${navigationProjectId}/workspace`)}>Opdracht geven navigateTo(`/projects/${navigationProjectId}/attention`)}>Jouw aandacht navigateTo(`/projects/${navigationProjectId}/activity`)}>Actieve uitvoering & activiteit navigateTo(`/projects/${navigationProjectId}/results`)}>Laatste resultaten navigateTo(`/projects/${navigationProjectId}/auto`)}>Auto Mode navigateTo(`/projects/${navigationProjectId}/runtime`)}>Systeemstatus : navigateTo("/portfolio")}>Home navigateTo("/projects")}>Projecten navigateTo("/start")}>Nieuw project navigateTo("/platform#portfolio-diagnostics")}>Platformdiagnose} + navigateTo("/projects")}>Bestaande projecten openenProjecten navigateTo("/start")}>Nieuw project startenStart + : <> + {navigationSearchGroup} + {rankedProjectResults.length ? <>{rankedProjectResults.map((result) => navigateTo(result.route)}>
    {result.label}{result.description}
    )}
    : null} + {globalResults.length ? <>{globalResults.map((result) => navigateTo(result.route)}>
    {result.label}{projectNameById.get(result.projectId) ?? "Project"}
    )}
    : null} + } +
    +
    + ; +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx new file mode 100644 index 0000000..b23f7f7 --- /dev/null +++ b/src/components/layout/Header.tsx @@ -0,0 +1,373 @@ +"use client"; + +import React from "react"; +import { AlertCircle, AlertTriangle, Bell, Check, CheckCircle2, ChevronDown, Database, FolderKanban, Info, Plus, Server, Star, User } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useOptionalProject } from "@/shared/contexts/ProjectContext"; +import { CommandMenu } from "./CommandMenu"; +import { Badge } from "@/components/ui/badge"; +import { getNavigationBreadcrumbs, getNavigationItemForPath, getProjectIdFromPath } from "@/shared/lib/navigation"; +import { useBackendSystemReadinessData, usePortfolioControlPlaneData } from "@/shared/hooks/useProjectData"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useAuth } from "@/shared/contexts/AuthContext"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command"; +import { projectBrainClientMode } from "@/shared/api/projectBrainClient"; +import { projectEntryPath } from "@/shared/lib/routeRegistry"; + +const readinessTone = { + ready: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300", + degraded: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300", + blocked: "border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300", + unknown: "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300", +} as const; + +const readinessLabel = { + ready: "Systeem gereed", + degraded: "Controle nodig", + blocked: "Geblokkeerd", + unknown: "Controleren", +} as const; + +const workflowLabel = { + Home: "Overzicht", + Blueprint: "Ontwerp", + Intake: "Invoer", + Knowledge: "Kennis", + Delivery: "Uitvoering", + System: "Systeem", +} as const; + +const READ_NOTIFICATIONS_KEY = "projectbrain:notifications:read:v1"; + +const Header = () => { + const { user, logout } = useAuth(); + const projectContext = useOptionalProject(); + const projects = React.useMemo(() => projectContext?.projects ?? [], [projectContext?.projects]); + const headerNotifications = projectContext?.headerNotifications ?? []; + const navigate = useNavigate(); + const location = useLocation(); + const [projectPickerOpen, setProjectPickerOpen] = React.useState(false); + const [favoriteProjectIds, setFavoriteProjectIds] = React.useState(() => { + try { + const stored = JSON.parse(window.localStorage.getItem("projectbrain.favoriteProjects.v1") ?? "[]") as unknown; + return Array.isArray(stored) ? stored.filter((item): item is string => typeof item === "string") : []; + } catch { + return []; + } + }); + const [readNotificationIds, setReadNotificationIds] = React.useState(() => { + try { + const stored = JSON.parse(window.localStorage.getItem(READ_NOTIFICATIONS_KEY) ?? "[]") as unknown; + return Array.isArray(stored) ? stored.filter((item): item is string => typeof item === "string") : []; + } catch { + return []; + } + }); + const routeProjectId = getProjectIdFromPath(location.pathname); + const routeProject = routeProjectId ? projects.find((project) => project.id === routeProjectId) : undefined; + const activeProject = routeProjectId ? routeProject : projectContext?.activeProject; + const navigationProjectId = routeProjectId; + const favoriteProjectIdSet = React.useMemo(() => new Set(favoriteProjectIds), [favoriteProjectIds]); + const orderedProjects = React.useMemo(() => [...projects].sort((left, right) => { + if (left.id === activeProject?.id) return -1; + if (right.id === activeProject?.id) return 1; + return Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || left.name.localeCompare(right.name); + }), [activeProject?.id, projects]); + const favoriteProjects = orderedProjects.filter((project) => favoriteProjectIdSet.has(project.id)); + const recentProjects = orderedProjects.filter((project) => !favoriteProjectIdSet.has(project.id)).slice(0, 5); + const remainingProjects = orderedProjects.filter((project) => !favoriteProjectIdSet.has(project.id) && !recentProjects.some((recent) => recent.id === project.id)); + const currentNavigationItem = getNavigationItemForPath(location.pathname, navigationProjectId); + const breadcrumbs = getNavigationBreadcrumbs(location.pathname, navigationProjectId, routeProject?.name); + const systemReadiness = useBackendSystemReadinessData(); + const portfolioResource = usePortfolioControlPlaneData(); + const portfolioData = portfolioResource.data; + const reloadPortfolio = portfolioResource.reload; + const readiness = systemReadiness.data; + const isRuntimeUnavailable = !readiness && !systemReadiness.isLoading; + const readinessStatus = readiness?.status ?? "unknown"; + const runtimeStatusLabel = isRuntimeUnavailable ? "Lokale modus" : readinessLabel[readinessStatus]; + const failingChecks = readiness?.checks.filter((check) => check.status === "fail") ?? []; + const warningChecks = readiness?.checks.filter((check) => check.status === "warn") ?? []; + React.useEffect(() => { + const refreshVisibleState = () => { + if (document.visibilityState === "visible") reloadPortfolio(); + }; + const timer = window.setInterval(refreshVisibleState, 60_000); + document.addEventListener("visibilitychange", refreshVisibleState); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", refreshVisibleState); + }; + }, [reloadPortfolio]); + const notifications = portfolioData?.notifications.map((notification) => ({ + ...notification, + time: new Date(notification.occurredAt).toLocaleString("nl-BE", { dateStyle: "short", timeStyle: "short" }), + })) ?? headerNotifications.map((notification) => ({ + id: notification.id, + severity: notification.icon === "alert" ? "warning" as const : notification.icon === "check" ? "info" as const : "info" as const, + title: notification.title, + description: notification.description, + route: activeProject ? `/projects/${activeProject.id}/history` : "/projects", + time: notification.time, + })); + const unreadNotifications = notifications.filter((notification) => !readNotificationIds.includes(notification.id)); + + const persistReadNotifications = (ids: string[]) => { + setReadNotificationIds(ids); + try { + window.localStorage.setItem(READ_NOTIFICATIONS_KEY, JSON.stringify(ids)); + } catch { + // Reading notifications remains available for this session when storage is unavailable. + } + }; + + const markNotificationRead = (notificationId: string) => { + if (readNotificationIds.includes(notificationId)) return; + persistReadNotifications([...readNotificationIds, notificationId]); + }; + + const selectProject = (project: (typeof projects)[number]) => { + projectContext?.setActiveProject(project); + projectContext?.setActiveProjectId(project.id); + setProjectPickerOpen(false); + navigate(projectEntryPath(project.id)); + }; + + const toggleFavoriteProject = (projectId: string) => { + setFavoriteProjectIds((current) => { + const next = current.includes(projectId) ? current.filter((id) => id !== projectId) : [...current, projectId]; + try { + window.localStorage.setItem("projectbrain.favoriteProjects.v1", JSON.stringify(next)); + } catch { + // Favorites remain available for this session when storage is unavailable. + } + return next; + }); + }; + + const renderProjectItem = (project: (typeof projects)[number]) => ( + selectProject(project)} className="gap-2 py-2.5"> + + {project.name} + {activeProject?.id === project.id ? : null} + + + ); + + return ( +
    +
    + + + + + + + + + Geen projecten gevonden. + {favoriteProjects.length ? {favoriteProjects.map(renderProjectItem)} : null} + {favoriteProjects.length ? : null} + {recentProjects.length ? {recentProjects.map(renderProjectItem)} : null} + {remainingProjects.length ? <>{remainingProjects.map(renderProjectItem)} : null} + +
    + +
    +
    +
    +
    + + {currentNavigationItem ? ( +
    +

    + {currentNavigationItem.shortLabel ?? currentNavigationItem.label} +

    +
    + ) : null} + + {currentNavigationItem && ( +
    +
    + + {workflowLabel[currentNavigationItem.workflow]} + +

    {currentNavigationItem.label}

    +
    + +
    + )} + + {projectContext ?
    + +
    : null} +
    + +
    + + + + + +
    +
    +
    +

    Runtimestatus

    +

    + {isRuntimeUnavailable + ? "De frontend gebruikt lokale projectdata; backend-readiness is niet gekoppeld." + : "Live status van backend, database en migraties."} +

    +
    + {runtimeStatusLabel} +
    +
    +
    +
    +
    +

    Databron

    +

    {readiness?.dataSource ?? "unknown"}

    +
    +
    +

    Database

    +

    {readiness?.database.reachable ? "bereikbaar" : "niet bevestigd"}

    +
    +
    +

    Projecten

    +

    {readiness?.database.projectCount ?? "niet gekoppeld"}

    +
    +
    +

    Migraties

    +

    {readiness?.database.migrationCount ?? "niet gekoppeld"}

    +
    +
    + {isRuntimeUnavailable ? ( +
    +

    Lokale voorbeeldmodus is actief.

    +

    De interface blijft bruikbaar met lokale data. Start de backend voor live database-readiness.

    +
    + ) : (failingChecks.length > 0 || warningChecks.length > 0) ? ( +
    +

    Aandacht nodig

    +

    {failingChecks.length} mislukte controles, {warningChecks.length} waarschuwingen.

    +
    + ) : ( +
    +

    De runtime reageert normaal.

    +

    API, databaseprobe en readinesscontroles zijn groen.

    +
    + )} +
    + + +
    +
    +
    +
    + + + + + + +
    +
    +

    Meldingen

    + {unreadNotifications.length ? : null} +
    +
    +
    + {notifications.length === 0 &&
    Geen open aandachtspunten. ProjectBrain blijft de portfolioflow bewaken.
    } + {notifications.map((notification) => { + const Icon = notification.severity === "critical" ? AlertCircle : notification.severity === "warning" ? AlertTriangle : notification.id === "portfolio-healthy" ? CheckCircle2 : Info; + const color = notification.severity === "critical" ? "text-red-600" : notification.severity === "warning" ? "text-amber-600" : "text-blue-600"; + return ( + + ); + })} +
    +
    + +
    +
    +
    + + + + + + + {user?.username ?? "Mijn account"} + + navigate("/settings")}>Instellingen + + void logout()}>Afmelden + + +
    +
    + ); +}; + +export default Header; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..ef6541a --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,155 @@ +"use client"; + +import React from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { ArrowRight, Brain, ChevronDown, ChevronLeft, ChevronRight, MoreHorizontal, SlidersHorizontal } from "lucide-react"; +import { useOptionalProject } from "@/shared/contexts/ProjectContext"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { useIsCompactSidebar, useIsMobile } from "@/hooks/use-mobile"; +import { getAdvancedNavigationGroups, getNavigationGroups, getNavigationItemForPath, getPrimaryMobileNavigation, getPrimaryNavigationGroups, getProjectIdFromPath, isNavigationPathActive, type NavigationItem } from "@/shared/lib/navigation"; +import { getProjectOperatingStep } from "@/shared/lib/projectOperatingStep"; +import { projectEntryPath } from "@/shared/lib/routeRegistry"; +import { readProjectJson, writeProjectJson } from "@/shared/lib/browserStorage"; + +const TECHNICAL_NAVIGATION_KEY = "projectbrain:navigation:technical-details"; + +const NavigationLink = ({ item, isCollapsed, onItemClick }: { item: NavigationItem; isCollapsed?: boolean; onItemClick?: () => void }) => ( + cn("group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors", isActive ? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm" : "text-sidebar-foreground/86 hover:bg-sidebar-accent/55 hover:text-sidebar-accent-foreground")} + > + +); + +const SidebarContent = ({ isCollapsed, onItemClick }: { isCollapsed?: boolean; onItemClick?: () => void }) => { + const projectContext = useOptionalProject(); + const location = useLocation(); + const routeProjectId = getProjectIdFromPath(location.pathname); + const activeProject = routeProjectId ? projectContext?.projects.find((project) => project.id === routeProjectId) : undefined; + const groups = getNavigationGroups(routeProjectId); + const primaryGroups = getPrimaryNavigationGroups(routeProjectId); + const technicalGroups = getAdvancedNavigationGroups(routeProjectId); + const staticProjectStep = activeProject ? getProjectOperatingStep(activeProject) : undefined; + const sidebarAction = activeProject && (staticProjectStep?.tone === "blocked" || staticProjectStep?.tone === "watch") + ? { label: staticProjectStep.label, route: staticProjectStep.route } + : activeProject + ? { label: "Actuele projectstatus bekijken", route: projectEntryPath(activeProject.id) } + : undefined; + const activeNavigationItem = getNavigationItemForPath(location.pathname, routeProjectId); + const activeGroupId = groups.find((group) => group.items.some((item) => item.path === activeNavigationItem?.path))?.id; + const activeTechnicalGroup = technicalGroups.find((group) => group.id === activeGroupId); + const [technicalVisible, setTechnicalVisible] = React.useState(() => readProjectJson(TECHNICAL_NAVIGATION_KEY, false)); + const [expandedGroups, setExpandedGroups] = React.useState>(() => new Set(["command-center", "project-management", "home", "application", activeGroupId].filter(Boolean) as string[])); + const visibleGroups = isCollapsed + ? [...primaryGroups, ...(activeTechnicalGroup && activeNavigationItem ? [{ ...activeTechnicalGroup, items: activeTechnicalGroup.items.filter((item) => item.path === activeNavigationItem.path) }] : [])] + : [...primaryGroups, ...(technicalVisible || activeTechnicalGroup ? technicalGroups : [])]; + + React.useEffect(() => { + if (!activeGroupId) return; + setExpandedGroups((current) => current.has(activeGroupId) ? current : new Set([...current, activeGroupId])); + }, [activeGroupId]); + + const toggleGroup = (groupId: string) => setExpandedGroups((current) => { + const next = new Set(current); + if (next.has(groupId)) next.delete(groupId); else next.add(groupId); + return next; + }); + + const toggleTechnical = () => { + const next = !technicalVisible; + setTechnicalVisible(next); + writeProjectJson(TECHNICAL_NAVIGATION_KEY, next); + if (next) setExpandedGroups((current) => new Set([...current, ...technicalGroups.map((group) => group.id)])); + }; + + return ( +
    +
    + {!isCollapsed ?
    ProjectBrain
    : } +
    + + + +
    + {!isCollapsed ?

    Actief project

    {activeProject?.name ?? "Geen project gekozen"}

    {activeProject ? : null}
    :
    } +
    +
    + ); +}; + +const MobileBottomNavigation = () => { + const location = useLocation(); + const routeProjectId = getProjectIdFromPath(location.pathname); + const [isOpen, setIsOpen] = React.useState(false); + const moreButtonRef = React.useRef(null); + const primaryItems = getPrimaryMobileNavigation(routeProjectId); + const visibleItems = primaryItems.slice(0, 4); + const hasPrimaryRouteMatch = primaryItems.some((item) => isNavigationPathActive(location.pathname, item.path)); + const isMoreActive = isOpen || !hasPrimaryRouteMatch || primaryItems.slice(4).some((item) => isNavigationPathActive(location.pathname, item.path)); + + return ( + <> + + { event.preventDefault(); moreButtonRef.current?.focus(); }}> + ProjectnavigatieKies een dagelijkse actie of open technische details. + setIsOpen(false)} /> + + + + + ); +}; + +const Sidebar = () => { + const [isCollapsed, setIsCollapsed] = React.useState(false); + const isMobile = useIsMobile(); + const isCompactSidebar = useIsCompactSidebar(); + if (isMobile) return ; + const collapsed = isCompactSidebar || isCollapsed; + return ( + + ); +}; + +export default Sidebar; diff --git a/src/components/layout/commandMenuSearch.test.ts b/src/components/layout/commandMenuSearch.test.ts new file mode 100644 index 0000000..54ffe8f --- /dev/null +++ b/src/components/layout/commandMenuSearch.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { SearchResult } from "@/shared/lib/projectSearch"; +import { getCommandResultKey, rankCommandResults, rankNavigationItems } from "./commandMenuSearch"; + +const result = (id: string, label: string, description = ""): SearchResult => ({ + id, + label, + description, + projectId: "widefrog", + route: `/projects/widefrog/history?entry=${id}`, + type: "documentation", +}); + +const navigationItem = (label: string, path: string, description = "", searchTerms = "", shortLabel = "") => ({ + label, + path, + description, + searchTerms, + shortLabel, + workflow: "System", +}); + +describe("command menu result ranking", () => { + it("filters the complete collection before applying the result limit", () => { + const results = Array.from({ length: 20 }, (_, index) => result(`noise-${index}`, `Ander resultaat ${index}`)); + results.push(result("target", "Release bewijs")); + + expect(rankCommandResults(results, "release bewijs", 12).map((item) => item.id)).toEqual(["target"]); + }); + + it("ranks an exact label before matches in descriptions", () => { + const results = [ + result("description", "Historiek", "Release bewijs terugvinden"), + result("exact", "Release bewijs"), + ]; + + expect(rankCommandResults(results, "release bewijs", 12).map((item) => item.id)) + .toEqual(["exact", "description"]); + }); + + it("removes duplicates and honors keys already shown in another group", () => { + const duplicate = result("duplicate", "Release bewijs"); + const other = result("other", "Release bewijs"); + other.route = duplicate.route; + const excluded = new Set([getCommandResultKey(duplicate)]); + + expect(rankCommandResults([duplicate, duplicate, other], "release", 12, excluded)).toEqual([]); + }); + + it("ranks navigation by label relevance instead of alphabetical order", () => { + const items = [ + navigationItem("Architectuur", "/architecture", "Technische repositorystructuur", "repo"), + navigationItem("Repository", "/develop", "Bestanden, branches en pull requests", "repo"), + navigationItem("Repositoryanalyses", "/analysis", "Analysejobs en resultaten", "repo scan"), + ]; + + expect(rankNavigationItems(items, "repository", 8).map((item) => item.label)) + .toEqual(["Repository", "Repositoryanalyses", "Architectuur"]); + }); + + it("matches navigation aliases and diacritics consistently", () => { + const items = [ + navigationItem("Extra bronnen", "/sources", "Aanvullend materiaal", "Source Library bronmateriaal"), + navigationItem("Uitvoeringsprofielen", "/skills", "Technische profielen", "skills Codex", "Profielen"), + ]; + + expect(rankNavigationItems(items, "bronmateriaal", 8).map((item) => item.path)).toEqual(["/sources"]); + expect(rankNavigationItems(items, "profielen", 8).map((item) => item.path)).toEqual(["/skills"]); + }); +}); diff --git a/src/components/layout/commandMenuSearch.ts b/src/components/layout/commandMenuSearch.ts new file mode 100644 index 0000000..a16c83f --- /dev/null +++ b/src/components/layout/commandMenuSearch.ts @@ -0,0 +1,116 @@ +import type { SearchResult } from "@/shared/lib/projectSearch"; + +type CommandSearchResult = Pick; +type NavigationSearchItem = { + label: string; + description: string; + path: string; + shortLabel?: string; + searchTerms?: string; + workflow?: string; +}; + +const normalizeSearchText = (value: string) => value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLocaleLowerCase("nl-BE") + .trim(); + +export const getCommandResultKey = (result: CommandSearchResult) => ( + `${result.route}|${normalizeSearchText(result.label)}` +); + +const getResultScore = (result: CommandSearchResult, normalizedQuery: string, terms: string[]) => { + const label = normalizeSearchText(result.label); + const type = normalizeSearchText(result.type); + const projectId = normalizeSearchText(result.projectId); + const description = normalizeSearchText(result.description); + const route = normalizeSearchText(result.route); + const searchableText = `${label} ${type} ${projectId} ${description} ${route}`; + + if (!terms.every((term) => searchableText.includes(term))) return undefined; + if (label === normalizedQuery) return 0; + if (label.startsWith(normalizedQuery)) return 10; + if (label.includes(normalizedQuery)) return 20; + if (`${type} ${projectId}`.includes(normalizedQuery)) return 30; + if (description.includes(normalizedQuery)) return 40; + return 50; +}; + +const getNavigationScore = (item: NavigationSearchItem, normalizedQuery: string, terms: string[]) => { + const label = normalizeSearchText(item.label); + const shortLabel = normalizeSearchText(item.shortLabel ?? ""); + const description = normalizeSearchText(item.description); + const searchTerms = normalizeSearchText(item.searchTerms ?? ""); + const workflow = normalizeSearchText(item.workflow ?? ""); + const path = normalizeSearchText(item.path); + const searchableText = `${label} ${shortLabel} ${description} ${searchTerms} ${workflow} ${path}`; + + if (!terms.every((term) => searchableText.includes(term))) return undefined; + if (label === normalizedQuery || shortLabel === normalizedQuery) return 0; + if (label.startsWith(normalizedQuery)) return 10; + if (shortLabel.startsWith(normalizedQuery)) return 12; + if (label.includes(normalizedQuery)) return 20; + if (shortLabel.includes(normalizedQuery)) return 22; + if (searchTerms.includes(normalizedQuery)) return 30; + if (description.includes(normalizedQuery)) return 40; + if (path.includes(normalizedQuery)) return 45; + if (workflow.includes(normalizedQuery)) return 50; + return 60; +}; + +export const rankCommandResults = ( + results: T[], + query: string, + limit: number, + excludedKeys: ReadonlySet = new Set(), +): T[] => { + const normalizedQuery = normalizeSearchText(query); + const terms = normalizedQuery.split(/\s+/).filter(Boolean); + const seen = new Set(excludedKeys); + + return results + .map((result, index) => ({ + index, + result, + score: normalizedQuery ? getResultScore(result, normalizedQuery, terms) : 0, + })) + .filter((candidate): candidate is typeof candidate & { score: number } => candidate.score !== undefined) + .sort((left, right) => ( + left.score - right.score + || left.result.label.localeCompare(right.result.label, "nl-BE") + || left.index - right.index + )) + .flatMap(({ result }) => { + const key = getCommandResultKey(result); + if (seen.has(key)) return []; + seen.add(key); + return [result]; + }) + .slice(0, limit); +}; + +export const rankNavigationItems = (items: T[], query: string, limit: number): T[] => { + const normalizedQuery = normalizeSearchText(query); + const terms = normalizedQuery.split(/\s+/).filter(Boolean); + const seenPaths = new Set(); + + return items + .map((item, index) => ({ + index, + item, + score: normalizedQuery ? getNavigationScore(item, normalizedQuery, terms) : 0, + })) + .filter((candidate): candidate is typeof candidate & { score: number } => candidate.score !== undefined) + .sort((left, right) => ( + left.score - right.score + || left.item.label.localeCompare(right.item.label, "nl-BE") + || left.index - right.index + )) + .flatMap(({ item }) => { + if (seenPaths.has(item.path)) return []; + seenPaths.add(item.path); + return [item]; + }) + .slice(0, limit); +}; diff --git a/src/components/made-with-dyad.tsx b/src/components/made-with-dyad.tsx new file mode 100644 index 0000000..4e4311f --- /dev/null +++ b/src/components/made-with-dyad.tsx @@ -0,0 +1,9 @@ +"use client"; + +import React from "react"; + +export const MadeWithDyad = () => ( +
    +); \ No newline at end of file diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..3bc117a --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/src/components/ui/ConfidenceBadge.tsx b/src/components/ui/ConfidenceBadge.tsx new file mode 100644 index 0000000..26b6d4e --- /dev/null +++ b/src/components/ui/ConfidenceBadge.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Brain } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +interface ConfidenceBadgeProps { + confidence: number; + className?: string; +} + +export const ConfidenceBadge = ({ confidence, className }: ConfidenceBadgeProps) => { + const getColor = (score: number) => { + if (score >= 90) return "text-green-600 bg-green-50 border-green-200"; + if (score >= 70) return "text-blue-600 bg-blue-50 border-blue-200"; + return "text-amber-600 bg-amber-50 border-amber-200"; + }; + + return ( + + + {confidence}% + + ); +}; \ No newline at end of file diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..c686add --- /dev/null +++ b/src/components/ui/EmptyState.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React from "react"; +import type { LucideIcon } from "lucide-react"; +import { Inbox } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/shared/lib/utils"; + +type EmptyStateAction = { + label: string; + onClick: () => void; + variant?: "default" | "outline" | "secondary" | "ghost"; +}; + +export const EmptyState = ({ + icon: Icon = Inbox, + title, + description, + action, + secondaryAction, + className, +}: { + icon?: LucideIcon; + title: string; + description: string; + action?: EmptyStateAction; + secondaryAction?: EmptyStateAction; + className?: string; +}) => ( + + +
    + +
    +

    {title}

    +

    {description}

    + {(action || secondaryAction) && ( +
    + {action && ( + + )} + {secondaryAction && ( + + )} +
    + )} +
    +
    +); diff --git a/src/components/ui/EvidenceList.tsx b/src/components/ui/EvidenceList.tsx new file mode 100644 index 0000000..4094014 --- /dev/null +++ b/src/components/ui/EvidenceList.tsx @@ -0,0 +1,38 @@ +"use client"; + +import React from "react"; +import { FileCode, Layout, Hash, Settings } from "lucide-react"; +import { CodeEvidence } from "@/shared/lib/model"; + +interface EvidenceListProps { + items: CodeEvidence[]; +} + +export const EvidenceList = ({ items }: EvidenceListProps) => { + const getIcon = (type: string) => { + switch (type) { + case "Logic": return ; + case "UI": return ; + case "Hook": return ; + case "Config": return ; + default: return ; + } + }; + + return ( +
    +

    Code Evidence

    +
    + {items.map((item, idx) => ( +
    +
    + {getIcon(item.type)} + {item.file} +
    + L{item.lines} +
    + ))} +
    +
    + ); +}; \ No newline at end of file diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..22ae341 --- /dev/null +++ b/src/components/ui/PageHeader.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React from "react"; + +interface PageHeaderProps { + title: string; + subtitle?: string; + actions?: React.ReactNode; +} + +export const PageHeader = ({ title, subtitle, actions }: PageHeaderProps) => { + return ( +
    +
    +

    {title}

    + {subtitle &&

    {subtitle}

    } +
    + {actions &&
    {actions}
    } +
    + ); +}; diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..71ef59d --- /dev/null +++ b/src/components/ui/StatusBadge.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/shared/lib/utils"; + +interface StatusBadgeProps { + status: "Todo" | "In Progress" | "Done"; + className?: string; +} + +export const StatusBadge = ({ status, className }: StatusBadgeProps) => { + const variants = { + Todo: "bg-slate-100 text-slate-700 border-slate-200 hover:bg-slate-100", + "In Progress": "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-50", + Done: "bg-green-50 text-green-700 border-green-200 hover:bg-green-50", + }; + + return ( + + {status} + + ); +}; \ No newline at end of file diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..83ff017 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
    {children}
    +
    +)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..4b29685 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
    +); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..13219e7 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
    +)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c9e6f4b --- /dev/null +++ b/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..444b1db --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..7659691 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends + React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
    + ); +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..6934f83 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>

kEMGuY$0$d^xAw`H!-kKF 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 u)u+Z}T!{R|2gC#q2eqq93I4W- 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 literal 0 HcmV?d00001 diff --git a/e2e/attention-auto-mode.spec.ts b/e2e/attention-auto-mode.spec.ts new file mode 100644 index 0000000..ae40d06 --- /dev/null +++ b/e2e/attention-auto-mode.spec.ts @@ -0,0 +1,112 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; +import type { AutoModeState } from "../src/shared/api/autonomyApi"; + +const projectId = "widefrog"; +const envelope = (data: unknown) => ({ data, meta: { requestId: "attention-auto-e2e", timestamp: new Date().toISOString() } }); + +const autoModeState = (): AutoModeState => ({ + version: 1, + enabled: true, + status: "running", + autoBrainstorm: true, + maxBrainstormIterations: 5, + maxConsecutiveFailures: 3, + leaseMinutes: 30, + maxWorkItemsPerRun: 8, + maxRunMinutes: 45, + roadmap: { + id: "roadmap-e2e", + title: "E2E-roadmap", + sourceMarkdown: "# E2E-roadmap\n\n## Eerste fase\n- [ ] Exact werkitem", + status: "active", + currentPhaseIndex: 0, + importedAt: "2026-09-01T00:00:00.000Z", + phases: [{ + id: "phase-e2e", + title: "Eerste fase", + objective: "Eerste fase", + status: "active", + brainstormIterations: 0, + brainstormConverged: false, + validationFailures: 0, + items: [{ id: "item-e2e", title: "Exact werkitem", status: "pending", attempts: 0 }], + }], + }, + completedLeases: [], + events: [], + updatedAt: "2026-09-01T00:00:00.000Z", + updatedBy: "owner", +}); + +test("attention inbox exposes every visible item as an actionable destination", async ({ page }) => { + await page.goto(`/projects/${projectId}/attention`); + await expect(page.getByRole("heading", { name: "Jouw aandacht", exact: true })).toBeVisible(); + await expect(page.getByText(/item\(s\) vragen jouw aandacht|Niets wacht op jou/)).toBeVisible(); + + const reviewAction = page.getByRole("button", { name: /Beoordelen/ }).first(); + if (await reviewAction.isVisible()) { + await reviewAction.click(); + await expect(page).toHaveURL(/\/review-queue\?reviewItemId=/); + } + + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? ""))).toEqual([]); +}); + +test("Auto Mode distinguishes queued work from real execution and protects roadmap progress", async ({ page }) => { + let state = autoModeState(); + let roadmapImports = 0; + await page.route(`**/api/projects/${projectId}/autonomy**`, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === "GET") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(state)) }); + return; + } + if (request.method() === "POST" && url.pathname.endsWith("/roadmap")) { + roadmapImports += 1; + await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify(envelope(state)) }); + return; + } + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(state)) }); + }); + + await page.goto(`/projects/${projectId}/auto`); + await expect(page.getByRole("heading", { name: "Auto Mode", exact: true })).toBeVisible(); + await expect(page.getByText("Klaar voor uitvoering", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Werk staat veilig in de wachtrij")).toBeVisible(); + await expect(page.getByText("ProjectBrain werkt", { exact: true })).toHaveCount(0); + + await page.getByText("Autonomiebeleid en geavanceerde instellingen", { exact: true }).click(); + await page.getByText("Geavanceerde grenzen", { exact: true }).click(); + await expect(page.getByLabel("Werkitems per run")).toHaveValue("8"); + await expect(page.getByLabel("Looptijd per run")).toHaveValue("45"); + + await expect(page.getByText("Roadmap handmatig importeren", { exact: true })).toBeVisible(); + await page.getByLabel("Roadmap").fill("# Nieuwe roadmap\n\n## Nieuwe fase\n- [ ] Nieuw werk"); + await page.getByRole("button", { name: "Roadmap bijwerken" }).click(); + await expect(page.getByText("Bestaande roadmap en voortgang vervangen?")).toBeVisible(); + expect(roadmapImports).toBe(0); + await page.getByRole("button", { name: "Annuleren" }).click(); + + state = { + ...state, + activeLease: { + token: "00000000-0000-4000-8000-000000000001", + kind: "implement", + actor: "chatgpt-agent", + roadmapId: "roadmap-e2e", + phaseId: "phase-e2e", + itemId: "item-e2e", + createdAt: new Date(Date.now() - 60_000).toISOString(), + expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(), + }, + }; + await page.getByRole("button", { name: "Vernieuwen" }).click(); + await expect(page.getByText("ProjectBrain werkt", { exact: true })).toBeVisible(); + await expect(page.getByText("Exact werkitem", { exact: true }).first()).toBeVisible(); + + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? ""))).toEqual([]); +}); diff --git a/e2e/core-journeys.spec.ts b/e2e/core-journeys.spec.ts new file mode 100644 index 0000000..16a137b --- /dev/null +++ b/e2e/core-journeys.spec.ts @@ -0,0 +1,267 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +const projectId = "widefrog"; +const workspaceHeading = /Wat wil je dat ProjectBrain bereikt\?/i; +const activityHeading = /Activiteit/i; + +const assertHealthyPage = async (page: Page, heading: RegExp) => { + const consoleErrors: string[] = []; + const failedRequests: string[] = []; + page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); + page.on("requestfailed", (request) => failedRequests.push(`${request.method()} ${request.url()}`)); + await expect(page.getByRole("heading", { name: heading }).first()).toBeVisible(); + await expect(page.locator("body")).not.toContainText("Something went wrong"); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + const accessibility = await new AxeBuilder({ page }).analyze(); + expect(accessibility.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? ""))).toEqual([]); + expect(consoleErrors).toEqual([]); + expect(failedRequests).toEqual([]); +}; + +test("1. opens the project portfolio and reopens persistent project memory", async ({ page }) => { + test.setTimeout(75_000); + await page.goto("/projects"); + await assertHealthyPage(page, /Projectcatalogus/i); + await page.goto(`/projects/${projectId}/workspace`); + await assertHealthyPage(page, workspaceHeading); + await expect(page.getByRole("button", { name: /WideFrog/i }).first()).toBeVisible(); + await page.reload(); + await assertHealthyPage(page, workspaceHeading); + await expect(page.getByRole("button", { name: /WideFrog/i }).first()).toBeVisible(); +}); + +test("2. capture and review surfaces form one navigable intake journey", async ({ page }) => { + await page.goto(`/projects/${projectId}/capture`); + await assertHealthyPage(page, /Capture Center/i); + await page.goto(`/projects/${projectId}/review-queue`); + await assertHealthyPage(page, /Te beoordelen/i); +}); + +test("3. review and accepted-truth history surfaces remain recoverable", async ({ page }) => { + await page.goto(`/projects/${projectId}/review-queue`); + await assertHealthyPage(page, /Te beoordelen/i); + expect(await page.locator('[aria-live="polite"], [role="status"], [role="alert"]').count()).toBeGreaterThan(0); + await page.goto(`/projects/${projectId}/history`); + await assertHealthyPage(page, activityHeading); +}); + +test("9. critical route remains operable on a constrained connection", async ({ page }) => { + await page.route("**/*.js", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 75)); + await route.continue(); + }); + await page.goto(`/projects/${projectId}/review-queue`); + await assertHealthyPage(page, /Te beoordelen/i); + await page.getByText("Proces, bewijs en beheer", { exact: true }).click(); + await expect(page.getByRole("button", { name: "Zichtbare reviews exporteren" })).toBeEnabled(); +}); + +test("10. action surfaces expose one primary action while system status stays diagnostic", async ({ page }) => { + for (const route of ["workspace", "review-queue", "execution"]) { + await page.goto(`/projects/${projectId}/${route}`); + await expect(page.locator('[data-page-primary="true"]:visible')).toHaveCount(1); + } + + await page.goto(`/projects/${projectId}/runtime`); + await expect(page.getByRole("heading", { name: "Systeemstatus", exact: true })).toBeVisible(); + await expect(page.getByText("ProjectBrain API", { exact: true })).toBeVisible(); + await expect(page.getByText("Database", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Runners", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Bijwerken", exact: true })).toBeVisible(); +}); + +test("4. context, handoff and session-pack chain renders end to end", async ({ page }) => { + for (const [route, heading] of [["context-packs", /Context Packs/i], ["handoffs", /Codex Handoffs/i], ["session-packs", /Session Packs/i]] as const) { + await page.goto(`/projects/${projectId}/${route}`); + await assertHealthyPage(page, heading); + } +}); + +test("5. execution feedback links back to durable project history", async ({ page }) => { + await page.goto(`/projects/${projectId}/execution-feedback`); + await assertHealthyPage(page, /Execution Feedback/i); + await page.goto(`/projects/${projectId}/history`); + await assertHealthyPage(page, activityHeading); +}); + +test("6. release evidence and release center surfaces render", async ({ page }) => { + await page.goto(`/projects/${projectId}/runtime`); + await assertHealthyPage(page, /Systeemstatus/i); + await page.goto(`/projects/${projectId}/release`); + await assertHealthyPage(page, /Releasecentrum/i); +}); + +test("7. mock-mode development identity survives reload", async ({ page }) => { + await page.goto(`/projects/${projectId}/workspace`); + await assertHealthyPage(page, workspaceHeading); + await expect(page.getByRole("button", { name: /WideFrog/i }).first()).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: workspaceHeading })).toHaveCount(1); + await expect(page.getByRole("button", { name: /WideFrog/i }).first()).toBeVisible(); + await expect(page.getByRole("heading", { name: /Aanmelden/i })).toHaveCount(0); +}); + +test("8. keyboard, route focus, reduced motion and 200% zoom stay usable", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(`/projects/${projectId}/workspace`); + await page.keyboard.press("Tab"); + const skipLink = page.getByRole("link", { name: "Ga naar hoofdinhoud" }); + await expect(skipLink).toBeFocused(); + await skipLink.press("Enter"); + await expect(page.locator("#projectbrain-main")).toBeFocused(); + + await page.evaluate(() => { document.documentElement.style.zoom = "2"; }); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + await expect(page.getByRole("heading", { name: workspaceHeading })).toHaveCount(1); + + await page.evaluate(() => { document.documentElement.style.zoom = "1"; }); + const moreButton = page.getByRole("button", { name: "Meer" }); + if (await moreButton.isVisible()) { + await moreButton.click(); + const navigationDialog = page.getByRole("dialog", { name: "Projectnavigatie" }); + await expect(navigationDialog).toBeVisible(); + await expect(navigationDialog).toContainText("ProjectBrain"); + await page.keyboard.press("Escape"); + await expect(navigationDialog).toBeHidden(); + await expect(moreButton).toBeFocused(); + } + + await page.goto(`/projects/${projectId}/capture`); + await expect(page.locator("#projectbrain-main")).toBeFocused(); +}); + +test("11. development workbench creates an isolated branch, commits a file and opens a pull request", async ({ page }) => { + await page.goto(`/projects/${projectId}/develop`); + await expect(page.getByRole("heading", { name: /WideFrog · repository/i })).toBeVisible(); + const localRepositoryBridge = page.getByRole("button", { name: "Repository voorbereiden" }); + if (await localRepositoryBridge.isVisible()) { + await expect(page.getByText("Werk lokaal verder in Repositorybouwer")).toBeVisible(); + await expect(localRepositoryBridge).toBeEnabled(); + return; + } + + let branchCreated = false; + let fileSaved = false; + let validationSynced = false; + let pullRequestCreated = false; + const envelope = (data: unknown) => ({ data, meta: { requestId: "e2e-workbench", timestamp: new Date().toISOString() } }); + const link = { id: "link-1", projectId, projectSlug: projectId, projectName: "WideFrog", owner: "Jens", repository: "widefrog", fullName: "Jens/widefrog", defaultBranch: "main", accessMode: "read_write", role: "primary", isPrimary: true }; + const delivery = () => ({ + id: "delivery-1", projectId, title: "Continue WideFrog", intent: "Deliver a reviewed repository change", priority: "important", approvalState: "approved", + spec: { + version: 1, problem: "The repository needs an updated readme", desiredOutcome: "The readme is current", users: ["maintainer"], goals: ["Update README"], nonGoals: [], + acceptanceCriteria: ["README is updated"], implementationPlan: ["Edit README.md"], risks: [], validationCommands: ["pnpm test"], + status: pullRequestCreated ? "pull_request_open" : validationSynced ? "validating" : branchCreated ? "executing" : "approved", + branch: branchCreated ? "chatgpt/widefrog-work" : undefined, repositoryId: "link-1", changedFiles: fileSaved ? ["README.md"] : [], progressNotes: [], + validationResults: validationSynced ? [{ command: "pnpm test", status: "passed" }] : [], + validationEvidence: validationSynced ? { provider: "gitea_actions", runId: 42, runNumber: 42, url: "https://gitea.example/Jens/widefrog/actions/runs/42", workflowPath: ".gitea/workflows/ci.yml", headSha: "work-sha", conclusion: "success", verifiedAt: new Date().toISOString() } : undefined, + pullRequest: pullRequestCreated ? { number: 7, url: "https://gitea.example/Jens/widefrog/pulls/7", title: "ProjectBrain: continue WideFrog", state: "open" } : undefined, + updatedBy: "e2e", updatedAt: new Date().toISOString(), + }, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + + await page.route(`**/api/projects/${projectId}/feature-deliveries`, async (route) => { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ items: [delivery()] })) }); + }); + + await page.route(`**/api/projects/${projectId}/feature-deliveries/delivery-1/validation/sync`, async (route) => { + validationSynced = true; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ delivery: delivery() })) }); + }); + + await page.route(`**/api/projects/${projectId}/gitea/**`, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.pathname.endsWith("/repositories")) { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope([link])) }); return; + } + if (url.pathname.endsWith("/workspace")) { + const ref = url.searchParams.get("ref") || "main"; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ + link, path: url.searchParams.get("path") || "", ref, + entries: [{ type: "file", name: "README.md", path: "README.md", sha: "sha-1", size: 24, html_url: "https://gitea.example/Jens/widefrog/src/branch/main/README.md" }], + branches: [{ name: "main", commit: { id: "main-sha" }, protected: true }, ...(branchCreated ? [{ name: "chatgpt/widefrog-work", commit: { id: "work-sha" }, protected: false }] : [])], + pullRequests: pullRequestCreated ? [{ id: 1, number: 7, title: "ProjectBrain: continue WideFrog", state: "open", html_url: "https://gitea.example/Jens/widefrog/pulls/7", merged: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), head: { ref: "chatgpt/widefrog-work", sha: "work-sha" }, base: { ref: "main", sha: "main-sha" } }] : [], + })) }); return; + } + if (url.pathname.endsWith("/file") && request.method() === "GET") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ path: "README.md", ref: url.searchParams.get("ref") || "main", sha: "sha-1", size: 24, content: fileSaved ? "# WideFrog\n\nUpdated." : "# WideFrog\n" })) }); return; + } + if (url.pathname.endsWith("/branches") && request.method() === "POST") { + branchCreated = true; await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify(envelope({ repository: "Jens/widefrog", branch: { name: "chatgpt/widefrog-work", commit: { id: "work-sha" }, protected: false } })) }); return; + } + if (url.pathname.endsWith("/file") && request.method() === "PUT") { + expect(request.postDataJSON()).toMatchObject({ expectedSha: "sha-1" }); + fileSaved = true; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ repository: "Jens/widefrog", branch: "chatgpt/widefrog-work", path: "README.md", action: "updated" })) }); return; + } + if (url.pathname.endsWith("/pull-requests") && request.method() === "POST") { + pullRequestCreated = true; await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify(envelope({ repository: "Jens/widefrog", pullRequest: { number: 7 } })) }); return; + } + await route.abort(); + }); + + await page.goto(`/projects/${projectId}/develop`); + await expect(page.getByRole("heading", { name: /WideFrog · repository/i })).toBeVisible(); + await page.getByRole("textbox", { name: "Nieuwe deliverybranch" }).fill("chatgpt/widefrog-work"); + await page.getByRole("button", { name: /Werkbranch maken/i }).click(); + await expect(page.getByText("chatgpt/widefrog-work").first()).toBeVisible(); + await page.getByRole("button", { name: /README.md/i }).click(); + await page.getByRole("textbox", { name: "Repository file content" }).fill("# WideFrog\n\nUpdated."); + await page.getByRole("button", { name: /Bestand committen/i }).click(); + expect(fileSaved).toBe(true); + await page.getByRole("button", { name: /Actions verifiëren/i }).click(); + expect(validationSynced).toBe(true); + await page.getByRole("button", { name: /Pull request maken/i }).click(); + expect(pullRequestCreated).toBe(true); +}); + +test("12. daily navigation hides specialist modules while keeping them searchable", async ({ page }) => { + await page.goto(`/projects/${projectId}/workspace`); + const moreButton = page.getByRole("button", { name: "Meer" }); + if (await moreButton.isVisible()) await moreButton.click(); + + await expect(page.getByRole("link", { name: /Opdracht geven/i }).first()).toBeVisible(); + await expect(page.getByRole("link", { name: /Repositoryanalyses/i })).toHaveCount(0); + + const advancedModulesButton = page.getByRole("button", { name: /Technische details tonen/i }); + if (await advancedModulesButton.isVisible()) { + await advancedModulesButton.click(); + await expect(page.getByRole("link", { name: /Repositoryanalyses/i })).toBeVisible(); + await page.getByRole("button", { name: /Technische details verbergen/i }).click(); + await expect(page.getByRole("link", { name: /Repositoryanalyses/i })).toHaveCount(0); + return; + } + + await page.getByRole("button", { name: "Open zoek- en opdrachtenmenu" }).click(); + await page.getByRole("combobox", { name: "Zoek pagina's, projectitems en opdrachten" }).fill("Repositoryanalyses"); + await page.getByText("Repositoryanalyses", { exact: true }).first().click(); + await expect(page).toHaveURL(new RegExp(`/projects/${projectId}/analysis-jobs$`)); + await expect(page.getByRole("link", { name: "Repositoryanalyses", exact: true })).toBeVisible(); +}); + +test("13. mobile shell keeps five-intent route context and search available", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/projects/nebula/workspace"); + await page.evaluate(() => { + window.history.pushState({}, "", "/projects/widefrog/findings"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + await expect(page).toHaveURL(/\/projects\/widefrog\/findings$/); + + await expect(page.getByRole("link", { name: "Overzicht", exact: true })).toHaveAttribute("href", "/projects/widefrog/overview"); + await expect(page.getByRole("link", { name: "Opdracht", exact: true })).toHaveAttribute("href", "/projects/widefrog/workspace"); + await expect(page.getByRole("link", { name: "Aandacht", exact: true })).toHaveAttribute("href", "/projects/widefrog/attention"); + await expect(page.getByRole("link", { name: "Resultaten", exact: true })).toHaveAttribute("href", "/projects/widefrog/results"); + await expect(page.getByRole("link", { name: "Auto", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Meer" })).toHaveAttribute("aria-current", "page"); + + const searchButton = page.getByRole("button", { name: "Open zoek- en opdrachtenmenu" }); + await expect(searchButton).toBeVisible(); + await searchButton.click(); + await expect(page.getByRole("dialog", { name: "Opdrachtenpalet" })).toBeVisible(); + await expect(page.getByRole("combobox", { name: "Zoek pagina's, projectitems en opdrachten" })).toBeFocused(); +}); diff --git a/e2e/platform-state.spec.ts b/e2e/platform-state.spec.ts new file mode 100644 index 0000000..b4facd9 --- /dev/null +++ b/e2e/platform-state.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { AutoModeState } from "../src/shared/api/autonomyApi"; + +const stateFor = (project: string): AutoModeState => ({ + version: 1, enabled: false, status: "off", autoBrainstorm: true, maxBrainstormIterations: 5, + maxConsecutiveFailures: 3, leaseMinutes: 30, maxWorkItemsPerRun: 8, maxRunMinutes: 45, + runs: [], events: [], updatedAt: new Date().toISOString(), updatedBy: "test", + roadmap: { id: project, title: `${project} roadmap`, sourceMarkdown: `## ${project}\n- [ ] ${project} task`, status: "active", currentPhaseIndex: 0, importedAt: new Date().toISOString(), phases: [ + { id: `${project}-phase`, title: project, objective: "Finish work", status: "active", brainstormIterations: 0, brainstormConverged: false, validationFailures: 0, + items: [{ id: `${project}-task`, title: `${project} task`, status: "pending", attempts: 1 }] }, + ] }, +}); +const envelope = (data: unknown) => ({ data, meta: { requestId: "platform-state", timestamp: new Date().toISOString() } }); +const navigateWithoutReload = async (page: Page, path: string) => page.evaluate((next) => { + window.history.pushState({}, "", next); + window.dispatchEvent(new PopStateEvent("popstate")); +}, path); + +const openAutoAdvancedSettings = async (page: Page) => { + const advanced = page.getByText("Autonomiebeleid en geavanceerde instellingen", { exact: true }); + if (await advanced.locator("xpath=..").getAttribute("open") === null) await advanced.click(); +}; + +const openManualRoadmapImport = async (page: Page) => { + await openAutoAdvancedSettings(page); + await expect(page.getByText("Roadmap handmatig importeren", { exact: true })).toBeVisible(); +}; + +const openAutoAdvancedLimits = async (page: Page) => { + await openAutoAdvancedSettings(page); + const advancedLimits = page.getByText("Geavanceerde grenzen", { exact: true }); + if (await advancedLimits.locator("xpath=..").getAttribute("open") === null) await advancedLimits.click(); +}; + +test("project switches never carry an instruction into another project", async ({ page }) => { + await page.goto("/projects/widefrog/workspace"); + await page.getByLabel("Jouw opdracht", { exact: true }).fill("Only change WideFrog onboarding"); + await navigateWithoutReload(page, "/projects/nebula/workspace"); + await expect(page.getByLabel("Jouw opdracht", { exact: true })).toHaveValue(""); + await expect(page.getByRole("button", { name: "Aan ProjectBrain geven" })).toBeDisabled(); +}); + +test("project switches discard old roadmap drafts and delayed responses", async ({ page }) => { + let delayWidefrog = false; + let releaseResponse!: () => void; + const responseGate = new Promise((resolve) => { releaseResponse = resolve; }); + let requestStarted!: () => void; + const started = new Promise((resolve) => { requestStarted = resolve; }); + await page.route("**/api/projects/*/autonomy", async (route) => { + const project = new URL(route.request().url()).pathname.split("/")[3]; + if (project === "widefrog" && delayWidefrog) { requestStarted(); await responseGate; } + await route.fulfill({ json: envelope(stateFor(project)) }); + }); + + await page.goto("/projects/widefrog/auto"); + await openManualRoadmapImport(page); + await expect(page.getByLabel("Roadmap", { exact: true })).toContainText("widefrog"); + await page.getByLabel("Roadmap", { exact: true }).fill("## Private draft\n- Only WideFrog"); + + delayWidefrog = true; + await page.getByRole("button", { name: "Vernieuwen" }).click(); + await started; + + const nebulaResponse = page.waitForResponse((response) => response.url().endsWith("/projects/nebula/autonomy")); + await navigateWithoutReload(page, "/projects/nebula/auto"); + await nebulaResponse; + await openManualRoadmapImport(page); + await expect(page.getByLabel("Titel", { exact: true })).toHaveValue("nebula roadmap"); + await expect(page.getByLabel("Roadmap", { exact: true })).toHaveValue("## nebula\n- [ ] nebula task"); + + const oldResponse = page.waitForResponse((response) => response.url().endsWith("/projects/widefrog/autonomy")); + releaseResponse(); + await oldResponse; + + await expect(page.getByLabel("Titel", { exact: true })).toHaveValue("nebula roadmap"); + await expect(page.getByLabel("Roadmap", { exact: true })).toHaveValue("## nebula\n- [ ] nebula task"); + await expect(page.getByText("widefrog task", { exact: true })).toHaveCount(0); +}); + +test("reimporting an unchanged roadmap preserves work and toggling Auto preserves unsaved policy", async ({ page }) => { + let state = stateFor("widefrog"); + let imports = 0; + await page.route("**/api/projects/widefrog/autonomy**", async (route) => { + const request = route.request(); + if (request.method() === "POST" && request.url().endsWith("/roadmap")) imports += 1; + if (request.method() === "PATCH" && request.url().endsWith("/autonomy")) { + state = { ...state, ...request.postDataJSON() }; + } + await route.fulfill({ json: envelope(state) }); + }); + await page.goto("/projects/widefrog/auto"); + await openManualRoadmapImport(page); + await page.getByRole("button", { name: "Roadmap bijwerken" }).click(); + await expect(page.getByText("Deze roadmap is al opgeslagen. De voortgang blijft behouden.")).toBeVisible(); + expect(imports).toBe(0); + await openAutoAdvancedLimits(page); + await page.getByLabel("Werkitems per run").fill("12"); + await page.getByRole("switch", { name: "Auto", exact: true }).click(); + await expect(page.getByRole("switch", { name: "Auto", exact: true })).toBeChecked(); + await expect(page.getByLabel("Werkitems per run")).toHaveValue("12"); + await expect(page.getByRole("button", { name: "Beleid opslaan" })).toBeEnabled(); +}); diff --git a/e2e/project-list-view.spec.ts b/e2e/project-list-view.spec.ts new file mode 100644 index 0000000..0bf10c2 --- /dev/null +++ b/e2e/project-list-view.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from "@playwright/test"; + +test("project search and sort survive reload and clearing preserves sorting", async ({ page }) => { + await page.goto("/projects?q=widefrog&sort=name"); + await expect(page.getByRole("textbox", { name: "Projecten zoeken", exact: true })).toHaveValue("widefrog"); + await expect(page.getByRole("button", { name: "Naam", exact: true })).toHaveAttribute("aria-pressed", "true"); + await page.getByRole("textbox", { name: "Projecten zoeken", exact: true }).fill("no-project-matches-this"); + await page.reload(); + await expect(page.getByRole("textbox", { name: "Projecten zoeken", exact: true })).toHaveValue("no-project-matches-this"); + await page.getByRole("button", { name: "Selectie wissen", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Projecten zoeken", exact: true })).toHaveValue(""); + await expect(page.getByRole("button", { name: "Naam", exact: true })).toHaveAttribute("aria-pressed", "true"); + await expect(page).toHaveURL(/sort=name/); +}); diff --git a/e2e/task-queue.spec.ts b/e2e/task-queue.spec.ts new file mode 100644 index 0000000..950aa92 --- /dev/null +++ b/e2e/task-queue.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test("create, filter, complete and reload a local task", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/projects/widefrog/tasks"); + await expect(page.getByRole("heading", { name: "Tasks", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "New Task", exact: true }).click(); + await page.getByLabel("Task Title").fill("Task queue browser regression"); + await page.getByLabel("Linked Feature").click(); + await page.getByRole("option").first().click(); + await page.getByRole("button", { name: "Create Task", exact: true }).click(); + await page.getByRole("textbox", { name: "Search tasks", exact: true }).fill("Task queue browser regression"); + await expect(page.getByText("Task queue browser regression", { exact: true })).toBeVisible(); + await page.getByRole("checkbox", { name: "Mark Task queue browser regression done", exact: true }).check(); + await page.reload(); + await expect(page.getByRole("checkbox", { name: "Mark Task queue browser regression done", exact: true })).toBeChecked(); + await expect(page.getByRole("textbox", { name: "Search tasks", exact: true })).toHaveValue("Task queue browser regression"); + await page.getByRole("textbox", { name: "Search tasks", exact: true }).fill("no-such-task-xyz"); + await expect(page.getByText("No matching tasks", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Clear filters" }).click(); + await expect(page.getByText("Task queue browser regression", { exact: true })).toBeVisible(); + expect(errors).toEqual([]); +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ccc8d78 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,50 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "backend/dist", "coverage", "playwright-report", "test-results"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + // React Hooks 7 adds React Compiler diagnostics to its recommended set. + // This app is not compiler-enabled yet; keep those migration diagnostics + // outside the release gate while retaining the upgraded hooks correctness rules. + "react-hooks/preserve-manual-memoization": "off", + "react-hooks/purity": "off", + "react-hooks/set-state-in-effect": "off", + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + // The legacy baseline contains tracked dead declarations. Keep this out of + // the v1 gate until they can be removed in behavior-preserving slices. + "@typescript-eslint/no-unused-vars": "off", + "no-restricted-imports": [ + "error", + { patterns: [{ group: ["@/shared/components/ui", "@/shared/components/ui/*", "@/shared/components/layout/AppLayout"], message: "Use the canonical @/components UI and layout paths." }] }, + ], + }, + }, + { + files: [ + "src/components/ui/**/*.{ts,tsx}", + "src/**/contexts/**/*.{ts,tsx}", + ], + rules: { + "react-refresh/only-export-components": "off", + }, + }, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..9e2d0c3 --- /dev/null +++ b/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + ProjectBrain + + + +