diff --git a/AGENTS.md b/AGENTS.md index 66c82cd..7106215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t - Use TypeScript strict mode - Always follow best practices - Write self-documenting code: for complex functions, describe purpose, expected inputs, and expected outputs above the function +- In `lib/platform/`, prefer one primary action per file. Use noun directories and verb file names, and add a short boundary comment above the main exported function covering purpose, expected inputs/preconditions, expected outputs/guarantees, and what is out of scope. - Use functional components with hooks ### Naming Conventions @@ -52,6 +53,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t - Database tables: PascalCase (Prisma models) - API routes: kebab-case - Files: kebab-case +- In `lib/platform/`, file names should usually be action-oriented verbs such as `create-project-with-sandbox`, `find-installation-repository`, or `get-user-default-namespace`, rather than vague names like `shared` or `helpers`. ### Component Organization - **Route-specific components**: Place in `_components/` directory under the route folder diff --git a/docs/architecture-evolution.md b/docs/architecture-evolution.md index b3d457d..065d572 100644 --- a/docs/architecture-evolution.md +++ b/docs/architecture-evolution.md @@ -129,7 +129,8 @@ This layer should be the only place that knows provider-specific protocol detail ## Ideal Repository Shape -The repository should gradually move toward something like this: +The repository should gradually move toward a platform-shaped layout, with +`lib/platform/` acting as the main container for control-plane code: ```text app/ @@ -137,22 +138,23 @@ app/ lib/ domain/ - control/ - commands/ - queries/ - persistence/ - orchestrators/ - resources/ - tasks/ - executors/ - k8s/ - sandbox/ - deploy/ - integrations/ - github/ - ttyd/ - k8s/ - aiproxy/ + platform/ + control/ + commands/ + queries/ + persistence/ + orchestrators/ + resources/ + tasks/ + executors/ + k8s/ + sandbox/ + deploy/ + integrations/ + github/ + ttyd/ + k8s/ + aiproxy/ policies/ shared/ ``` @@ -206,7 +208,7 @@ Typical examples: This is the layer that turns interaction into durable state changes. -### `lib/persistence/` +### `lib/platform/persistence/` Should contain: @@ -215,9 +217,11 @@ Should contain: - lock management - state transition persistence -This corresponds closely to what `lib/repo/` does today, but with a name that better reflects its role in a control-plane system. +This corresponds closely to what `lib/repo/` does today, but relocated under +`platform/` so the control plane and its persistence boundaries live in one +coherent top-level area. -### `lib/orchestrators/` +### `lib/platform/orchestrators/` Should contain: @@ -230,7 +234,7 @@ This layer is currently spread across `jobs/` and `events/`. Long term, the code should make it easy to inspect one workflow in one place, instead of hopping across multiple implementation-mechanism directories. -### `lib/executors/` +### `lib/platform/executors/` Should contain: @@ -247,7 +251,7 @@ The key separation is: - orchestrators decide whether to run - executors perform the work -### `lib/integrations/` +### `lib/platform/integrations/` Should contain: @@ -307,6 +311,46 @@ It is to make the codebase reflect the platform's actual conceptual boundaries: - execution - external integration +### Rule 6: Prefer one primary platform action per file + +Within `lib/platform/`, prefer files that exist to express one primary platform action. + +Examples: + +- `create-project-from-github.ts` +- `create-clone-repository-task.ts` +- `find-installation-repository.ts` +- `get-user-default-namespace.ts` + +This improves scanability. A reader should be able to infer the main responsibility +of a file from its name before opening it. + +### Rule 7: Use noun directories and verb file names + +Within `lib/platform/`: + +- directories should describe the boundary or subsystem +- files should describe the primary action they perform + +Examples: + +- good directory names: `control`, `persistence`, `integrations`, `project-task` +- good file names: `create-project-with-sandbox`, `create-clone-repository-task`, `find-github-installation-by-id` +- weaker file names: `shared`, `github`, `repository-access`, `user-namespace` + +### Rule 8: Primary platform functions must carry boundary comments + +If a file in `lib/platform/` exists to perform one primary action, its main exported +function should have a short comment that explains: + +- purpose +- expected inputs or preconditions +- expected outputs or guarantees +- what is explicitly out of scope + +The goal is not comment volume. The goal is to make the file's architectural role +obvious without reading the full implementation. + ## Suggested Migration Strategy The next phase should be gradual. diff --git a/docs/prds/README.md b/docs/prds/README.md new file mode 100644 index 0000000..03467f0 --- /dev/null +++ b/docs/prds/README.md @@ -0,0 +1,61 @@ +# PRDs + +This directory stores implementation-facing product requirement documents for the +current Fulling codebase. + +## What belongs here + +Put documents here when they define product behavior that directly affects: + +- control-plane state +- UI-visible status semantics +- retry behavior +- persistence requirements +- API or workflow expectations for a concrete feature + +Examples: + +- import project control flow +- deploy project workflow +- install skill behavior +- database creation UX and state semantics + +## What does not belong here + +Do not put high-level strategy, roadmap thinking, or long-range architecture vision here. +Those should stay in the handbook project space. + +Recommended split: + +- `handbook/projects/fulling/` + - vision + - roadmap + - version plans + - postmortems + - architecture direction +- `docs/prds/` + - implementation-facing feature requirements for the repository + +## Naming + +- One PRD per feature or workflow +- Use stable kebab-case file names +- Prefer names like `import-project-control-flow.md` + +## Suggested PRD structure + +Each PRD should usually include: + +1. Goal +2. Scope +3. Success semantics +4. Failure semantics +5. State requirements +6. UI requirements +7. Retry behavior +8. Persistence requirements +9. Non-goals + +## Current PRDs + +- [Import Project Control Flow](./import-project-control-flow.md) diff --git a/docs/prds/import-project-control-flow.md b/docs/prds/import-project-control-flow.md new file mode 100644 index 0000000..95ff540 --- /dev/null +++ b/docs/prds/import-project-control-flow.md @@ -0,0 +1,164 @@ +# Import Project Control Flow + +Status: Draft + +## Goal + +Define the product behavior and control-plane semantics for importing a GitHub +repository as a Fulling project. + +This PRD exists to clarify what "success" means for: + +- project creation +- sandbox creation +- repository cloning +- import failure handling + +## Scope + +This document covers the current import flow for: + +- creating a project from a GitHub repository +- creating the initial sandbox for that project +- cloning the selected repository into the sandbox +- representing clone failure without rolling back the project + +This document does not define future repository analysis, skill installation, or +deploy automation after import. + +## User Intent + +When a user imports a project from GitHub, the system receives two requested outcomes: + +1. Create and start a sandbox for the project +2. Clone the selected GitHub repository into that sandbox + +These two outcomes are related, but they are not treated as a single all-or-nothing +product success condition. + +## Success Semantics + +### Project creation success + +A project is considered successfully created when its sandbox is successfully created +and reaches a runnable state. + +This means: + +- if sandbox creation succeeds, project creation succeeds +- project success is not blocked by repository clone failure + +### Import transaction success + +The import transaction is considered successful only when the repository is cloned +successfully into the sandbox. + +## Failure Semantics + +### Sandbox creation failure + +If the sandbox fails to reach a runnable state, project creation is considered failed. + +### Repository clone failure + +If the sandbox succeeds but repository cloning fails, the system must: + +- keep the project +- keep the sandbox +- mark the import transaction as failed +- preserve the GitHub association metadata on the project + +Clone failure does not roll back the project. + +Examples of clone failure include: + +- repository does not exist +- repository access is denied +- clone operation times out +- GitHub access token or upstream operation fails + +## Current UX Requirements + +For the current stage of the product: + +- the user should still land in a usable project with an empty sandbox +- no dedicated import-failure modal is required yet +- the system should preserve existing code paths as much as possible + +## Status Requirements + +The system should represent two layers of status: + +1. Project resource status +2. Import transaction status + +For the current product behavior: + +- project status may become `RUNNING` +- import may independently become `IMPORT_FAILED` + +The intended current UI meaning is: + +- `RUNNING + IMPORT FAILED` + +This combination means: + +- the sandbox is available +- the project exists and is usable +- the requested repository import did not complete successfully + +## Retry Behavior + +Repository clone should automatically retry up to 3 times, matching the current system behavior. + +Requirements: + +- retries are automatic +- no manual retry UX is required in this phase +- exhausting retries should leave the project intact and mark import as failed + +## Persistence Requirements + +The system must persist enough state to represent: + +- that the project exists +- that the sandbox exists +- that the project was created from GitHub +- that the clone task was attempted +- whether the clone task eventually succeeded or failed + +If clone fails, the database must still clearly reflect: + +- project creation succeeded +- import did not succeed + +## GitHub Metadata Requirements + +If the repository later becomes unavailable or permissions change, the project should +continue to retain its GitHub association metadata. + +This means clone failure or later repository access loss should not automatically clear: + +- GitHub installation reference +- GitHub repository ID +- GitHub repository full name +- default branch metadata + +## Non-Goals + +This PRD does not define: + +- a new import intent model +- a dedicated import failure modal +- post-import repository analysis +- skill installation after import +- deployment after import +- new manual retry workflows + +## Implementation Notes + +Current implementation should preserve this product contract: + +- project creation success is anchored to sandbox success +- clone failure is visible as an import failure, not as project creation failure +- import logic may fail independently after the project already exists diff --git a/lib/platform/control/commands/project/create-project-from-github.ts b/lib/platform/control/commands/project/create-project-from-github.ts index e80bd38..5a969f5 100644 --- a/lib/platform/control/commands/project/create-project-from-github.ts +++ b/lib/platform/control/commands/project/create-project-from-github.ts @@ -2,10 +2,13 @@ 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 { findInstallationRepository } from '@/lib/platform/integrations/github/find-installation-repository' +import { getUserDefaultNamespace } from '@/lib/platform/integrations/k8s/get-user-default-namespace' +import { findGitHubInstallationById } from '@/lib/platform/persistence/github/find-github-installation-by-id' +import { createProjectWithSandbox } from '@/lib/platform/persistence/project/create-project-with-sandbox' +import { createCloneRepositoryTask } from '@/lib/platform/persistence/project-task/create-clone-repository-task' -import { createProjectWithSandbox, validateProjectName } from './shared' +import { validateProjectName } from './shared' const logger = baseLogger.child({ module: 'platform/control/commands/project/create-project-from-github', @@ -21,7 +24,17 @@ export interface CreateProjectFromGitHubCommandInput { } /** - * Creates the control-plane state for a GitHub import flow after repository ownership is verified. + * Initializes GitHub import state after ownership and repository access are verified. + * + * Expected inputs: + * - A Fulling user ID plus GitHub installation and repository metadata selected by the user. + * + * Expected outputs: + * - Creates the imported project state and its initial clone task, then returns the project. + * + * Out of scope: + * - Does not execute the repository clone. + * - Does not advance task prerequisites or sandbox lifecycle. */ export async function createProjectFromGitHubCommand( input: { @@ -37,18 +50,19 @@ export async function createProjectFromGitHubCommand( return { success: false, error: nameValidation.error || 'Invalid project name format' } } - const installation = await getInstallationByGitHubId(input.installationId) + const installation = await findGitHubInstallationById(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 - ) + const repo = await findInstallationRepository({ + installationId: installation.installationId, + repoId: input.repoId, + repoFullName: input.repoFullName, + }) - if (!matchedRepo) { + if (!repo) { return { success: false, error: 'Repository not found in selected installation' } } } catch (error) { @@ -56,16 +70,47 @@ export async function createProjectFromGitHubCommand( return { success: false, error: 'Failed to verify repository access' } } - return createProjectWithSandbox({ + let namespace: string + try { + namespace = await getUserDefaultNamespace(input.userId) + } 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 project', + } + } + throw error + } + + const result = await createProjectWithSandbox({ userId: input.userId, + namespace, name: input.repoName, description: input.description, - importData: { + githubSource: { githubAppInstallationId: installation.id, - installationId: installation.installationId, githubRepoId: input.repoId, githubRepoFullName: input.repoFullName, githubRepoDefaultBranch: input.defaultBranch, }, }) + + if (!result.success) { + return result + } + + await createCloneRepositoryTask({ + projectId: result.data.project.id, + sandboxId: result.data.sandbox.id, + installationId: installation.installationId, + repoId: input.repoId, + repoFullName: input.repoFullName, + defaultBranch: input.defaultBranch, + }) + + return { + success: true, + data: result.data.project, + } } diff --git a/lib/platform/control/commands/project/create-project.ts b/lib/platform/control/commands/project/create-project.ts index 9f0a481..f45db87 100644 --- a/lib/platform/control/commands/project/create-project.ts +++ b/lib/platform/control/commands/project/create-project.ts @@ -1,11 +1,23 @@ import type { Project } from '@prisma/client' import { CommandResult } from '@/lib/platform/control/types' +import { getUserDefaultNamespace } from '@/lib/platform/integrations/k8s/get-user-default-namespace' +import { createProjectWithSandbox } from '@/lib/platform/persistence/project/create-project-with-sandbox' -import { createProjectWithSandbox, validateProjectName } from './shared' +import { validateProjectName } from './shared' /** - * Creates a blank project and persists the initial sandbox state for later reconciliation. + * Initializes a blank project after validating the requested project name. + * + * Expected inputs: + * - A Fulling user ID and a valid project name. + * + * Expected outputs: + * - Creates the initial project and sandbox state, then returns the project record. + * + * Out of scope: + * - Does not create project tasks. + * - Does not perform external Kubernetes effects. */ export async function createProjectCommand(input: { userId: string @@ -17,9 +29,32 @@ export async function createProjectCommand(input: { return { success: false, error: nameValidation.error || 'Invalid project name format' } } - return createProjectWithSandbox({ + let namespace: string + try { + namespace = await getUserDefaultNamespace(input.userId) + } 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 project', + } + } + throw error + } + + const result = await createProjectWithSandbox({ userId: input.userId, + namespace, name: input.name, description: input.description, }) + + if (!result.success) { + return result + } + + return { + success: true, + data: result.data.project, + } } diff --git a/lib/platform/control/commands/project/shared.ts b/lib/platform/control/commands/project/shared.ts index 7e0179c..576626b 100644 --- a/lib/platform/control/commands/project/shared.ts +++ b/lib/platform/control/commands/project/shared.ts @@ -1,30 +1,3 @@ -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. */ @@ -52,125 +25,3 @@ export function validateProjectName(name: string): { valid: boolean; error?: str 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/readme.md b/lib/platform/control/readme.md index bb650c1..0ed1305 100644 --- a/lib/platform/control/readme.md +++ b/lib/platform/control/readme.md @@ -1,7 +1,5 @@ -## lib/platform/control +# Control -This directory contains control-plane use cases. +`lib/platform/control/` contains command and query entrypoints that translate user intent into durable control-plane state. -- `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. +This layer decides what should happen. It should not own long-running orchestration or provider-specific protocol details. diff --git a/lib/platform/executors/README.md b/lib/platform/executors/README.md new file mode 100644 index 0000000..98dde8d --- /dev/null +++ b/lib/platform/executors/README.md @@ -0,0 +1,5 @@ +# Executors + +`lib/platform/executors/` contains effectful operations that run only after orchestration has decided work is ready. + +Executors perform work. They should not decide whether work ought to run. diff --git a/lib/platform/executors/deploy/README.md b/lib/platform/executors/deploy/README.md new file mode 100644 index 0000000..cc68be9 --- /dev/null +++ b/lib/platform/executors/deploy/README.md @@ -0,0 +1,3 @@ +# Deploy Executors + +`lib/platform/executors/deploy/` is for deployment actions triggered by the control plane. diff --git a/lib/platform/executors/k8s/README.md b/lib/platform/executors/k8s/README.md new file mode 100644 index 0000000..9cba5dc --- /dev/null +++ b/lib/platform/executors/k8s/README.md @@ -0,0 +1,3 @@ +# Kubernetes Executors + +`lib/platform/executors/k8s/` is for platform-triggered Kubernetes resource actions. diff --git a/lib/platform/executors/sandbox/README.md b/lib/platform/executors/sandbox/README.md new file mode 100644 index 0000000..0dd7adb --- /dev/null +++ b/lib/platform/executors/sandbox/README.md @@ -0,0 +1,3 @@ +# Sandbox Executors + +`lib/platform/executors/sandbox/` is for effectful work executed inside sandboxes, such as cloning repositories or installing skills. diff --git a/lib/platform/integrations/README.md b/lib/platform/integrations/README.md new file mode 100644 index 0000000..52f60dd --- /dev/null +++ b/lib/platform/integrations/README.md @@ -0,0 +1,3 @@ +# Integrations + +`lib/platform/integrations/` isolates provider-specific protocol and transport logic from platform orchestration and domain rules. diff --git a/lib/platform/integrations/aiproxy/README.md b/lib/platform/integrations/aiproxy/README.md new file mode 100644 index 0000000..14e291b --- /dev/null +++ b/lib/platform/integrations/aiproxy/README.md @@ -0,0 +1,3 @@ +# AI Proxy Integrations + +`lib/platform/integrations/aiproxy/` is for AI proxy provider communication and token/config retrieval. diff --git a/lib/platform/integrations/github/README.md b/lib/platform/integrations/github/README.md new file mode 100644 index 0000000..953edeb --- /dev/null +++ b/lib/platform/integrations/github/README.md @@ -0,0 +1,3 @@ +# GitHub Integrations + +`lib/platform/integrations/github/` is for GitHub App APIs, token exchange, and repository metadata access. diff --git a/lib/platform/integrations/github/find-installation-repository.ts b/lib/platform/integrations/github/find-installation-repository.ts new file mode 100644 index 0000000..719a081 --- /dev/null +++ b/lib/platform/integrations/github/find-installation-repository.ts @@ -0,0 +1,27 @@ +import { listInstallationRepos } from '@/lib/services/github-app' + +/** + * Finds a repository within a GitHub App installation by stable repo identity. + * + * Expected inputs: + * - A valid GitHub App installation ID that Fulling can access. + * - The repository ID and full name selected by the user. + * + * Expected outputs: + * - Returns the matching installation repository when accessible, otherwise null. + * + * Out of scope: + * - Does not verify Fulling user ownership of the installation record. + * - Does not persist any control-plane state. + */ +export async function findInstallationRepository(input: { + installationId: number + repoId: number + repoFullName: string +}) { + const repos = await listInstallationRepos(input.installationId) + + return ( + repos.find((repo) => repo.id === input.repoId && repo.full_name === input.repoFullName) ?? null + ) +} diff --git a/lib/platform/integrations/k8s/README.md b/lib/platform/integrations/k8s/README.md new file mode 100644 index 0000000..cd39cfe --- /dev/null +++ b/lib/platform/integrations/k8s/README.md @@ -0,0 +1,3 @@ +# Kubernetes Integrations + +`lib/platform/integrations/k8s/` is for Kubernetes client interactions and provider protocol details. diff --git a/lib/platform/integrations/k8s/get-user-default-namespace.ts b/lib/platform/integrations/k8s/get-user-default-namespace.ts new file mode 100644 index 0000000..b821aa4 --- /dev/null +++ b/lib/platform/integrations/k8s/get-user-default-namespace.ts @@ -0,0 +1,19 @@ +import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' + +/** + * Resolves the default Kubernetes namespace for a Fulling user. + * + * Expected inputs: + * - A valid Fulling user ID with KUBECONFIG already configured. + * + * Expected outputs: + * - Returns the namespace string used for new platform resources. + * + * Out of scope: + * - Does not create Kubernetes resources. + * - Does not persist any control-plane state. + */ +export async function getUserDefaultNamespace(userId: string): Promise { + const k8sService = await getK8sServiceForUser(userId) + return k8sService.getDefaultNamespace() +} diff --git a/lib/platform/integrations/ttyd/README.md b/lib/platform/integrations/ttyd/README.md new file mode 100644 index 0000000..f9e85aa --- /dev/null +++ b/lib/platform/integrations/ttyd/README.md @@ -0,0 +1,3 @@ +# TTYD Integrations + +`lib/platform/integrations/ttyd/` is for terminal transport, command execution, and ttyd-specific protocol handling. diff --git a/lib/platform/orchestrators/README.md b/lib/platform/orchestrators/README.md new file mode 100644 index 0000000..70b32e1 --- /dev/null +++ b/lib/platform/orchestrators/README.md @@ -0,0 +1,5 @@ +# Orchestrators + +`lib/platform/orchestrators/` advances persisted state toward the desired outcome. + +This layer scans control-plane state, evaluates prerequisites, claims runnable work, and determines which transition comes next. diff --git a/lib/platform/orchestrators/resources/README.md b/lib/platform/orchestrators/resources/README.md new file mode 100644 index 0000000..f0b83ee --- /dev/null +++ b/lib/platform/orchestrators/resources/README.md @@ -0,0 +1,3 @@ +# Resource Orchestrators + +`lib/platform/orchestrators/resources/` is for resource-plane reconcile flows such as sandbox and database lifecycle advancement. diff --git a/lib/platform/orchestrators/tasks/README.md b/lib/platform/orchestrators/tasks/README.md new file mode 100644 index 0000000..b4432bb --- /dev/null +++ b/lib/platform/orchestrators/tasks/README.md @@ -0,0 +1,3 @@ +# Task Orchestrators + +`lib/platform/orchestrators/tasks/` is for task-plane reconcile flows such as project task claiming, prerequisite evaluation, and scheduling. diff --git a/lib/platform/persistence/README.md b/lib/platform/persistence/README.md new file mode 100644 index 0000000..96f4d51 --- /dev/null +++ b/lib/platform/persistence/README.md @@ -0,0 +1,5 @@ +# Persistence + +`lib/platform/persistence/` contains database-facing state persistence for the control plane. + +This layer is responsible for durable writes, row claiming, lock management, and persisted state transitions. diff --git a/lib/platform/persistence/github/find-github-installation-by-id.ts b/lib/platform/persistence/github/find-github-installation-by-id.ts new file mode 100644 index 0000000..bdfc465 --- /dev/null +++ b/lib/platform/persistence/github/find-github-installation-by-id.ts @@ -0,0 +1,21 @@ +import { prisma } from '@/lib/db' + +/** + * Loads a persisted GitHub App installation record by GitHub installation ID. + * + * Expected inputs: + * - A GitHub installation ID already known to the control plane. + * + * Expected outputs: + * - Returns the persisted installation record with its owning user, or null. + * + * Out of scope: + * - Does not call GitHub APIs. + * - Does not validate whether a specific repository is accessible. + */ +export async function findGitHubInstallationById(installationId: number) { + return prisma.gitHubAppInstallation.findUnique({ + where: { installationId }, + include: { user: true }, + }) +} diff --git a/lib/platform/persistence/project-task/create-clone-repository-task.ts b/lib/platform/persistence/project-task/create-clone-repository-task.ts new file mode 100644 index 0000000..4b71cf8 --- /dev/null +++ b/lib/platform/persistence/project-task/create-clone-repository-task.ts @@ -0,0 +1,42 @@ +import { prisma } from '@/lib/db' + +/** + * Persists the initial clone-repository task for a GitHub import project. + * + * Expected inputs: + * - A project and sandbox that already exist in persisted control-plane state. + * - Repository metadata required later by the clone executor. + * + * Expected outputs: + * - Creates a ProjectTask record in WAITING_FOR_PREREQUISITES status. + * + * Out of scope: + * - Does not verify GitHub access or ownership. + * - Does not decide whether the task should exist. + * - Does not execute the clone itself. + */ +export async function createCloneRepositoryTask(input: { + projectId: string + sandboxId: string + installationId: number + repoId: number + repoFullName: string + defaultBranch: string +}) { + return prisma.projectTask.create({ + data: { + projectId: input.projectId, + sandboxId: input.sandboxId, + type: 'CLONE_REPOSITORY', + status: 'WAITING_FOR_PREREQUISITES', + triggerSource: 'USER_ACTION', + payload: { + installationId: input.installationId, + repoId: input.repoId, + repoFullName: input.repoFullName, + defaultBranch: input.defaultBranch, + }, + maxAttempts: 3, + }, + }) +} diff --git a/lib/platform/persistence/project/create-project-with-sandbox.ts b/lib/platform/persistence/project/create-project-with-sandbox.ts new file mode 100644 index 0000000..ef640b0 --- /dev/null +++ b/lib/platform/persistence/project/create-project-with-sandbox.ts @@ -0,0 +1,140 @@ +import type { Project } from '@prisma/client' + +import { EnvironmentCategory } from '@/lib/const' +import { prisma } from '@/lib/db' +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 { generateRandomString } from '@/lib/util/common' + +const logger = baseLogger.child({ module: 'platform/persistence/project/create-project-with-sandbox' }) + +export type CreateProjectWithSandboxInput = { + userId: string + namespace: string + name: string + description?: string + githubSource?: { + githubAppInstallationId: string + githubRepoId: number + githubRepoFullName: string + githubRepoDefaultBranch?: string + } +} + +export type CreateProjectWithSandboxData = { + project: Project + sandbox: { + id: string + } +} + +/** + * Persists the initial project, sandbox, and workspace credentials for a new project. + * + * Expected inputs: + * - A validated project name and a namespace already resolved by the control layer. + * - Optional GitHub source metadata that belongs to the project record itself. + * + * Expected outputs: + * - Creates the project, its primary sandbox, and required environment records in one transaction. + * - Returns the created project plus sandbox identity for follow-up state creation. + * + * Out of scope: + * - Does not resolve Kubernetes namespaces. + * - Does not create project tasks. + * - Does not perform any external Kubernetes or GitHub effects. + */ +export async function createProjectWithSandbox({ + userId, + namespace, + name, + description, + githubSource, +}: CreateProjectWithSandboxInput): Promise> { + logger.info(`Creating project: ${name} for user: ${userId}`) + + 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: githubSource?.githubAppInstallationId, + githubRepoId: githubSource?.githubRepoId, + githubRepoFullName: githubSource?.githubRepoFullName, + githubRepoDefaultBranch: githubSource?.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, + }, + }) + + return { + project, + sandbox: { + id: sandbox.id, + }, + } + }, + { + timeout: 20000, + } + ) + + logger.info(`Project created: ${result.project.id}`) + return { success: true, data: result } +} diff --git a/lib/platform/readme.md b/lib/platform/readme.md index f22c775..b24ec7e 100644 --- a/lib/platform/readme.md +++ b/lib/platform/readme.md @@ -1,12 +1,14 @@ -## lib/platform +# Platform -This directory contains the platform core of Fulling. +`lib/platform/` is the main container for Fulling's control-plane code. -Code here should model the system's main flow: +The full system still includes the interaction layer in `app/`, but the platform +area itself is organized into these internal layers: -- intent -- state -- reconcile -- effect +1. Control State Layer: `lib/platform/control/` +2. Persistence Layer: `lib/platform/persistence/` +3. Orchestration Layer: `lib/platform/orchestrators/` +4. Execution Layer: `lib/platform/executors/` +5. Integration Layer: `lib/platform/integrations/` -Framework adapters such as Next.js pages, route handlers, Server Actions, and Server Component loaders should stay outside this directory and call into it. +The interaction layer remains outside `lib/platform/` because it is framework-facing.