From cdb838154e5b14c70f34765a4ad999df0d492cf5 Mon Sep 17 00:00:00 2001 From: Che <30403707+Che-Zhu@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:39:20 +0800 Subject: [PATCH] refactor: introduce platform control commands --- docs/architecture-evolution.md | 2 +- lib/actions/database.ts | 102 +------- lib/actions/project.ts | 243 +----------------- lib/jobs/database/databaseReconcile.ts | 2 +- lib/jobs/project-task/projectTaskReconcile.ts | 2 +- lib/jobs/sandbox/sandboxReconcile.ts | 2 +- .../commands/database/create-database.ts | 76 ++++++ .../commands/database/delete-database.ts | 40 +++ .../control/commands/database/index.ts | 2 + .../control/commands/database/readme.md | 6 + .../project/create-project-from-github.ts | 71 +++++ .../commands/project/create-project.ts | 25 ++ .../control/commands/project/index.ts | 5 + .../control/commands/project/readme.md | 7 + .../control/commands/project/shared.ts | 176 +++++++++++++ lib/platform/control/commands/readme.md | 12 + lib/platform/control/readme.md | 7 + lib/platform/control/types.ts | 6 + lib/platform/readme.md | 12 + 19 files changed, 469 insertions(+), 329 deletions(-) create mode 100644 lib/platform/control/commands/database/create-database.ts create mode 100644 lib/platform/control/commands/database/delete-database.ts create mode 100644 lib/platform/control/commands/database/index.ts create mode 100644 lib/platform/control/commands/database/readme.md create mode 100644 lib/platform/control/commands/project/create-project-from-github.ts create mode 100644 lib/platform/control/commands/project/create-project.ts create mode 100644 lib/platform/control/commands/project/index.ts create mode 100644 lib/platform/control/commands/project/readme.md create mode 100644 lib/platform/control/commands/project/shared.ts create mode 100644 lib/platform/control/commands/readme.md create mode 100644 lib/platform/control/readme.md create mode 100644 lib/platform/control/types.ts create mode 100644 lib/platform/readme.md diff --git a/docs/architecture-evolution.md b/docs/architecture-evolution.md index 835b844..b3d457d 100644 --- a/docs/architecture-evolution.md +++ b/docs/architecture-evolution.md @@ -189,7 +189,7 @@ Typical examples: - task prerequisite rules - lifecycle transition rules -### `lib/control/` +### `lib/platform/control/` Should contain: diff --git a/lib/actions/database.ts b/lib/actions/database.ts index 0a0079c..1e5d763 100644 --- a/lib/actions/database.ts +++ b/lib/actions/database.ts @@ -10,16 +10,13 @@ import type { Database } from '@prisma/client' import { auth } from '@/lib/auth' -import { prisma } from '@/lib/db' -import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' -import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils' -import { VERSIONS } from '@/lib/k8s/versions' -import { logger as baseLogger } from '@/lib/logger' +import { + createDatabaseCommand, + deleteDatabaseCommand, +} from '@/lib/platform/control/commands/database' import type { ActionResult } from './types' -const logger = baseLogger.child({ module: 'actions/database' }) - /** * Create a database for an existing project * @@ -35,66 +32,11 @@ export async function createDatabase( return { success: false, error: 'Unauthorized' } } - // Verify project exists and belongs to user - const project = await prisma.project.findUnique({ - where: { id: projectId }, - include: { databases: true }, - }) - - if (!project) { - return { success: false, error: 'Project not found' } - } - - if (project.userId !== session.user.id) { - return { success: false, error: 'Unauthorized' } - } - - // Check if database already exists - if (project.databases.length > 0) { - return { success: false, error: 'Database already exists for this project' } - } - - // Get K8s service for user - let k8sService - let namespace - try { - k8sService = await getK8sServiceForUser(session.user.id) - namespace = k8sService.getDefaultNamespace() - } catch (error) { - if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) { - return { - success: false, - error: 'Please configure your kubeconfig before creating a database', - } - } - throw error - } - - // Generate database name if not provided - const k8sProjectName = KubernetesUtils.toK8sProjectName(project.name) - const randomSuffix = KubernetesUtils.generateRandomString() - const finalDatabaseName = databaseName || `${k8sProjectName}-db-${randomSuffix}` - - // Create Database record - const database = await prisma.database.create({ - data: { - projectId: project.id, - name: finalDatabaseName, - k8sNamespace: namespace, - databaseName: finalDatabaseName, - status: 'CREATING', - lockedUntil: null, - storageSize: VERSIONS.STORAGE.DATABASE_SIZE, - cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu, - cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu, - memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory, - memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory, - }, + return createDatabaseCommand({ + userId: session.user.id, + projectId, + databaseName, }) - - logger.info(`Database created: ${database.id} for project: ${project.id}`) - - return { success: true, data: database } } /** @@ -108,30 +50,8 @@ export async function deleteDatabase(databaseId: string): Promise> { - logger.info(`Creating project: ${name} for user: ${userId}`) - - let k8sService - let namespace - try { - k8sService = await getK8sServiceForUser(userId) - namespace = k8sService.getDefaultNamespace() - } catch (error) { - if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) { - logger.warn(`Project creation failed - missing kubeconfig for user: ${userId}`) - return { - success: false, - error: 'Please configure your kubeconfig before creating a project', - } - } - throw error - } - - const k8sProjectName = KubernetesUtils.toK8sProjectName(name) - const randomSuffix = KubernetesUtils.generateRandomString() - const ttydAuthToken = generateRandomString(24) - const fileBrowserUsername = `fb-${randomSuffix}` - const fileBrowserPassword = generateRandomString(16) - const sandboxName = `${k8sProjectName}-${randomSuffix}` - - const result = await prisma.$transaction( - async (tx) => { - const project = await tx.project.create({ - data: { - name, - description, - userId, - status: 'CREATING', - githubAppInstallationId: importData?.githubAppInstallationId, - githubRepoId: importData?.githubRepoId, - githubRepoFullName: importData?.githubRepoFullName, - githubRepoDefaultBranch: importData?.githubRepoDefaultBranch, - }, - }) - - const sandbox = await tx.sandbox.create({ - data: { - projectId: project.id, - name: sandboxName, - k8sNamespace: namespace, - sandboxName: sandboxName, - status: 'CREATING', - lockedUntil: null, - runtimeImage: VERSIONS.RUNTIME_IMAGE, - cpuRequest: VERSIONS.RESOURCES.SANDBOX.requests.cpu, - cpuLimit: VERSIONS.RESOURCES.SANDBOX.limits.cpu, - memoryRequest: VERSIONS.RESOURCES.SANDBOX.requests.memory, - memoryLimit: VERSIONS.RESOURCES.SANDBOX.limits.memory, - }, - }) - - await tx.environment.create({ - data: { - projectId: project.id, - key: 'TTYD_ACCESS_TOKEN', - value: ttydAuthToken, - category: EnvironmentCategory.TTYD, - isSecret: true, - }, - }) - - await tx.environment.create({ - data: { - projectId: project.id, - key: 'FILE_BROWSER_USERNAME', - value: fileBrowserUsername, - category: EnvironmentCategory.FILE_BROWSER, - isSecret: false, - }, - }) - - await tx.environment.create({ - data: { - projectId: project.id, - key: 'FILE_BROWSER_PASSWORD', - value: fileBrowserPassword, - category: EnvironmentCategory.FILE_BROWSER, - isSecret: true, - }, - }) - - if (importData?.githubRepoDefaultBranch) { - await createProjectTask(tx, { - projectId: project.id, - sandboxId: sandbox.id, - type: 'CLONE_REPOSITORY', - status: 'WAITING_FOR_PREREQUISITES', - triggerSource: 'USER_ACTION', - payload: { - installationId: importData.installationId, - repoId: importData.githubRepoId, - repoFullName: importData.githubRepoFullName, - defaultBranch: importData.githubRepoDefaultBranch, - }, - maxAttempts: 3, - }) - } - - return { project, sandbox } - }, - { - timeout: 20000, - } - ) - - logger.info(`Project created: ${result.project.id} with sandbox: ${result.sandbox.id}`) - return { success: true, data: result.project } -} - /** * Create a new project with database and sandbox. * @@ -210,27 +34,14 @@ export async function createProject( return { success: false, error: 'Unauthorized' } } - // Validate project name format - const nameValidation = validateProjectName(name) - if (!nameValidation.valid) { - return { success: false, error: nameValidation.error || 'Invalid project name format' } - } - - return createProjectWithSandbox({ + return createProjectCommand({ userId: session.user.id, name, description, }) } -export interface ImportProjectPayload { - installationId: number - repoId: number - repoName: string - repoFullName: string - defaultBranch: string - description?: string -} +export type ImportProjectPayload = CreateProjectFromGitHubCommandInput /** * Create project in import mode. This only creates project + sandbox metadata and returns immediately. @@ -245,44 +56,8 @@ export async function importProjectFromGitHub( return { success: false, error: 'Unauthorized' } } - if (!payload.repoName || !payload.repoFullName || !payload.defaultBranch) { - return { success: false, error: 'Repository metadata is required' } - } - - const nameValidation = validateProjectName(payload.repoName) - if (!nameValidation.valid) { - return { success: false, error: nameValidation.error || 'Invalid project name format' } - } - - const installation = await getInstallationByGitHubId(payload.installationId) - if (!installation || installation.userId !== session.user.id) { - return { success: false, error: 'Installation not found' } - } - - try { - const repos = await listInstallationRepos(installation.installationId) - const matchedRepo = repos.find( - (repo) => repo.id === payload.repoId && repo.full_name === payload.repoFullName - ) - - if (!matchedRepo) { - return { success: false, error: 'Repository not found in selected installation' } - } - } catch (error) { - logger.error(`Failed to verify repository for import: ${error}`) - return { success: false, error: 'Failed to verify repository access' } - } - - return createProjectWithSandbox({ + return createProjectFromGitHubCommand({ userId: session.user.id, - name: payload.repoName, - description: payload.description, - importData: { - githubAppInstallationId: installation.id, - installationId: installation.installationId, - githubRepoId: payload.repoId, - githubRepoFullName: payload.repoFullName, - githubRepoDefaultBranch: payload.defaultBranch, - }, + ...payload, }) } diff --git a/lib/jobs/database/databaseReconcile.ts b/lib/jobs/database/databaseReconcile.ts index d55fd9b..3e051dd 100644 --- a/lib/jobs/database/databaseReconcile.ts +++ b/lib/jobs/database/databaseReconcile.ts @@ -9,7 +9,7 @@ const logger = baseLogger.child({ module: 'lib/jobs/database/databaseReconcile' // Maximum number of databases to process per reconcile cycle const MAX_DATABASES_PER_CYCLE = parseInt(process.env.MAX_DATABASES_PER_RECONCILE || '10', 10) const RECONCILE_INTERVAL_SECONDS = parseInt( - process.env.DATABASE_RECONCILE_INTERVAL_SECONDS || '10', + process.env.DATABASE_RECONCILE_INTERVAL_SECONDS || '11', 10 ) diff --git a/lib/jobs/project-task/projectTaskReconcile.ts b/lib/jobs/project-task/projectTaskReconcile.ts index acf50e2..ab9dfad 100644 --- a/lib/jobs/project-task/projectTaskReconcile.ts +++ b/lib/jobs/project-task/projectTaskReconcile.ts @@ -18,7 +18,7 @@ const logger = baseLogger.child({ module: 'lib/jobs/project-task/projectTaskReco const MAX_TASKS_PER_CYCLE = parseInt(process.env.MAX_PROJECT_TASKS_PER_RECONCILE || '10', 10) const RECONCILE_INTERVAL_SECONDS = parseInt( - process.env.PROJECT_TASK_RECONCILE_INTERVAL_SECONDS || '10', + process.env.PROJECT_TASK_RECONCILE_INTERVAL_SECONDS || '13', 10 ) const EXECUTION_LOCK_DURATION_SECONDS = parseInt( diff --git a/lib/jobs/sandbox/sandboxReconcile.ts b/lib/jobs/sandbox/sandboxReconcile.ts index fc2a4b7..3270eca 100644 --- a/lib/jobs/sandbox/sandboxReconcile.ts +++ b/lib/jobs/sandbox/sandboxReconcile.ts @@ -9,7 +9,7 @@ const logger = baseLogger.child({ module: 'lib/jobs/sandbox/sandboxReconcile' }) // Maximum number of sandboxes to process per reconcile cycle const MAX_SANDBOXES_PER_CYCLE = parseInt(process.env.MAX_SANDBOXES_PER_RECONCILE || '10', 10) const RECONCILE_INTERVAL_SECONDS = parseInt( - process.env.SANDBOX_RECONCILE_INTERVAL_SECONDS || '10', + process.env.SANDBOX_RECONCILE_INTERVAL_SECONDS || '7', 10 ) diff --git a/lib/platform/control/commands/database/create-database.ts b/lib/platform/control/commands/database/create-database.ts new file mode 100644 index 0000000..31107db --- /dev/null +++ b/lib/platform/control/commands/database/create-database.ts @@ -0,0 +1,76 @@ +import type { Database } from '@prisma/client' + +import { prisma } from '@/lib/db' +import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' +import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils' +import { VERSIONS } from '@/lib/k8s/versions' +import { logger as baseLogger } from '@/lib/logger' +import { CommandResult } from '@/lib/platform/control/types' + +const logger = baseLogger.child({ + module: 'platform/control/commands/database/create-database', +}) + +/** + * Creates the database control-plane record for an existing project. + */ +export async function createDatabaseCommand(input: { + userId: string + projectId: string + databaseName?: string +}): Promise> { + const project = await prisma.project.findUnique({ + where: { id: input.projectId }, + include: { databases: true }, + }) + + if (!project) { + return { success: false, error: 'Project not found' } + } + + if (project.userId !== input.userId) { + return { success: false, error: 'Unauthorized' } + } + + if (project.databases.length > 0) { + return { success: false, error: 'Database already exists for this project' } + } + + let namespace: string + try { + const k8sService = await getK8sServiceForUser(input.userId) + namespace = k8sService.getDefaultNamespace() + } catch (error) { + if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) { + return { + success: false, + error: 'Please configure your kubeconfig before creating a database', + } + } + throw error + } + + const k8sProjectName = KubernetesUtils.toK8sProjectName(project.name) + const randomSuffix = KubernetesUtils.generateRandomString() + const finalDatabaseName = input.databaseName || `${k8sProjectName}-db-${randomSuffix}` + + const database = await prisma.database.create({ + data: { + projectId: project.id, + name: finalDatabaseName, + k8sNamespace: namespace, + databaseName: finalDatabaseName, + status: 'CREATING', + lockedUntil: null, + storageSize: VERSIONS.STORAGE.DATABASE_SIZE, + cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu, + cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu, + memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory, + memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory, + }, + }) + + logger.info(`Database created: ${database.id} for project: ${project.id}`) + + return { success: true, data: database } +} diff --git a/lib/platform/control/commands/database/delete-database.ts b/lib/platform/control/commands/database/delete-database.ts new file mode 100644 index 0000000..8393c11 --- /dev/null +++ b/lib/platform/control/commands/database/delete-database.ts @@ -0,0 +1,40 @@ +import { prisma } from '@/lib/db' +import { logger as baseLogger } from '@/lib/logger' +import { CommandResult } from '@/lib/platform/control/types' + +const logger = baseLogger.child({ + module: 'platform/control/commands/database/delete-database', +}) + +/** + * Marks a database for deletion so reconciliation can perform the external cleanup. + */ +export async function deleteDatabaseCommand(input: { + userId: string + databaseId: string +}): Promise> { + const database = await prisma.database.findUnique({ + where: { id: input.databaseId }, + include: { project: true }, + }) + + if (!database) { + return { success: false, error: 'Database not found' } + } + + if (database.project.userId !== input.userId) { + return { success: false, error: 'Unauthorized' } + } + + await prisma.database.update({ + where: { id: input.databaseId }, + data: { + status: 'TERMINATING', + lockedUntil: null, + }, + }) + + logger.info(`Database ${input.databaseId} marked for deletion`) + + return { success: true, data: undefined } +} diff --git a/lib/platform/control/commands/database/index.ts b/lib/platform/control/commands/database/index.ts new file mode 100644 index 0000000..a6d8556 --- /dev/null +++ b/lib/platform/control/commands/database/index.ts @@ -0,0 +1,2 @@ +export { createDatabaseCommand } from './create-database' +export { deleteDatabaseCommand } from './delete-database' diff --git a/lib/platform/control/commands/database/readme.md b/lib/platform/control/commands/database/readme.md new file mode 100644 index 0000000..9ae32a4 --- /dev/null +++ b/lib/platform/control/commands/database/readme.md @@ -0,0 +1,6 @@ +## lib/platform/control/commands/database + +This directory contains database-related control-plane commands. + +- `create-database.ts`: create the database control-plane record +- `delete-database.ts`: mark the database for deletion diff --git a/lib/platform/control/commands/project/create-project-from-github.ts b/lib/platform/control/commands/project/create-project-from-github.ts new file mode 100644 index 0000000..e80bd38 --- /dev/null +++ b/lib/platform/control/commands/project/create-project-from-github.ts @@ -0,0 +1,71 @@ +import type { Project } from '@prisma/client' + +import { logger as baseLogger } from '@/lib/logger' +import { CommandResult } from '@/lib/platform/control/types' +import { getInstallationByGitHubId } from '@/lib/repo/github' +import { listInstallationRepos } from '@/lib/services/github-app' + +import { createProjectWithSandbox, validateProjectName } from './shared' + +const logger = baseLogger.child({ + module: 'platform/control/commands/project/create-project-from-github', +}) + +export interface CreateProjectFromGitHubCommandInput { + installationId: number + repoId: number + repoName: string + repoFullName: string + defaultBranch: string + description?: string +} + +/** + * Creates the control-plane state for a GitHub import flow after repository ownership is verified. + */ +export async function createProjectFromGitHubCommand( + input: { + userId: string + } & CreateProjectFromGitHubCommandInput +): Promise> { + if (!input.repoName || !input.repoFullName || !input.defaultBranch) { + return { success: false, error: 'Repository metadata is required' } + } + + const nameValidation = validateProjectName(input.repoName) + if (!nameValidation.valid) { + return { success: false, error: nameValidation.error || 'Invalid project name format' } + } + + const installation = await getInstallationByGitHubId(input.installationId) + if (!installation || installation.userId !== input.userId) { + return { success: false, error: 'Installation not found' } + } + + try { + const repos = await listInstallationRepos(installation.installationId) + const matchedRepo = repos.find( + (repo) => repo.id === input.repoId && repo.full_name === input.repoFullName + ) + + if (!matchedRepo) { + return { success: false, error: 'Repository not found in selected installation' } + } + } catch (error) { + logger.error(`Failed to verify repository for import: ${error}`) + return { success: false, error: 'Failed to verify repository access' } + } + + return createProjectWithSandbox({ + userId: input.userId, + name: input.repoName, + description: input.description, + importData: { + githubAppInstallationId: installation.id, + installationId: installation.installationId, + githubRepoId: input.repoId, + githubRepoFullName: input.repoFullName, + githubRepoDefaultBranch: input.defaultBranch, + }, + }) +} diff --git a/lib/platform/control/commands/project/create-project.ts b/lib/platform/control/commands/project/create-project.ts new file mode 100644 index 0000000..9f0a481 --- /dev/null +++ b/lib/platform/control/commands/project/create-project.ts @@ -0,0 +1,25 @@ +import type { Project } from '@prisma/client' + +import { CommandResult } from '@/lib/platform/control/types' + +import { createProjectWithSandbox, validateProjectName } from './shared' + +/** + * Creates a blank project and persists the initial sandbox state for later reconciliation. + */ +export async function createProjectCommand(input: { + userId: string + name: string + description?: string +}): Promise> { + const nameValidation = validateProjectName(input.name) + if (!nameValidation.valid) { + return { success: false, error: nameValidation.error || 'Invalid project name format' } + } + + return createProjectWithSandbox({ + userId: input.userId, + name: input.name, + description: input.description, + }) +} diff --git a/lib/platform/control/commands/project/index.ts b/lib/platform/control/commands/project/index.ts new file mode 100644 index 0000000..9985954 --- /dev/null +++ b/lib/platform/control/commands/project/index.ts @@ -0,0 +1,5 @@ +export { createProjectCommand } from './create-project' +export { + createProjectFromGitHubCommand, + type CreateProjectFromGitHubCommandInput, +} from './create-project-from-github' diff --git a/lib/platform/control/commands/project/readme.md b/lib/platform/control/commands/project/readme.md new file mode 100644 index 0000000..8a99f42 --- /dev/null +++ b/lib/platform/control/commands/project/readme.md @@ -0,0 +1,7 @@ +## lib/platform/control/commands/project + +This directory contains project-related control-plane commands. + +- `create-project.ts`: create a blank project and its initial sandbox state +- `create-project-from-github.ts`: create project state for a GitHub import flow +- `shared.ts`: private helpers shared by project commands in this directory diff --git a/lib/platform/control/commands/project/shared.ts b/lib/platform/control/commands/project/shared.ts new file mode 100644 index 0000000..7e0179c --- /dev/null +++ b/lib/platform/control/commands/project/shared.ts @@ -0,0 +1,176 @@ +import type { Project } from '@prisma/client' + +import { EnvironmentCategory } from '@/lib/const' +import { prisma } from '@/lib/db' +import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' +import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils' +import { VERSIONS } from '@/lib/k8s/versions' +import { logger as baseLogger } from '@/lib/logger' +import { CommandResult } from '@/lib/platform/control/types' +import { createProjectTask } from '@/lib/repo/project-task' +import { generateRandomString } from '@/lib/util/common' + +const logger = baseLogger.child({ module: 'platform/control/commands/project/shared' }) + +export type CreateProjectWithSandboxOptions = { + userId: string + name: string + description?: string + importData?: { + githubAppInstallationId: string + installationId: number + githubRepoId: number + githubRepoFullName: string + githubRepoDefaultBranch?: string + } +} + +/** + * Validates the project display name before the control layer persists any state. + */ +export function validateProjectName(name: string): { valid: boolean; error?: string } { + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Project name cannot be empty' } + } + + const allowedPattern = /^[a-zA-Z0-9\s-]+$/ + if (!allowedPattern.test(name)) { + return { + valid: false, + error: 'Project name can only contain letters, numbers, spaces, and hyphens', + } + } + + const trimmedName = name.trim() + if (!/^[a-zA-Z]/.test(trimmedName)) { + return { valid: false, error: 'Project name must start with a letter' } + } + + if (!/[a-zA-Z]$/.test(trimmedName)) { + return { valid: false, error: 'Project name must end with a letter' } + } + + return { valid: true } +} + +/** + * Creates the initial control-plane records for a project and its primary sandbox. + */ +export async function createProjectWithSandbox({ + userId, + name, + description, + importData, +}: CreateProjectWithSandboxOptions): Promise> { + logger.info(`Creating project: ${name} for user: ${userId}`) + + let namespace: string + try { + const k8sService = await getK8sServiceForUser(userId) + namespace = k8sService.getDefaultNamespace() + } catch (error) { + if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) { + logger.warn(`Project creation failed - missing kubeconfig for user: ${userId}`) + return { + success: false, + error: 'Please configure your kubeconfig before creating a project', + } + } + throw error + } + + const k8sProjectName = KubernetesUtils.toK8sProjectName(name) + const randomSuffix = KubernetesUtils.generateRandomString() + const ttydAuthToken = generateRandomString(24) + const fileBrowserUsername = `fb-${randomSuffix}` + const fileBrowserPassword = generateRandomString(16) + const sandboxName = `${k8sProjectName}-${randomSuffix}` + + const result = await prisma.$transaction( + async (tx) => { + const project = await tx.project.create({ + data: { + name, + description, + userId, + status: 'CREATING', + githubAppInstallationId: importData?.githubAppInstallationId, + githubRepoId: importData?.githubRepoId, + githubRepoFullName: importData?.githubRepoFullName, + githubRepoDefaultBranch: importData?.githubRepoDefaultBranch, + }, + }) + + const sandbox = await tx.sandbox.create({ + data: { + projectId: project.id, + name: sandboxName, + k8sNamespace: namespace, + sandboxName, + status: 'CREATING', + lockedUntil: null, + runtimeImage: VERSIONS.RUNTIME_IMAGE, + cpuRequest: VERSIONS.RESOURCES.SANDBOX.requests.cpu, + cpuLimit: VERSIONS.RESOURCES.SANDBOX.limits.cpu, + memoryRequest: VERSIONS.RESOURCES.SANDBOX.requests.memory, + memoryLimit: VERSIONS.RESOURCES.SANDBOX.limits.memory, + }, + }) + + await tx.environment.create({ + data: { + projectId: project.id, + key: 'TTYD_ACCESS_TOKEN', + value: ttydAuthToken, + category: EnvironmentCategory.TTYD, + isSecret: true, + }, + }) + + await tx.environment.create({ + data: { + projectId: project.id, + key: 'FILE_BROWSER_USERNAME', + value: fileBrowserUsername, + category: EnvironmentCategory.FILE_BROWSER, + isSecret: false, + }, + }) + + await tx.environment.create({ + data: { + projectId: project.id, + key: 'FILE_BROWSER_PASSWORD', + value: fileBrowserPassword, + category: EnvironmentCategory.FILE_BROWSER, + isSecret: true, + }, + }) + + if (importData?.githubRepoDefaultBranch) { + await createProjectTask(tx, { + projectId: project.id, + sandboxId: sandbox.id, + type: 'CLONE_REPOSITORY', + status: 'WAITING_FOR_PREREQUISITES', + triggerSource: 'USER_ACTION', + payload: { + installationId: importData.installationId, + repoId: importData.githubRepoId, + repoFullName: importData.githubRepoFullName, + defaultBranch: importData.githubRepoDefaultBranch, + }, + maxAttempts: 3, + }) + } + + return project + }, + { + timeout: 20000, + } + ) + + logger.info(`Project created: ${result.id}`) + return { success: true, data: result } +} diff --git a/lib/platform/control/commands/readme.md b/lib/platform/control/commands/readme.md new file mode 100644 index 0000000..b1e504c --- /dev/null +++ b/lib/platform/control/commands/readme.md @@ -0,0 +1,12 @@ +## lib/platform/control/commands + +This directory contains write-side control-plane use cases. + +Each command should: + +- accept an already-authenticated intent +- validate command-specific business input +- persist the required state changes +- enqueue follow-up work when needed + +Commands should not contain route handling, UI concerns, cron scheduling, or direct reconciliation loops. diff --git a/lib/platform/control/readme.md b/lib/platform/control/readme.md new file mode 100644 index 0000000..bb650c1 --- /dev/null +++ b/lib/platform/control/readme.md @@ -0,0 +1,7 @@ +## lib/platform/control + +This directory contains control-plane use cases. + +- `commands/`: state-changing application use cases + +Modules here translate user intent into persistent state changes. They decide what records to create or update, but they do not execute long-running external effects directly. diff --git a/lib/platform/control/types.ts b/lib/platform/control/types.ts new file mode 100644 index 0000000..ef4a687 --- /dev/null +++ b/lib/platform/control/types.ts @@ -0,0 +1,6 @@ +/** + * Shared result type for control-plane use cases. + */ +export type CommandResult = + | { success: true; data: T } + | { success: false; error: string } diff --git a/lib/platform/readme.md b/lib/platform/readme.md new file mode 100644 index 0000000..f22c775 --- /dev/null +++ b/lib/platform/readme.md @@ -0,0 +1,12 @@ +## lib/platform + +This directory contains the platform core of Fulling. + +Code here should model the system's main flow: + +- intent +- state +- reconcile +- effect + +Framework adapters such as Next.js pages, route handlers, Server Actions, and Server Component loaders should stay outside this directory and call into it.