diff --git a/docs-web/architecture/custom-dashboard-foundation.md b/docs-web/architecture/custom-dashboard-foundation.md index e5fe4d86cb..0ff9a9213f 100644 --- a/docs-web/architecture/custom-dashboard-foundation.md +++ b/docs-web/architecture/custom-dashboard-foundation.md @@ -1,6 +1,6 @@ # Custom Dashboard Foundation -Custom dashboards are a persisted domain model for future project-scoped dashboard generation. This foundation stores manifests, generated file bundles, data-source node graphs, validation history, and publication state only. It does not expose HTTP routes, MCP tools, frontend UI, or runtime execution. +Custom dashboards are a persisted domain model for project-scoped dashboard generation. The foundation stores manifests, generated file bundles, data-source node graphs, validation history, and publication state, and the server-side validation runtime can now build and health-check a revision in an isolated Docker session. HTTP routes, MCP tools, and frontend UI are layered separately. ## Contracts @@ -35,8 +35,28 @@ All dashboard JSON payloads are stored as text and hydrated through `CustomDashb - list dashboards by project and load a dashboard by id - create and update draft metadata, manifests, files, source graphs, styleguides, and runtime metadata - create immutable revisions from the current draft or explicit payloads -- create/update validation sessions and mark a revision validated +- create/update/delete validation sessions and mark a revision validated - publish only validated revisions - archive or delete dashboards Publishing rejects unvalidated, failed, cancelled, or cross-dashboard revisions. Publishing a new validated revision replaces the prior `custom_dashboard_publications` row for the dashboard, preserving the single-active-publication invariant. + +## Validation Runtime + +`src/services/custom-dashboard-validation-service.ts` owns server-side validation execution. It consumes `CustomDashboardRepository`, `ProjectManagementRepository`, and `SettingsRepository` through the core dependency factory. + +Validation flow: + +- `startValidation(projectId, dashboardId, revisionId)` creates a validation session, materializes the immutable revision bundle under `.code-ux/runtime/custom-dashboards///workspace`, and writes a generated Vite/Preact harness. +- The harness injects a read-only Code UX data bridge containing the revision manifest, source node graph, styleguide, runtime metadata, integrations, and declared `external_api` nodes. +- The service runs install/build inside Docker using the resolved `cliWorkflow.containerImage`, then creates and starts a detached serving container on an allocated localhost port. +- A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. +- Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, and latest error/log excerpt so dashboard routes can reuse the detached session later. + +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. + +## Docker and Logs + +Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. + +Logs are captured in the validation runtime directory and combined with bounded `docker logs` output through `getValidationLogs(sessionId, tail)`. `stopValidation` removes the detached container while preserving a passed revision report, and `removeValidation` removes the session row after container cleanup. diff --git a/docs/architecture/custom-dashboard-foundation.md b/docs/architecture/custom-dashboard-foundation.md index e5fe4d86cb..0ff9a9213f 100644 --- a/docs/architecture/custom-dashboard-foundation.md +++ b/docs/architecture/custom-dashboard-foundation.md @@ -1,6 +1,6 @@ # Custom Dashboard Foundation -Custom dashboards are a persisted domain model for future project-scoped dashboard generation. This foundation stores manifests, generated file bundles, data-source node graphs, validation history, and publication state only. It does not expose HTTP routes, MCP tools, frontend UI, or runtime execution. +Custom dashboards are a persisted domain model for project-scoped dashboard generation. The foundation stores manifests, generated file bundles, data-source node graphs, validation history, and publication state, and the server-side validation runtime can now build and health-check a revision in an isolated Docker session. HTTP routes, MCP tools, and frontend UI are layered separately. ## Contracts @@ -35,8 +35,28 @@ All dashboard JSON payloads are stored as text and hydrated through `CustomDashb - list dashboards by project and load a dashboard by id - create and update draft metadata, manifests, files, source graphs, styleguides, and runtime metadata - create immutable revisions from the current draft or explicit payloads -- create/update validation sessions and mark a revision validated +- create/update/delete validation sessions and mark a revision validated - publish only validated revisions - archive or delete dashboards Publishing rejects unvalidated, failed, cancelled, or cross-dashboard revisions. Publishing a new validated revision replaces the prior `custom_dashboard_publications` row for the dashboard, preserving the single-active-publication invariant. + +## Validation Runtime + +`src/services/custom-dashboard-validation-service.ts` owns server-side validation execution. It consumes `CustomDashboardRepository`, `ProjectManagementRepository`, and `SettingsRepository` through the core dependency factory. + +Validation flow: + +- `startValidation(projectId, dashboardId, revisionId)` creates a validation session, materializes the immutable revision bundle under `.code-ux/runtime/custom-dashboards///workspace`, and writes a generated Vite/Preact harness. +- The harness injects a read-only Code UX data bridge containing the revision manifest, source node graph, styleguide, runtime metadata, integrations, and declared `external_api` nodes. +- The service runs install/build inside Docker using the resolved `cliWorkflow.containerImage`, then creates and starts a detached serving container on an allocated localhost port. +- A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. +- Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, and latest error/log excerpt so dashboard routes can reuse the detached session later. + +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. + +## Docker and Logs + +Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. + +Logs are captured in the validation runtime directory and combined with bounded `docker logs` output through `getValidationLogs(sessionId, tail)`. `stopValidation` removes the detached container while preserving a passed revision report, and `removeValidation` removes the session row after container cleanup. diff --git a/src/app/dependency-factory/core-factory.ts b/src/app/dependency-factory/core-factory.ts index ba1ecf9473..94169f8634 100644 --- a/src/app/dependency-factory/core-factory.ts +++ b/src/app/dependency-factory/core-factory.ts @@ -65,6 +65,7 @@ import { SprintFileBrowserService } from "../../services/sprint-file-browser-ser import { SprintFileBrowserRepository } from "../../repositories/sprint-file-browser-repository.js"; import { DockerService } from "../../services/docker-service.js"; import { CustomDashboardRepository } from "../../repositories/custom-dashboard-repository.js"; +import { CustomDashboardValidationService } from "../../services/custom-dashboard-validation-service.js"; export interface CoreDependencies { providerRunner: IProviderRunner; @@ -123,6 +124,7 @@ export interface CoreDependencies { sprintFileBrowserService: SprintFileBrowserService; sprintFileBrowserRepository: SprintFileBrowserRepository; customDashboardRepository: CustomDashboardRepository; + customDashboardValidationService: CustomDashboardValidationService; } export function createCoreDependencies( @@ -241,6 +243,12 @@ export function createCoreDependencies( logger: logger.child({ component: "sprint-file-browser-service" }), }); const customDashboardRepository = new CustomDashboardRepository(appDbStorage); + const customDashboardValidationService = new CustomDashboardValidationService({ + customDashboardRepository, + projectManagementRepository, + settingsRepository, + logger: logger.child({ component: "custom-dashboard-validation-service" }), + }); const sprintMarkdownService = new SprintMarkdownService(projectManagementRepository); const sprintIssueService = new SprintIssueService({ projectManagementRepository, @@ -379,5 +387,6 @@ export function createCoreDependencies( sprintFileBrowserService, sprintFileBrowserRepository, customDashboardRepository, + customDashboardValidationService, }; } diff --git a/src/repositories/custom-dashboard-repository.ts b/src/repositories/custom-dashboard-repository.ts index 69b33b3387..2843032b06 100644 --- a/src/repositories/custom-dashboard-repository.ts +++ b/src/repositories/custom-dashboard-repository.ts @@ -369,6 +369,11 @@ export class CustomDashboardRepository { return row ? this.mapValidationSessionRow(row) : null; } + deleteValidationSession(sessionId: string): void { + this.requireValidationSession(sessionId); + this.db.prepare(`DELETE FROM custom_dashboard_validation_sessions WHERE id = ?`).run(sessionId); + } + markRevisionValidated( revisionId: string, validationReport: CustomDashboardValidationReport, diff --git a/src/services/custom-dashboard-docker-plan.ts b/src/services/custom-dashboard-docker-plan.ts new file mode 100644 index 0000000000..0a637140c4 --- /dev/null +++ b/src/services/custom-dashboard-docker-plan.ts @@ -0,0 +1,150 @@ +import { CONTAINER_SETUP_SCRIPT } from "./cli-workflow-utils.js"; +import { + DOCKER_BRIDGE_NETWORK_ARGS, + DOCKER_NO_NEW_PRIVILEGES_ARGS, + toDockerMountArg, +} from "./cli-docker-utils.js"; + +export const CUSTOM_DASHBOARD_VALIDATION_CONTAINER_PORT = 4173; +export const CUSTOM_DASHBOARD_VALIDATION_LOG_DRIVER = "local"; +export const CUSTOM_DASHBOARD_VALIDATION_CONTAINER_WORKSPACE = "/code-ux-custom-dashboard/workspace"; +export const CUSTOM_DASHBOARD_VALIDATION_CONTAINER_HOME = "/code-ux-custom-dashboard/home"; +export const CUSTOM_DASHBOARD_VALIDATION_CONTAINER_NPM_PREFIX = "/code-ux-custom-dashboard/npm-global"; +export const CUSTOM_DASHBOARD_VALIDATION_CONTAINER_NPM_CACHE = "/code-ux-custom-dashboard/npm-cache"; + +interface CustomDashboardValidationDockerBaseArgs { + projectId: string; + dashboardId: string; + revisionId: string; + sessionId: string; + workspacePath: string; + runtimeHomePath: string; + hostPort?: number | null; + containerName?: string; + userSpec: string | null; + setupScriptSource?: string | null; + shouldRunSetupScriptAtRuntime: boolean; + resolvedImage: string; + bootstrapScript: string; +} + +export interface CustomDashboardValidationDockerRunArgs extends CustomDashboardValidationDockerBaseArgs { + command: string; +} + +export interface CustomDashboardValidationDockerCreateArgs extends CustomDashboardValidationDockerBaseArgs { + hostPort: number; + containerName: string; + startCommand: string; +} + +export function buildCustomDashboardValidationDockerRunArgs( + args: CustomDashboardValidationDockerRunArgs, +): string[] { + const dockerArgs = [ + "run", + "--rm", + "--log-driver", CUSTOM_DASHBOARD_VALIDATION_LOG_DRIVER, + ...DOCKER_BRIDGE_NETWORK_ARGS, + ...DOCKER_NO_NEW_PRIVILEGES_ARGS, + "--workdir", CUSTOM_DASHBOARD_VALIDATION_CONTAINER_WORKSPACE, + "--label", "code-ux.managed=true", + "--label", "code-ux.custom-dashboard-validation-build=true", + "--label", `code-ux.project-id=${args.projectId}`, + "--label", `code-ux.dashboard-id=${args.dashboardId}`, + "--label", `code-ux.revision-id=${args.revisionId}`, + "--label", `code-ux.session-id=${args.sessionId}`, + "--mount", toDockerMountArg({ + source: args.workspacePath, + destination: CUSTOM_DASHBOARD_VALIDATION_CONTAINER_WORKSPACE, + readonly: false, + }), + "--mount", toDockerMountArg({ + source: args.runtimeHomePath, + destination: CUSTOM_DASHBOARD_VALIDATION_CONTAINER_HOME, + readonly: false, + }), + "-e", `HOME=${CUSTOM_DASHBOARD_VALIDATION_CONTAINER_HOME}`, + "-e", `NPM_CONFIG_PREFIX=${CUSTOM_DASHBOARD_VALIDATION_CONTAINER_NPM_PREFIX}`, + "-e", `NPM_CONFIG_CACHE=${CUSTOM_DASHBOARD_VALIDATION_CONTAINER_NPM_CACHE}`, + ]; + + appendCommonDockerArgs(dockerArgs, args); + dockerArgs.push( + args.resolvedImage, + "bash", + "-c", + args.bootstrapScript, + "dashboard-validator", + "bash", + "-lc", + args.command, + ); + return dockerArgs; +} + +export function buildCustomDashboardValidationDockerCreateArgs( + args: CustomDashboardValidationDockerCreateArgs, +): string[] { + const dockerArgs = [ + "create", + "--name", args.containerName, + "--log-driver", CUSTOM_DASHBOARD_VALIDATION_LOG_DRIVER, + ...DOCKER_BRIDGE_NETWORK_ARGS, + ...DOCKER_NO_NEW_PRIVILEGES_ARGS, + "-p", `127.0.0.1:${args.hostPort}:${CUSTOM_DASHBOARD_VALIDATION_CONTAINER_PORT}`, + "--workdir", CUSTOM_DASHBOARD_VALIDATION_CONTAINER_WORKSPACE, + "--label", "code-ux.managed=true", + "--label", "code-ux.custom-dashboard-validation=true", + "--label", `code-ux.project-id=${args.projectId}`, + "--label", `code-ux.dashboard-id=${args.dashboardId}`, + "--label", `code-ux.revision-id=${args.revisionId}`, + "--label", `code-ux.session-id=${args.sessionId}`, + "--label", `code-ux.host-port=${args.hostPort}`, + "--mount", toDockerMountArg({ + source: args.workspacePath, + destination: CUSTOM_DASHBOARD_VALIDATION_CONTAINER_WORKSPACE, + readonly: false, + }), + "--mount", toDockerMountArg({ + source: args.runtimeHomePath, + destination: CUSTOM_DASHBOARD_VALIDATION_CONTAINER_HOME, + readonly: false, + }), + "-e", `HOME=${CUSTOM_DASHBOARD_VALIDATION_CONTAINER_HOME}`, + "-e", "HOST=0.0.0.0", + "-e", `PORT=${CUSTOM_DASHBOARD_VALIDATION_CONTAINER_PORT}`, + "-e", `DASHBOARD_HOST=0.0.0.0`, + "-e", `DASHBOARD_PORT=${CUSTOM_DASHBOARD_VALIDATION_CONTAINER_PORT}`, + ]; + + appendCommonDockerArgs(dockerArgs, args); + dockerArgs.push( + args.resolvedImage, + "bash", + "-c", + args.bootstrapScript, + "dashboard-validator", + "bash", + "-lc", + args.startCommand, + ); + return dockerArgs; +} + +function appendCommonDockerArgs( + dockerArgs: string[], + args: CustomDashboardValidationDockerBaseArgs, +): void { + if (args.userSpec) { + dockerArgs.push("--user", args.userSpec); + } + + if (args.setupScriptSource && args.shouldRunSetupScriptAtRuntime) { + dockerArgs.push("--mount", toDockerMountArg({ + source: args.setupScriptSource, + destination: CONTAINER_SETUP_SCRIPT, + readonly: true, + })); + } +} diff --git a/src/services/custom-dashboard-validation-service.ts b/src/services/custom-dashboard-validation-service.ts new file mode 100644 index 0000000000..811ee1c466 --- /dev/null +++ b/src/services/custom-dashboard-validation-service.ts @@ -0,0 +1,759 @@ +import * as fs from "fs/promises"; +import * as net from "net"; +import * as os from "os"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import type { + CustomDashboardJsonObject, + CustomDashboardRevisionRecord, + CustomDashboardValidationReport, + CustomDashboardValidationSessionRecord, + CustomDashboardValidationStatus, +} from "../contracts/custom-dashboard-types.js"; +import type { ProjectManagementRepository } from "../repositories/project-management-repository.js"; +import type { SettingsRepository } from "../repositories/settings-repository.js"; +import { CustomDashboardRepository } from "../repositories/custom-dashboard-repository.js"; +import { EntityNotFoundError } from "../repositories/repository-utils.js"; +import type { Logger } from "../shared/logging/logger.js"; +import { getDockerUserSpec, mapPathPrefix, resolveConfiguredPath } from "./cli-docker-utils.js"; +import { runCommandStrict } from "./cli-process-runner.js"; +import { CONTAINER_SETUP_SCRIPT } from "./cli-workflow-utils.js"; +import { + buildCustomDashboardValidationDockerCreateArgs, + buildCustomDashboardValidationDockerRunArgs, + CUSTOM_DASHBOARD_VALIDATION_CONTAINER_NPM_CACHE, + CUSTOM_DASHBOARD_VALIDATION_CONTAINER_NPM_PREFIX, + CUSTOM_DASHBOARD_VALIDATION_CONTAINER_PORT, +} from "./custom-dashboard-docker-plan.js"; +import { + appendValidationLog, + buildBridgeConfig, + CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES, + materializeCustomDashboardWorkspace, + readValidationLog, + tailLogLines, +} from "./custom-dashboard-validation-utils.js"; +import { DockerSessionLifecycle, sanitizeContainerNameComponent } from "./docker-session-lifecycle.js"; +import { DockerBootstrapBuilder } from "../infrastructure/providers/cli/docker-bootstrap-builder.js"; + +const BUNDLED_CONTAINER_SETUP_SCRIPT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../.code-ux/container/setup.sh", +); +const VALIDATION_READINESS_TIMEOUT_MS = 300_000; +const VALIDATION_READINESS_POLL_MS = 1000; +const VALIDATION_URL_PREFIX = "/api/custom-dashboards/validation-sessions"; +const INSTALL_AND_BUILD_COMMAND = "npm install --no-audit --no-fund && npm run build"; +const START_COMMAND = `npm run start -- --host 0.0.0.0 --port ${CUSTOM_DASHBOARD_VALIDATION_CONTAINER_PORT}`; + +export interface CustomDashboardValidationServiceDeps { + customDashboardRepository: CustomDashboardRepository; + projectManagementRepository: ProjectManagementRepository; + settingsRepository: SettingsRepository; + logger?: Logger; + fetchImpl?: typeof fetch; + readinessTimeoutMs?: number; + readinessPollMs?: number; +} + +type RuntimeMetadataPatch = CustomDashboardJsonObject; + +interface ValidationContainerSummary { + id: string; + name: string | null; + status: string | null; + hostPort: number | null; + labels: Record; +} + +export class CustomDashboardValidationService { + private readonly lifecycle: DockerSessionLifecycle; + private readonly fetchImpl: typeof fetch; + private readonly readinessTimeoutMs: number; + private readonly readinessPollMs: number; + + constructor(private readonly deps: CustomDashboardValidationServiceDeps) { + this.lifecycle = new DockerSessionLifecycle(this.deps.logger); + this.fetchImpl = deps.fetchImpl ?? fetch; + this.readinessTimeoutMs = deps.readinessTimeoutMs ?? VALIDATION_READINESS_TIMEOUT_MS; + this.readinessPollMs = deps.readinessPollMs ?? VALIDATION_READINESS_POLL_MS; + } + + async startValidation( + projectId: string, + dashboardId: string, + revisionId: string, + ): Promise { + return await this.lifecycle.withSessionLock(this.buildRevisionLockKey(projectId, dashboardId, revisionId), async () => { + const project = this.requireProject(projectId); + const dashboard = this.deps.customDashboardRepository.getDashboardById(dashboardId); + if (!dashboard || dashboard.projectId !== projectId) { + throw new EntityNotFoundError(`Custom dashboard not found: ${dashboardId}`); + } + const revision = this.requireRevision(projectId, dashboardId, revisionId); + const runtimeRoot = path.join(project.baseDir, ".code-ux", "runtime", "custom-dashboards", dashboardId, revisionId); + const workspacePath = path.join(runtimeRoot, "workspace"); + const runtimeHomePath = path.join(runtimeRoot, "home-validation"); + const logPath = path.join(runtimeRoot, "validation.log"); + const session = this.deps.customDashboardRepository.createValidationSession(revision.id, { + status: "queued", + runtimeMetadata: this.buildRuntimeMetadata({ + workspacePath, + runtimeHomePath, + logPath, + installCommand: INSTALL_AND_BUILD_COMMAND, + startCommand: START_COMMAND, + }), + }); + const containerName = this.buildContainerName(projectId, dashboardId, revisionId, session.id); + + await fs.rm(logPath, { force: true }).catch(() => undefined); + await appendValidationLog(logPath, "validation", `Created validation session ${session.id}.`); + + try { + const settings = this.deps.settingsRepository.resolveProjectDashboardSettings(projectId).settings; + const cliWorkflow = settings.cliWorkflow; + const resolvedImage = cliWorkflow.containerImage.trim() || "node:24-bookworm"; + const setupScriptPath = await this.resolveContainerSetupScriptPath(project.baseDir, cliWorkflow.containerSetupScriptPath); + + this.deps.customDashboardRepository.updateValidationSession(session.id, { + status: "building", + startedAt: new Date().toISOString(), + runtimeMetadata: this.buildRuntimeMetadata({ + workspacePath, + runtimeHomePath, + logPath, + containerName, + image: resolvedImage, + installCommand: INSTALL_AND_BUILD_COMMAND, + startCommand: START_COMMAND, + }), + }); + + await fs.mkdir(runtimeHomePath, { recursive: true }); + await materializeCustomDashboardWorkspace({ + revision, + workspacePath, + bridgeConfig: buildBridgeConfig(revision), + }); + + const bootstrapScript = new DockerBootstrapBuilder().build({ + runtimeNpmPrefix: CUSTOM_DASHBOARD_VALIDATION_CONTAINER_NPM_PREFIX, + runtimeNpmCache: CUSTOM_DASHBOARD_VALIDATION_CONTAINER_NPM_CACHE, + fallbackProviders: [], + runSetupScript: Boolean(setupScriptPath), + }); + const userSpec = cliWorkflow.containerRunAsRoot ? null : await this.resolveDockerUserSpec(workspacePath); + const mappedWorkspacePath = this.mapDockerSourcePathForDaemon(workspacePath, project.baseDir); + const mappedRuntimeHomePath = this.mapDockerSourcePathForDaemon(runtimeHomePath, project.baseDir); + const mappedSetupScriptPath = setupScriptPath + ? this.mapDockerSourcePathForDaemon(setupScriptPath, project.baseDir) + : null; + + const buildResult = await runCommandStrict( + "docker", + buildCustomDashboardValidationDockerRunArgs({ + projectId, + dashboardId, + revisionId, + sessionId: session.id, + workspacePath: mappedWorkspacePath, + runtimeHomePath: mappedRuntimeHomePath, + userSpec, + setupScriptSource: mappedSetupScriptPath, + shouldRunSetupScriptAtRuntime: Boolean(setupScriptPath), + resolvedImage, + bootstrapScript, + command: INSTALL_AND_BUILD_COMMAND, + }), + project.baseDir, + process.env, + { trimOutput: false, maxStdoutChars: 1024 * 1024 }, + ); + await appendValidationLog(logPath, "install-build stdout", buildResult.stdout); + await appendValidationLog(logPath, "install-build stderr", buildResult.stderr); + + const hostPort = await this.findFreePort( + settings.sprintPreview.hostPortRangeStart, + settings.sprintPreview.hostPortRangeEnd, + ); + const validationUrlPath = `${VALIDATION_URL_PREFIX}/${session.id}/proxy/`; + const validationUrl = `http://127.0.0.1:${hostPort}/`; + await this.lifecycle.removeContainerIfPresent(containerName, project.baseDir); + const createResult = await runCommandStrict( + "docker", + buildCustomDashboardValidationDockerCreateArgs({ + projectId, + dashboardId, + revisionId, + sessionId: session.id, + workspacePath: mappedWorkspacePath, + runtimeHomePath: mappedRuntimeHomePath, + hostPort, + containerName, + userSpec, + setupScriptSource: mappedSetupScriptPath, + shouldRunSetupScriptAtRuntime: Boolean(setupScriptPath), + resolvedImage, + bootstrapScript, + startCommand: START_COMMAND, + }), + project.baseDir, + ); + const containerId = createResult.stdout.trim(); + if (!containerId) { + throw new Error("Custom dashboard validation container did not return a container id."); + } + await appendValidationLog(logPath, "docker-create", containerId); + await runCommandStrict("docker", ["start", containerName], project.baseDir); + + const runningSession = this.deps.customDashboardRepository.updateValidationSession(session.id, { + status: "running", + runtimeMetadata: this.buildRuntimeMetadata({ + workspacePath, + runtimeHomePath, + logPath, + hostPort, + containerId, + containerName, + validationUrl, + validationUrlPath, + image: resolvedImage, + installCommand: INSTALL_AND_BUILD_COMMAND, + startCommand: START_COMMAND, + }), + }); + + await this.waitForReadiness(runningSession, project.baseDir); + const logs = await this.getValidationLogs(session.id, CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES); + const report = this.buildPassedReport(hostPort, containerId, containerName, validationUrlPath, logs.logs); + return this.deps.customDashboardRepository.updateValidationSession(session.id, { + status: "passed", + validationReport: report, + finishedAt: new Date().toISOString(), + runtimeMetadata: this.buildRuntimeMetadata({ + workspacePath, + runtimeHomePath, + logPath, + hostPort, + containerId, + containerName, + validationUrl, + validationUrlPath, + image: resolvedImage, + installCommand: INSTALL_AND_BUILD_COMMAND, + startCommand: START_COMMAND, + logExcerpt: tailLogLines(logs.logs, CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES), + }), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await appendValidationLog(logPath, "validation-error", message).catch(() => undefined); + const current = this.deps.customDashboardRepository.getValidationSessionById(session.id); + const runtimeMetadata = this.mergeRuntimeMetadata(current?.runtimeMetadata, { + workspacePath, + runtimeHomePath, + logPath, + containerName, + lastError: message, + logExcerpt: await readValidationLog(logPath, CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES), + }); + const failedValidationMetadata = this.getValidationMetadata({ runtimeMetadata }); + return this.deps.customDashboardRepository.updateValidationSession(session.id, { + status: "failed", + validationReport: this.buildFailedReport("validation_failed", message, failedValidationMetadata.logExcerpt), + runtimeMetadata, + finishedAt: new Date().toISOString(), + }); + } + }); + } + + async getValidationSession(sessionId: string): Promise { + const session = this.deps.customDashboardRepository.getValidationSessionById(sessionId); + return session ? await this.refreshRuntimeState(session) : null; + } + + async listValidationSessions( + projectId: string, + dashboardId?: string, + ): Promise { + this.requireProject(projectId); + const dashboards = dashboardId + ? [this.requireDashboardForProject(projectId, dashboardId)] + : this.deps.customDashboardRepository.listDashboardsByProject(projectId); + const sessions = dashboards.flatMap((dashboard) => + this.deps.customDashboardRepository + .listRevisions(dashboard.id) + .flatMap((revision) => this.deps.customDashboardRepository.listValidationSessions(revision.id)) + ); + const refreshed = await Promise.all(sessions.map((session) => this.refreshRuntimeState(session))); + return refreshed.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + } + + async getValidationLogs(sessionId: string, tail = CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES): Promise<{ logs: string }> { + const session = this.deps.customDashboardRepository.getValidationSessionById(sessionId); + if (!session) { + throw new EntityNotFoundError("Custom dashboard validation session not found."); + } + const validationMetadata = this.getValidationMetadata(session); + const fileLogs = await readValidationLog(this.getMetadataString(validationMetadata, "logPath"), tail); + const containerRef = this.getContainerRef(session); + if (!containerRef) { + return { logs: fileLogs }; + } + const project = this.deps.projectManagementRepository.getProject(session.projectId); + const cwd = project?.baseDir ?? process.cwd(); + try { + const result = await runCommandStrict("docker", ["logs", "--tail", String(Math.max(1, Math.round(tail))), containerRef], cwd); + const dockerLogs = [result.stdout, result.stderr].filter((output) => output.trim().length > 0).join("\n"); + return { logs: [fileLogs, dockerLogs].filter((output) => output.trim().length > 0).join("\n") }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { logs: [fileLogs, message].filter((output) => output.trim().length > 0).join("\n") }; + } + } + + async stopValidation(sessionId: string): Promise { + const session = await this.requireValidationSession(sessionId); + return await this.lifecycle.withSessionLock( + this.buildRevisionLockKey(session.projectId, session.dashboardId, session.revisionId), + async () => { + const project = this.requireProject(session.projectId); + const containerRef = this.getContainerRef(session) || this.buildContainerName( + session.projectId, + session.dashboardId, + session.revisionId, + session.id, + ); + await this.lifecycle.removeContainerIfPresent(containerRef, project.baseDir); + const status = this.shouldCancelOnStop(session.status) ? "cancelled" : session.status; + const validationReport = status === "cancelled" + ? this.buildFailedReport("validation_cancelled", "Custom dashboard validation was stopped.", null) + : session.validationReport; + return this.deps.customDashboardRepository.updateValidationSession(session.id, { + status, + validationReport, + runtimeMetadata: this.mergeRuntimeMetadata(session.runtimeMetadata, { + containerId: null, + containerName: null, + hostPort: null, + lastStoppedAt: new Date().toISOString(), + }), + finishedAt: session.finishedAt ?? new Date().toISOString(), + }); + }, + ); + } + + async removeValidation(sessionId: string): Promise { + const session = await this.requireValidationSession(sessionId); + await this.lifecycle.withSessionLock( + this.buildRevisionLockKey(session.projectId, session.dashboardId, session.revisionId), + async () => { + if (this.shouldCancelOnStop(session.status)) { + this.deps.customDashboardRepository.updateValidationSession(session.id, { + status: "cancelled", + validationReport: this.buildFailedReport("validation_cancelled", "Custom dashboard validation was removed.", null), + finishedAt: new Date().toISOString(), + }); + } + const project = this.requireProject(session.projectId); + const containerRef = this.getContainerRef(session) || this.buildContainerName( + session.projectId, + session.dashboardId, + session.revisionId, + session.id, + ); + await this.lifecycle.removeContainerIfPresent(containerRef, project.baseDir); + this.deps.customDashboardRepository.deleteValidationSession(session.id); + }, + ); + } + + private async waitForReadiness(session: CustomDashboardValidationSessionRecord, cwd: string): Promise { + const deadline = Date.now() + this.readinessTimeoutMs; + while (Date.now() < deadline) { + const refreshed = await this.refreshRuntimeState(session); + if (refreshed.status === "passed" || refreshed.status === "running") { + const hostPort = this.getValidationMetadata(refreshed).hostPort; + if (typeof hostPort === "number" && await this.fetchHealthStatus(hostPort)) { + return; + } + } + const containerRef = this.getContainerRef(refreshed); + if (containerRef) { + const container = await this.findManagedContainerForSession(refreshed, cwd); + if (container && container.status !== "running") { + const logs = await this.readContainerLogs(container.id, cwd).catch(() => ""); + throw new Error(this.extractValidationError(logs) || `Validation container is ${container.status}.`); + } + } + await new Promise((resolve) => setTimeout(resolve, this.readinessPollMs)); + } + throw new Error(`Custom dashboard validation did not become reachable within ${Math.round(this.readinessTimeoutMs / 1000)} seconds.`); + } + + private async refreshRuntimeState( + session: CustomDashboardValidationSessionRecord, + ): Promise { + const project = this.deps.projectManagementRepository.getProject(session.projectId); + if (!project) { + return session; + } + const container = await this.findManagedContainerForSession(session, project.baseDir); + if (!container) { + if (!this.getContainerRef(session) || this.isTerminalStatus(session.status)) { + return session; + } + const metadata = this.mergeRuntimeMetadata(session.runtimeMetadata, { + containerId: null, + containerName: null, + hostPort: null, + lastError: "Validation container is no longer present.", + }); + return this.deps.customDashboardRepository.updateValidationSession(session.id, { + status: "failed", + validationReport: this.buildFailedReport("container_missing", "Validation container is no longer present.", null), + runtimeMetadata: metadata, + finishedAt: new Date().toISOString(), + }); + } + + const metadata = this.mergeRuntimeMetadata(session.runtimeMetadata, { + containerId: container.id, + containerName: container.name, + hostPort: container.hostPort, + }); + if (container.status !== "running" && !this.isTerminalStatus(session.status)) { + const logs = await this.readContainerLogs(container.id, project.baseDir).catch(() => ""); + const message = this.extractValidationError(logs) || `Validation container is ${container.status}.`; + return this.deps.customDashboardRepository.updateValidationSession(session.id, { + status: "failed", + validationReport: this.buildFailedReport("container_exited", message, tailLogLines(logs, CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES)), + runtimeMetadata: this.mergeRuntimeMetadata(metadata, { + lastError: message, + logExcerpt: tailLogLines(logs, CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES), + }), + finishedAt: new Date().toISOString(), + }); + } + + const nextValidationMetadata = this.getValidationMetadata({ runtimeMetadata: metadata }); + const currentValidationMetadata = this.getValidationMetadata(session); + if ( + container.status === "running" + && (nextValidationMetadata.containerId !== currentValidationMetadata.containerId + || nextValidationMetadata.hostPort !== currentValidationMetadata.hostPort) + ) { + return this.deps.customDashboardRepository.updateValidationSession(session.id, { runtimeMetadata: metadata }); + } + return session; + } + + private async findManagedContainerForSession( + session: CustomDashboardValidationSessionRecord, + cwd: string, + ): Promise { + const containers = await this.listValidationContainers(cwd); + const metadata = this.getValidationMetadata(session); + const containerId = typeof metadata.containerId === "string" ? metadata.containerId.trim() : ""; + const containerName = typeof metadata.containerName === "string" ? metadata.containerName.trim() : ""; + return containers.find((container) => container.labels["code-ux.session-id"] === session.id) + ?? containers.find((container) => container.id === containerId) + ?? containers.find((container) => containerName.length > 0 && container.name === containerName) + ?? null; + } + + private async listValidationContainers(cwd: string): Promise { + try { + const result = await runCommandStrict( + "docker", + [ + "ps", + "-a", + "--filter", "label=code-ux.custom-dashboard-validation=true", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Label \"code-ux.project-id\"}}\t{{.Label \"code-ux.dashboard-id\"}}\t{{.Label \"code-ux.revision-id\"}}\t{{.Label \"code-ux.session-id\"}}\t{{.Label \"code-ux.host-port\"}}", + ], + cwd, + ); + return this.parseDockerPsOutput(result.stdout); + } catch { + return []; + } + } + + private parseDockerPsOutput(stdout: string): ValidationContainerSummary[] { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [id, name, rawStatus, projectId, dashboardId, revisionId, sessionId, hostPortRaw] = line.split("\t"); + const parsedPort = hostPortRaw ? Number.parseInt(hostPortRaw, 10) : NaN; + return { + id, + name: name || null, + status: this.lifecycle.normalizeDockerState(rawStatus), + hostPort: Number.isInteger(parsedPort) ? parsedPort : null, + labels: { + "code-ux.project-id": projectId || "", + "code-ux.dashboard-id": dashboardId || "", + "code-ux.revision-id": revisionId || "", + "code-ux.session-id": sessionId || "", + }, + }; + }); + } + + private async fetchHealthStatus(hostPort: number): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2500); + try { + const response = await this.fetchImpl(`http://127.0.0.1:${hostPort}/`, { signal: controller.signal }); + return response.status < 500; + } finally { + clearTimeout(timeout); + } + } catch { + return false; + } + } + + private async readContainerLogs(containerRef: string, cwd: string): Promise { + const result = await runCommandStrict( + "docker", + ["logs", "--tail", String(CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES), containerRef], + cwd, + ); + return [result.stdout, result.stderr].filter((output) => output.trim().length > 0).join("\n"); + } + + private extractValidationError(logs: string): string | null { + const lines = logs + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (/error|failed|fatal|eaddr|enoent|permission denied/i.test(line)) { + return line; + } + } + return lines.at(-1) || null; + } + + private async findFreePort(start: number, end: number): Promise { + const lower = Number.isInteger(start) ? Math.max(1, start) : 4445; + const upper = Number.isInteger(end) ? Math.min(65535, end) : 4999; + for (let port = lower; port <= upper; port += 1) { + if (await this.checkPortAvailable(port)) { + return port; + } + } + throw new Error(`No free validation ports available in range ${lower}-${upper}.`); + } + + private async checkPortAvailable(port: number): Promise { + return await new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); + } + + private requireProject(projectId: string) { + const project = this.deps.projectManagementRepository.getProject(projectId); + if (!project) { + throw new EntityNotFoundError(`Project not found: ${projectId}`); + } + return project; + } + + private requireDashboardForProject(projectId: string, dashboardId: string) { + const dashboard = this.deps.customDashboardRepository.getDashboardById(dashboardId); + if (!dashboard || dashboard.projectId !== projectId) { + throw new EntityNotFoundError(`Custom dashboard not found: ${dashboardId}`); + } + return dashboard; + } + + private requireRevision( + projectId: string, + dashboardId: string, + revisionId: string, + ): CustomDashboardRevisionRecord { + const revision = this.deps.customDashboardRepository.getRevisionById(revisionId); + if (!revision || revision.projectId !== projectId || revision.dashboardId !== dashboardId) { + throw new EntityNotFoundError(`Custom dashboard revision not found: ${revisionId}`); + } + return revision; + } + + private async requireValidationSession(sessionId: string): Promise { + const session = await this.getValidationSession(sessionId); + if (!session) { + throw new EntityNotFoundError("Custom dashboard validation session not found."); + } + return session; + } + + private buildRuntimeMetadata(values: Record): RuntimeMetadataPatch { + return { validation: this.pruneJsonObject(values) }; + } + + private mergeRuntimeMetadata( + current: CustomDashboardJsonObject | null | undefined, + values: Record, + ): RuntimeMetadataPatch { + const existing = this.getValidationMetadata({ runtimeMetadata: current ?? {} }); + return { + validation: { + ...existing, + ...this.pruneJsonObject(values), + }, + }; + } + + private pruneJsonObject(values: Record): CustomDashboardJsonObject { + const result: CustomDashboardJsonObject = {}; + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + continue; + } + result[key] = value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? value + : JSON.parse(JSON.stringify(value)) as CustomDashboardJsonObject[string]; + } + return result; + } + + private getValidationMetadata( + session: Pick, + ): Record { + const value = session.runtimeMetadata.validation; + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + } + + private getMetadataString( + metadata: Record, + key: string, + ): string | null { + const value = metadata[key]; + return typeof value === "string" && value.trim().length > 0 ? value : null; + } + + private getContainerRef(session: CustomDashboardValidationSessionRecord): string | null { + const metadata = this.getValidationMetadata(session); + const containerId = typeof metadata.containerId === "string" ? metadata.containerId.trim() : ""; + if (containerId) { + return containerId; + } + const containerName = typeof metadata.containerName === "string" ? metadata.containerName.trim() : ""; + return containerName || null; + } + + private buildPassedReport( + hostPort: number, + containerId: string, + containerName: string, + validationUrlPath: string, + logs: string, + ): CustomDashboardValidationReport { + return { + valid: true, + summary: "Custom dashboard revision built, started, and passed health checks.", + issues: [], + metadata: { + hostPort, + containerId, + containerName, + validationUrlPath, + logExcerpt: tailLogLines(logs, CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES), + }, + }; + } + + private buildFailedReport( + code: string, + message: string, + logExcerpt: unknown, + ): CustomDashboardValidationReport { + return { + valid: false, + summary: message, + issues: [{ field: "runtime", code, message }], + metadata: typeof logExcerpt === "string" && logExcerpt.trim().length > 0 ? { logExcerpt } : {}, + }; + } + + private shouldCancelOnStop(status: CustomDashboardValidationStatus): boolean { + return status === "queued" || status === "building" || status === "running"; + } + + private isTerminalStatus(status: CustomDashboardValidationStatus): boolean { + return status === "passed" || status === "failed" || status === "cancelled"; + } + + private buildRevisionLockKey(projectId: string, dashboardId: string, revisionId: string): string { + return `${projectId}:${dashboardId}:${revisionId}`; + } + + private buildContainerName(projectId: string, dashboardId: string, revisionId: string, sessionId: string): string { + return [ + "code-ux-cdash", + sanitizeContainerNameComponent(projectId, 12), + sanitizeContainerNameComponent(dashboardId, 12), + sanitizeContainerNameComponent(revisionId, 12), + sanitizeContainerNameComponent(sessionId, 12), + ].join("-").slice(0, 63); + } + + private mapDockerSourcePathForDaemon(sourcePath: string, repoPath: string): string { + const normalizedSource = path.resolve(sourcePath); + const workspaceMapping = (process.env.JULES_DOCKER_HOST_WORKSPACE_ROOT || "").trim(); + const homeMapping = (process.env.JULES_DOCKER_HOST_HOME_ROOT || "").trim(); + let mapped = normalizedSource; + if (workspaceMapping.length > 0) mapped = mapPathPrefix(mapped, repoPath, workspaceMapping); + if (homeMapping.length > 0) mapped = mapPathPrefix(mapped, os.homedir(), homeMapping); + return mapped; + } + + private async resolveDockerUserSpec(workspacePath: string): Promise { + try { + const stats = await fs.stat(workspacePath); + if (typeof stats.uid === "number" && typeof stats.gid === "number" && stats.uid !== 0) { + return `${stats.uid}:${stats.gid}`; + } + } catch { + // fall through to getDockerUserSpec + } + return getDockerUserSpec(); + } + + private async resolveContainerSetupScriptPath( + repoPath: string, + configuredSetupScriptPath: string, + ): Promise { + const configured = configuredSetupScriptPath.trim(); + const candidates = configured + ? [resolveConfiguredPath(repoPath, configured)] + : [path.join(repoPath, ".code-ux", "container", "setup.sh"), BUNDLED_CONTAINER_SETUP_SCRIPT]; + for (const candidate of candidates) { + try { + await fs.access(candidate); + return candidate; + } catch { + continue; + } + } + return null; + } +} diff --git a/src/services/custom-dashboard-validation-utils.ts b/src/services/custom-dashboard-validation-utils.ts new file mode 100644 index 0000000000..10ca7bb1c2 --- /dev/null +++ b/src/services/custom-dashboard-validation-utils.ts @@ -0,0 +1,247 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import * as pathPosix from "path/posix"; +import type { + CustomDashboardJsonObject, + CustomDashboardRevisionRecord, +} from "../contracts/custom-dashboard-types.js"; + +export const CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES = 200; +export const CUSTOM_DASHBOARD_VALIDATION_MAX_LOG_TAIL_LINES = 1000; + +export interface CustomDashboardBridgeConfig { + projectId: string; + dashboardId: string; + revisionId: string; + manifest: CustomDashboardRevisionRecord["manifest"]; + sourceNodeGraph: CustomDashboardRevisionRecord["sourceNodeGraph"]; + styleguide: CustomDashboardRevisionRecord["styleguide"]; + runtimeMetadata: CustomDashboardJsonObject; + integrations: CustomDashboardJsonObject; + externalApiNodes: CustomDashboardJsonObject[]; +} + +export interface MaterializedCustomDashboardWorkspace { + workspacePath: string; + entryImportPath: string; +} + +export async function materializeCustomDashboardWorkspace(args: { + revision: CustomDashboardRevisionRecord; + workspacePath: string; + bridgeConfig: CustomDashboardBridgeConfig; +}): Promise { + await fs.rm(args.workspacePath, { recursive: true, force: true }); + await fs.mkdir(args.workspacePath, { recursive: true }); + + for (const file of args.revision.fileBundle.files) { + const safeRelativePath = normalizeCustomDashboardBundlePath(file.path); + const absolutePath = path.join(args.workspacePath, safeRelativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, file.content, "utf8"); + } + + const harnessDir = path.join(args.workspacePath, ".codeux-harness"); + await fs.mkdir(harnessDir, { recursive: true }); + const entryPath = normalizeCustomDashboardBundlePath(args.revision.manifest.entryFile); + const entryImportPath = toRelativeImportPath(".codeux-harness/main.tsx", entryPath); + + await Promise.all([ + writeJsonFile(path.join(args.workspacePath, "package.json"), buildPackageJson()), + fs.writeFile(path.join(args.workspacePath, "index.html"), buildIndexHtml(), "utf8"), + fs.writeFile(path.join(args.workspacePath, "vite.config.ts"), buildViteConfig(), "utf8"), + fs.writeFile(path.join(args.workspacePath, "tsconfig.json"), buildTsConfig(), "utf8"), + fs.writeFile( + path.join(harnessDir, "codeux-data-bridge.ts"), + buildDataBridgeModule(args.bridgeConfig), + "utf8", + ), + fs.writeFile( + path.join(harnessDir, "main.tsx"), + buildHarnessEntry(entryImportPath), + "utf8", + ), + ]); + + return { + workspacePath: args.workspacePath, + entryImportPath, + }; +} + +export function normalizeCustomDashboardBundlePath(input: string): string { + const normalized = input.trim().replace(/\\/g, "/"); + if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) { + throw new Error(`Invalid custom dashboard bundle path: ${input}`); + } + const collapsed = pathPosix.normalize(normalized); + if (collapsed === "." || collapsed.startsWith("../") || collapsed === "..") { + throw new Error(`Custom dashboard bundle path escapes the workspace: ${input}`); + } + if (collapsed.split("/").includes("node_modules")) { + throw new Error(`Custom dashboard bundle path cannot target node_modules: ${input}`); + } + return collapsed; +} + +export function tailLogLines(logs: string, tail: number): string { + const boundedTail = Math.max(1, Math.min(CUSTOM_DASHBOARD_VALIDATION_MAX_LOG_TAIL_LINES, Math.round(tail))); + const lines = logs.split(/\r?\n/); + return lines.slice(-boundedTail).join("\n"); +} + +export async function appendValidationLog(logPath: string, section: string, content: string): Promise { + await fs.mkdir(path.dirname(logPath), { recursive: true }); + const body = content.trim().length > 0 ? content.trimEnd() : "(no output)"; + await fs.appendFile(logPath, `\n## ${section}\n${body}\n`, "utf8"); +} + +export async function readValidationLog(logPath: string | null | undefined, tail: number): Promise { + if (!logPath) { + return ""; + } + try { + return tailLogLines(await fs.readFile(logPath, "utf8"), tail); + } catch { + return ""; + } +} + +export function buildBridgeConfig(revision: CustomDashboardRevisionRecord): CustomDashboardBridgeConfig { + return { + projectId: revision.projectId, + dashboardId: revision.dashboardId, + revisionId: revision.id, + manifest: revision.manifest, + sourceNodeGraph: revision.sourceNodeGraph, + styleguide: revision.styleguide, + runtimeMetadata: revision.runtimeMetadata, + integrations: extractJsonObject(revision.runtimeMetadata.integrations), + externalApiNodes: revision.sourceNodeGraph.nodes + .filter((node) => node.type === "external_api") + .map((node) => ({ + id: node.id, + type: node.type, + title: node.title, + config: extractJsonObject(node.config), + })), + }; +} + +function extractJsonObject(value: unknown): CustomDashboardJsonObject { + return value && typeof value === "object" && !Array.isArray(value) + ? value as CustomDashboardJsonObject + : {}; +} + +function toRelativeImportPath(fromPath: string, toPath: string): string { + const relative = pathPosix.relative(pathPosix.dirname(fromPath), toPath); + return relative.startsWith(".") ? relative : `./${relative}`; +} + +function buildPackageJson(): Record { + return { + private: true, + type: "module", + scripts: { + build: "vite build", + start: "vite preview --host 0.0.0.0", + }, + dependencies: { + "@preact/preset-vite": "^2.10.5", + "@preact/signals": "^2.9.0", + "preact": "^10.29.0", + "typescript": "^5.9.3", + "vite": "^8.0.8", + }, + devDependencies: {}, + }; +} + +function buildIndexHtml(): string { + return [ + "", + "", + " ", + " ", + " ", + " Code UX Custom Dashboard Validation", + " ", + " ", + "
", + " ", + " ", + "", + "", + ].join("\n"); +} + +function buildViteConfig(): string { + return [ + "import { defineConfig } from \"vite\";", + "import preact from \"@preact/preset-vite\";", + "", + "export default defineConfig({", + " plugins: [preact()],", + " server: { host: \"0.0.0.0\" },", + " preview: { host: \"0.0.0.0\" },", + "});", + "", + ].join("\n"); +} + +function buildTsConfig(): string { + return `${JSON.stringify({ + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + jsx: "react-jsx", + jsxImportSource: "preact", + strict: true, + skipLibCheck: true, + noEmit: true, + types: ["vite/client"], + }, + include: ["**/*.ts", "**/*.tsx"], + }, null, 2)}\n`; +} + +function buildDataBridgeModule(config: CustomDashboardBridgeConfig): string { + return [ + "export const codeUxDataBridge = Object.freeze(", + `${JSON.stringify(config, null, 2)}`, + ");", + "", + "export type CodeUxDataBridge = typeof codeUxDataBridge;", + "", + ].join("\n"); +} + +function buildHarnessEntry(entryImportPath: string): string { + return [ + "import { h, render } from \"preact\";", + "import { codeUxDataBridge } from \"./codeux-data-bridge\";", + `import * as DashboardModule from ${JSON.stringify(entryImportPath)};`, + "", + "const root = document.getElementById(\"app\");", + "const Candidate = (DashboardModule.default ?? DashboardModule.Dashboard ?? DashboardModule.App) as unknown;", + "", + "if (root && typeof Candidate === \"function\") {", + " render(h(Candidate as never, { codeUxDataBridge }), root);", + "} else if (root) {", + " root.dataset.codeUxValidationReady = \"true\";", + "}", + "", + "Object.defineProperty(window, \"codeUxDataBridge\", {", + " value: codeUxDataBridge,", + " writable: false,", + " configurable: false,", + "});", + "", + ].join("\n"); +} + +async function writeJsonFile(filePath: string, value: Record): Promise { + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} diff --git a/tests/backend/services/custom-dashboard-validation-service.test.ts b/tests/backend/services/custom-dashboard-validation-service.test.ts new file mode 100644 index 0000000000..c88a3a864f --- /dev/null +++ b/tests/backend/services/custom-dashboard-validation-service.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import type { CommandResult } from "../../../src/services/cli-process-runner.js"; +import type { + CustomDashboardFileBundle, + CustomDashboardManifest, +} from "../../../src/contracts/custom-dashboard-types.js"; +import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; +import { CustomDashboardRepository } from "../../../src/repositories/custom-dashboard-repository.js"; +import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { SettingsRepository } from "../../../src/repositories/settings-repository.js"; +import { CustomDashboardValidationService } from "../../../src/services/custom-dashboard-validation-service.js"; +import { runCommandStrict } from "../../../src/services/cli-process-runner.js"; + +vi.mock("../../../src/services/cli-process-runner.js", () => ({ + runCommandStrict: vi.fn(), +})); + +const tempDirs: string[] = []; + +function commandResult(stdout = "", stderr = ""): CommandResult { + return { ok: true, code: 0, stdout, stderr }; +} + +async function createFixture(): Promise<{ + dir: string; + storage: AppDbStorage; + projects: ProjectManagementRepository; + dashboards: CustomDashboardRepository; + service: CustomDashboardValidationService; + projectId: string; + dashboardId: string; + revisionId: string; +}> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "custom-dashboard-validation-")); + tempDirs.push(dir); + const storage = new AppDbStorage(path.join(dir, "app.db")); + const projects = new ProjectManagementRepository(storage); + const project = projects.createProject({ + name: "Custom Dashboard Validation Project", + sourceType: "local", + sourceRef: dir, + }); + const dashboards = new CustomDashboardRepository(storage); + const dashboard = dashboards.createDraft(project.id, { + title: "Delivery Pulse", + manifest: manifest(), + fileBundle: fileBundle(), + sourceNodeGraph: { + nodes: [{ id: "incidents", type: "external_api", title: "Incidents", config: { endpoint: "/incidents" } }], + edges: [], + }, + runtimeMetadata: { integrations: { incidents: { readonly: true } } }, + }); + const revision = dashboards.createRevision(dashboard.id); + const service = new CustomDashboardValidationService({ + customDashboardRepository: dashboards, + projectManagementRepository: projects, + settingsRepository: new SettingsRepository(path.join(dir, "settings.db")), + fetchImpl: vi.fn().mockResolvedValue(new Response("ok", { status: 200 })), + readinessTimeoutMs: 50, + readinessPollMs: 1, + }); + + return { + dir, + storage, + projects, + dashboards, + service, + projectId: project.id, + dashboardId: dashboard.id, + revisionId: revision.id, + }; +} + +function manifest(): CustomDashboardManifest { + return { + schemaVersion: 1, + title: "Delivery Pulse", + entryFile: "src/dashboard.tsx", + filePaths: ["src/dashboard.tsx", "src/data.ts"], + }; +} + +function fileBundle(content = "export default function Dashboard() { return
ok
; }"): CustomDashboardFileBundle { + return { + files: [ + { path: "src/dashboard.tsx", content }, + { path: "src/data.ts", content: "export const rows = [];" }, + ], + }; +} + +function mockSuccessfulDocker(): void { + vi.mocked(runCommandStrict).mockImplementation(async (command, args) => { + if (command !== "docker") { + return commandResult(); + } + const action = args[0]; + if (action === "run") { + return commandResult("install ok\nbuild ok\n"); + } + if (action === "create") { + return commandResult("container-123\n"); + } + if (action === "start" || action === "rm") { + return commandResult(); + } + if (action === "logs") { + return commandResult("vite preview ready\n"); + } + if (action === "ps") { + return commandResult("container-123\tcode-ux-cdash-test\tUp 2 seconds\tproject\tdashboard\trevision\t\t4445\n"); + } + return commandResult(); + }); +} + +beforeEach(() => { + vi.mocked(runCommandStrict).mockReset(); + mockSuccessfulDocker(); +}); + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("CustomDashboardValidationService", () => { + it("materializes a revision, builds it in Docker, starts a detached session, and marks validation passed", async () => { + const { dashboards, service, projectId, dashboardId, revisionId, dir } = await createFixture(); + + const session = await service.startValidation(projectId, dashboardId, revisionId); + + expect(session.status).toBe("passed"); + expect(session.validationReport).toMatchObject({ + valid: true, + issues: [], + metadata: { containerId: "container-123" }, + }); + const revision = dashboards.getRevisionById(revisionId); + expect(revision?.validationStatus).toBe("passed"); + expect(revision?.validationReport?.valid).toBe(true); + expect(dashboards.getDashboardById(dashboardId)?.status).toBe("validated"); + + const workspacePath = path.join(dir, ".code-ux", "runtime", "custom-dashboards", dashboardId, revisionId, "workspace"); + await expect(fs.readFile(path.join(workspacePath, "src", "dashboard.tsx"), "utf8")).resolves.toContain("Dashboard"); + await expect(fs.readFile(path.join(workspacePath, ".codeux-harness", "codeux-data-bridge.ts"), "utf8")).resolves.toContain("externalApiNodes"); + + const dockerCalls = vi.mocked(runCommandStrict).mock.calls.filter(([command]) => command === "docker"); + expect(dockerCalls.some(([, args]) => args[0] === "run" && args.includes("npm install --no-audit --no-fund && npm run build"))).toBe(true); + expect(dockerCalls.some(([, args]) => args[0] === "create" && args.includes("--name"))).toBe(true); + expect(dockerCalls.some(([, args]) => args[0] === "start")).toBe(true); + expect(dockerCalls.flatMap(([, args]) => args).filter((arg) => arg.startsWith("type=")).join(" ")).not.toContain("/opt/credentials"); + }); + + it("records failed validation when the Docker build fails and keeps publication state unchanged", async () => { + const { dashboards, service, projectId, dashboardId, revisionId } = await createFixture(); + vi.mocked(runCommandStrict).mockImplementation(async (_command, args) => { + if (args[0] === "run") { + throw new Error("npm run build failed: Build failed."); + } + if (args[0] === "logs") { + return commandResult("build failed\n"); + } + return commandResult(); + }); + + const session = await service.startValidation(projectId, dashboardId, revisionId); + + expect(session.status).toBe("failed"); + expect(session.validationReport).toMatchObject({ + valid: false, + issues: [{ code: "validation_failed" }], + }); + expect(dashboards.getRevisionById(revisionId)?.validationStatus).toBe("failed"); + expect(dashboards.getDashboardById(dashboardId)?.publishedRevisionId).toBeNull(); + expect(vi.mocked(runCommandStrict).mock.calls.some(([, args]) => args[0] === "create")).toBe(false); + }); + + it("retrieves bounded validation logs from the persisted log file and container logs", async () => { + const { service, projectId, dashboardId, revisionId } = await createFixture(); + const session = await service.startValidation(projectId, dashboardId, revisionId); + + const logs = await service.getValidationLogs(session.id, 20); + + expect(logs.logs).toContain("install ok"); + expect(logs.logs).toContain("vite preview ready"); + }); + + it("stops and removes validation sessions without invalidating passed revisions", async () => { + const { dashboards, service, projectId, dashboardId, revisionId } = await createFixture(); + const session = await service.startValidation(projectId, dashboardId, revisionId); + + const stopped = await service.stopValidation(session.id); + expect(stopped.status).toBe("passed"); + expect(stopped.validationReport?.valid).toBe(true); + expect(dashboards.getRevisionById(revisionId)?.validationStatus).toBe("passed"); + + await service.removeValidation(session.id); + + expect(dashboards.getValidationSessionById(session.id)).toBeNull(); + expect(dashboards.getRevisionById(revisionId)?.validationStatus).toBe("passed"); + expect(vi.mocked(runCommandStrict).mock.calls.some(([, args]) => args[0] === "rm" && args.includes("container-123"))).toBe(true); + }); + + it("lists validation sessions by project and optional dashboard", async () => { + const { service, projectId, dashboardId, revisionId } = await createFixture(); + const session = await service.startValidation(projectId, dashboardId, revisionId); + + await expect(service.getValidationSession(session.id)).resolves.toMatchObject({ id: session.id }); + await expect(service.listValidationSessions(projectId)).resolves.toEqual([expect.objectContaining({ id: session.id })]); + await expect(service.listValidationSessions(projectId, dashboardId)).resolves.toEqual([expect.objectContaining({ id: session.id })]); + }); +});