feat: establish Git-backed project source foundation

This commit is contained in:
Varin Narula
2026-09-17 19:55:48 +07:00
parent 720dc3df75
commit e16317a984
28 changed files with 2615 additions and 313 deletions

View File

@@ -39,6 +39,18 @@ ROKCET_SSO_CLIENT_SECRET="replace-with-the-same-secret-configured-on-buckets"
ROKCET_SSO_REDIRECT_URI="http://localhost:3000/api/auth/sso/callback"
# Production example: https://rokcet.dev/api/auth/sso/callback
# Rokcet Git source authority. Gitea and Forgejo expose the compatible API used
# by this internal adapter. Omit ROKCET_GIT_API_URL only for local development,
# where Rokcet creates private bare repositories under the configured data root.
ROKCET_GIT_API_URL=""
ROKCET_GIT_HTTP_URL=""
ROKCET_GIT_OWNER="rokcet"
ROKCET_GIT_SERVICE_TOKEN=""
ROKCET_GIT_STORAGE_ROOT="./data/git"
ROKCET_GIT_LOCAL_REMOTE_ROOT="./data/git/remotes"
ROKCET_GIT_MAX_FILES_PER_COMMIT="250"
ROKCET_GIT_MAX_COMMIT_BYTES="10485760"
# Real Vercel deployment integration (server-side only)
# Create a Vercel access token for the account/team that should own Rokcet deployments.
# Never expose this value through VITE_* variables or generated frontend artifacts.

View File

@@ -4,7 +4,7 @@ Rokcet is the guided business-app builder for novices. A user describes the cust
## Run locally
1. Install dependencies: `npm install`
1. Install Node.js 22.5 or newer and dependencies: `npm install`
2. Copy `.env.example` to `.env` and configure the services you want to use.
3. Start Rokcet: `npm run dev`
@@ -24,6 +24,12 @@ Configure provider credentials with `GEMINI_API_KEY`, `OPENAI_API_KEY`, `ANTHROP
Artifact snapshots are created only when a live provider actually returns filename-addressed code deliverables. Normal prose chat does not create synthetic files or snapshots.
## Git-backed project source
Authenticated projects use one private Git repository each. `main` is the reviewed source line, coding sessions use isolated `rokcet/session/<uuid>` branches and worktrees, and every Snapshot records a verified commit SHA. Preview, downloads, chat context, and deployment source are resolved by the server from that repository; the browser is never the source authority.
For local development, leaving `ROKCET_GIT_API_URL` empty creates bare repositories under `ROKCET_GIT_LOCAL_REMOTE_ROOT`. Staging and production must configure the neutral Rokcet Git variables in `.env.example` for a private Gitea- or Forgejo-compatible service over HTTPS. The service token is server-only and must not be embedded in repository URLs or Git configuration.
## Buckets.id integration
Rokcet deliberately keeps the two Buckets MCP/runtime responsibilities separate:
@@ -88,7 +94,7 @@ Use matching values on the two services:
- Rokcet: `APP_URL=https://rokcet.dev`
- Rokcet: `ROKCET_SESSION_SECRET=<independent strong random secret>`
Rokcet's local account/workspace/session records are stored in the SQLite file configured by `ROKCET_DATABASE_PATH`. In production, point this at a durable mounted volume (for example `/data/rokcet.sqlite`). If Rokcet is later run with multiple application replicas, replace this local store with a shared database before horizontally scaling.
Rokcet's current account and product metadata are stored in the SQLite file configured by `ROKCET_DATABASE_PATH` through numbered schema migrations. Project source is not stored in SQLite. PostgreSQL remains the durable production direction; the future database adapter change must preserve project IDs, repository identities, session branches, and Snapshot SHAs. Do not horizontally scale the SQLite-backed control plane.
## Rokcet project contracts and adapters

29
docs/GIT_FOUNDATION.md Normal file
View File

@@ -0,0 +1,29 @@
# Rokcet Git Foundation
Git is the only authoritative project-source store. Rokcet's database holds ownership, repository identity, coding-session coordination, and friendly Snapshot metadata; it does not hold file contents or parallel file-version history.
## Source lifecycle
Each project provisions one private repository with an opaque `p-<uuid>` name. The repository begins with an empty `main` commit. A coding session creates `rokcet/session/<session-uuid>` from the current main SHA and gets a UUID-derived worktree below `ROKCET_GIT_STORAGE_ROOT`. File changes are validated, written without executing repository code, committed, pushed, and pinned at `refs/rokcet/snapshots/<snapshot-uuid>`.
Applying a session fast-forwards main when possible. If main advanced, Rokcet attempts a three-way merge in a separate integration worktree; conflicts leave main and the session unchanged. Restore creates a new commit with the selected Snapshot tree and never rewrites history. Deployments accept a Snapshot ID, require its commit to be reachable from main, and load source from Git on the server.
## Provider boundary
`GitProvider` exposes private-repository provisioning and clone URL resolution. The current HTTP adapter uses the API subset shared by Gitea and Forgejo. Provider names, repository URLs, and credentials are internal. Staging can use the existing Gitea endpoint; a future production Forgejo deployment uses the same Rokcet configuration contract.
Production provisioning is outside Phase 1. Before production, the Git service requires private-network administration, encrypted off-host backups covering repositories plus provider database/configuration, and a documented restore rehearsal. Workspaces must receive repository-scoped credentials rather than the provisioning service token.
## SQLite and future PostgreSQL
Phase 1 adds numbered migrations and normalized `rokcet_projects`, `rokcet_git_repositories`, `rokcet_coding_sessions`, and `rokcet_snapshots` tables. Existing project metadata is extracted once from app-state; pseudo Artifact/Snapshot/session history is discarded. Users, workspaces, encrypted Buckets connections, secrets, and infrastructure metadata retain their project identifiers.
The current store remains synchronous SQLite and therefore single-control-plane-instance. PostgreSQL migration must replace the database adapter and map JSON text to JSONB and ISO timestamps to timezone-aware timestamps. It must preserve primary keys, repository paths/provider IDs, full commit SHAs, Snapshot sequences, branch names, and deployment commit SHAs. Optimistic expected-head checks and Git push leases remain the concurrency authority; PostgreSQL transactions will replace SQLite `BEGIN IMMEDIATE` for Snapshot sequencing. No Rokcet service may query the Buckets database.
## Security invariants
- HTTPS authentication is non-interactive and credentials never enter repository URLs, Git config, API responses, or logs.
- Git is invoked with argument arrays, disabled hooks, a fixed environment, timeouts, and bounded output.
- Absolute/traversal paths, Git metadata, environment/credential files, submodules, duplicate paths, and oversized changes are rejected.
- Generated source may be stored and inspected on the control plane during Phase 1, but is never executed there.
- Preview compilation is the existing request-scoped renderer; package installation, repository scripts, tests, and builds remain outside the Phase 1 Git service.

View File

@@ -0,0 +1,222 @@
# Phase 1 Staging Git — DevOps Handover
## Purpose and scope
This handover covers the staging-only configuration and acceptance smoke test for Rokcet Phase 1 Git Foundation.
In scope:
- configure Rokcet to use the existing staging Gitea service over authenticated HTTPS;
- provide a least-privilege server-side service token;
- verify private repository provisioning and authenticated Git transport;
- run one end-to-end Rokcet project/session/Snapshot smoke test;
- preserve evidence and report pass/fail without disclosing credentials or source.
Out of scope:
- production Forgejo provisioning or reconfiguration;
- public SSH Git access;
- Phase 2 Rokcet Node, workspace scheduling, or code execution;
- production networking, backup infrastructure, or restore rehearsal;
- running customer repository hooks, package managers, builds, or tests on the Git/control-plane hosts.
Implementation branch: `phase/1-git-foundation`.
## Important pre-deployment warning
Phase 1 includes an intentional pre-launch database cutover. On first startup it:
- preserves users, workspaces, projects, project contracts, secrets, Buckets connections, and infrastructure records;
- extracts project definitions into normalized tables;
- removes browser/SQLite pseudo source history, pseudo Snapshots/sessions, and deployment history that has no verified Git commit.
Take a recoverable staging SQLite backup **before starting the new application version**. Stopping or rolling back the application binary does not reconstruct discarded pseudo-history.
Use the team's normal quiesced SQLite backup procedure and record the backup location and checksum. Do not copy an actively written SQLite file with a plain filesystem copy.
## Gitea prerequisites
Use the existing authenticated HTTPS endpoint. SSH is neither required nor approved for this test.
Create or confirm a dedicated Gitea organization/user owner for Rokcet repositories. The configured owner must match `ROKCET_GIT_OWNER`.
Create a dedicated machine/service account and token with only the capabilities needed to:
- inspect repositories under the configured owner;
- create repositories under that owner;
- create **private** repositories;
- read and write repository contents and refs over HTTPS.
For Gitea versions with scoped tokens, grant repository read/write and the minimum organization permission required to create repositories for the configured organization. Do not grant site administration, user administration, package, issue, webhook, or unrelated organization permissions. If the owner is the service account rather than an organization, it must be allowed to create repositories in its own account.
The service account must not be a personal developer account. Set an owner, purpose, creation date, rotation date, and revocation procedure in the secret inventory.
## Required staging configuration
Set these values in the staging secret/configuration system, not in a committed `.env` file:
```dotenv
ROKCET_GIT_API_URL="https://<staging-gitea-host>/api/v1"
ROKCET_GIT_HTTP_URL="https://<staging-gitea-host>"
ROKCET_GIT_OWNER="<rokcet-owner>"
ROKCET_GIT_SERVICE_TOKEN="<secret-token>"
ROKCET_GIT_STORAGE_ROOT="/var/lib/rokcet/git"
ROKCET_GIT_MAX_FILES_PER_COMMIT="250"
ROKCET_GIT_MAX_COMMIT_BYTES="10485760"
```
Do not set `ROKCET_GIT_LOCAL_REMOTE_ROOT` on staging. It is for local development/tests only.
Requirements:
- `ROKCET_GIT_API_URL` ends at the Gitea-compatible `/api/v1` root.
- `ROKCET_GIT_HTTP_URL` is the HTTPS Git web/clone origin without `/api/v1` and without a trailing repository path.
- Neither URL contains a username, token, password, or query credential.
- The Rokcet service user owns and can write `ROKCET_GIT_STORAGE_ROOT`.
- The storage root is persistent, mode `0700`, outside the application source tree, document root, and static asset directories.
- The filesystem has enough space for canonical clones, session worktrees, and temporary integration worktrees.
- Egress from Rokcet to the Gitea HTTPS host is allowed; certificate validation must remain enabled.
- Proxy and application logs redact `Authorization`, cookies, and secret environment values.
Do not expose any of these as `VITE_*` variables. `ROKCET_GIT_SERVICE_TOKEN` is server-only.
## Pre-deployment checks
From the Rokcet staging runtime identity/network namespace, verify:
1. DNS resolves the configured Gitea host.
2. HTTPS certificate validation succeeds without `-k`/insecure mode.
3. `GET <ROKCET_GIT_API_URL>/version` is reachable.
4. An authenticated `GET <ROKCET_GIT_API_URL>/user` identifies the intended service account.
5. The service account can inspect repositories belonging to `ROKCET_GIT_OWNER`.
6. The configured owner can create private repositories and is not at a repository quota.
7. HTTPS Git smart-protocol endpoints are reachable from the Rokcet host.
Do not paste the token into shell history or pass it in a repository URL. Prefer the deployment platform's secret injection and an ephemeral, permission-restricted curl/Git credential configuration for manual checks. Destroy temporary credential files immediately after the check.
## Deployment sequence
1. Put Rokcet staging in the team's normal maintenance/drain state so the SQLite migration does not race active writes.
2. Record the currently deployed revision.
3. Create and verify the pre-migration SQLite backup.
4. Install the Phase 1 application build using Node.js 22.5 or newer.
5. Create the storage root with the Rokcet service account as owner and mode `0700`.
6. Inject the required environment variables and token through the staging secret manager.
7. Restart Rokcet.
8. Confirm startup and migration complete without credential material in logs.
9. Confirm the application health/authentication flow before running the smoke test.
## Provider-level smoke test
Use a unique, clearly temporary repository name such as `phase1-smoke-<UTC timestamp>` under the configured owner.
Verify through the Gitea API/UI:
- repository creation succeeds with `private: true`, `auto_init: false`, and default branch `main`;
- the response reports the repository as private;
- authenticated HTTPS clone/push works with the service token;
- unauthenticated repository API access and clone do not expose repository contents;
- no credential appears in clone URLs, `.git/config`, application/proxy logs, or captured error messages.
Delete only the exact temporary provider-test repository after evidence is captured. Do not delete a repository created by the Rokcet application smoke test; Rokcet metadata must retain its canonical repository.
## Rokcet end-to-end smoke test
Use a dedicated staging test user and workspace.
1. Create a new Rokcet project from the authenticated UI/API.
2. Confirm provisioning transitions to `ready` rather than remaining `provisioning` or `failed`.
3. In Gitea, confirm exactly one new private repository exists beneath `ROKCET_GIT_OWNER` with an opaque `p-...` name.
4. Confirm its `main` branch contains the root commit `Initialize Rokcet project`.
5. Confirm the root commit SHA shown by Gitea matches Rokcet's recorded verified main SHA.
6. Create a coding session. Confirm a `rokcet/session/<session-uuid>` branch is created from the recorded main SHA.
7. Save one harmless source file through Rokcet using the expected session-head SHA.
8. Confirm a real commit is pushed to the session branch and Rokcet records a Snapshot with the exact full SHA.
9. Confirm `refs/rokcet/snapshots/<snapshot-uuid>` resolves to that same commit.
10. Confirm the session Snapshot is previewable but cannot be selected for deployment before apply.
11. Apply the session to `main`. Confirm main advances and the Snapshot becomes reachable from main.
12. Confirm the applied Snapshot is now deployment-eligible. A real external deployment is not required for this Git smoke test.
13. Compare history/diff in Rokcet and confirm it matches Gitea.
14. Create a second harmless edit and restore the first Snapshot. Confirm restore creates a new commit; it must not reset or rewrite published history.
15. Create and discard a second session. Confirm its worktree and session branch are removed only after its Snapshot ref is present.
16. Sign in as a user outside the workspace and confirm project/source/session/Snapshot endpoints deny access.
Do not run repository code, hooks, package installation, builds, or tests during this smoke test.
## Database evidence
Without printing secrets or source contents, confirm:
- `rokcet_schema_migrations` contains the Phase 1 migrations once only;
- the test project has one row in `rokcet_projects`;
- it has one `ready` row in `rokcet_git_repositories` with a provider repository ID and full main SHA;
- its session and Snapshot rows contain full 40–64 character lowercase hexadecimal commit SHAs;
- Snapshot sequence values are unique and increasing within the project;
- any new deployment record requires and stores `commit_sha`.
Restart Rokcet once more and confirm the migrations are repeatable and no duplicate repository or metadata row is created.
## Log and security checks
Search application, reverse-proxy, process-manager, and Gitea logs for the test interval. The following must not appear:
- the service token or any substring distinctive enough to identify it;
- credentials embedded in an HTTPS URL;
- file contents or prompts;
- authorization headers;
- a provider error response containing sensitive internal detail.
Expected operational logs may contain project/session/repository IDs and abbreviated commit SHAs.
Also confirm:
- repository visibility is private;
- hooks are not invoked by Rokcet Git operations;
- the storage root is not served over HTTP;
- worktree paths are UUID-derived rather than user/project names;
- the service account cannot perform unrelated administrative operations.
## Acceptance criteria
The staging gate passes only when all of the following are true:
- private repository provisioning is idempotent;
- HTTPS API, clone, fetch, and push work with the dedicated service token;
- the empty `main` root commit is created and its full SHA is recorded;
- a session branch/worktree, real commit, immutable Snapshot ref, apply, restore, and discard all work;
- unauthenticated and cross-workspace access is denied;
- repository URLs, Git configuration, logs, and responses contain no credential;
- migration and restart are repeatable;
- no production infrastructure or Phase 2 component was changed.
## Failure and rollback procedure
If the Git API is unavailable or authorization fails, Rokcet should retain a retryable project/repository failure state rather than presenting a partially usable project. Capture the normalized Rokcet error code, HTTP status, timestamps, and affected opaque IDs; do not capture the token or source.
For an application rollback:
1. Drain Rokcet staging.
2. Stop the Phase 1 application.
3. Preserve the Phase 1 SQLite database and Git storage root for diagnosis; do not delete repositories or worktrees.
4. Restore the verified pre-migration SQLite backup if the previous application version must be resumed.
5. Restore the previous application revision and configuration.
6. Revoke or rotate the staging service token if credential exposure is suspected.
Do not attempt to reverse the migration manually in place. Do not delete the configured Git owner, its repositories, or the entire storage root as part of rollback.
## Evidence to return to engineering
Return a short report containing:
- deployed application revision and Node.js version;
- Gitea version and sanitized API/HTTP origins;
- configured owner name and service-account identity (never the token);
- token creation/rotation date and a list of granted scopes/permissions;
- backup identifier and checksum;
- smoke-test project, repository, session, and Snapshot IDs;
- abbreviated root/session/main/restore SHAs;
- each acceptance criterion marked pass/fail;
- sanitized relevant log excerpts;
- any failure's timestamp, HTTP status, normalized Rokcet error code, and reproduction step.
Do not include tokens, cookies, authorization headers, complete credential-bearing process environments, prompts, or source contents in the handover report.

69
package-lock.json generated
View File

@@ -9,18 +9,18 @@
"version": "0.0.0",
"dependencies": {
"@google/genai": "^2.4.0",
"@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"jszip": "^3.10.1",
"lucide-react": "^0.546.0",
"monaco-editor": "^0.56.0",
"motion": "^12.23.24",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"@monaco-editor/react": "^4.7.0",
"monaco-editor": "^0.56.0"
"vite": "^6.2.3"
},
"devDependencies": {
"@types/express": "^4.17.21",
@@ -31,6 +31,9 @@
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
},
"engines": {
"node": ">=22.5.0"
}
},
"node_modules/@babel/code-frame": {
@@ -1419,6 +1422,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",

View File

@@ -2,13 +2,17 @@
"name": "react-example",
"private": true,
"version": "0.0.0",
"engines": {
"node": ">=22.5.0"
},
"type": "module",
"scripts": {
"dev": "tsx server.ts",
"build": "vite build && esbuild server.ts --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/server.js",
"build": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build && esbuild server.ts --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/server.js",
"start": "node dist/server.js",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
"lint": "tsc --noEmit",
"test": "node --import tsx --test --test-concurrency=1 tests/**/*.test.ts"
},
"dependencies": {
"@google/genai": "^2.4.0",

View File

@@ -9,6 +9,7 @@ import previewRouter from './routes/preview';
import artifactsRouter from './routes/artifacts';
import tenantRouter from './routes/tenant';
import bucketsRouter from './routes/buckets';
import gitRouter from './routes/git';
import { requireRokcetAuth } from './auth';
import { developerMcpClientMetadata } from './services/bucketsMcpOAuth';
@@ -39,6 +40,7 @@ export function createApp() {
app.use('/api', requireRokcetAuth, llmRouter);
app.use('/api', requireRokcetAuth, artifactsRouter);
app.use('/api', requireRokcetAuth, bucketsRouter);
app.use('/api', requireRokcetAuth, gitRouter);
// Preview compilation accepts generated source and therefore stays behind the
// Rokcet product session. Rendering is request-scoped and serverless-safe.

View File

@@ -5,6 +5,7 @@ const router = Router();
router.post('/artifacts/zip', async (req, res) => {
try {
if (!(req as any).rokcetAuth?.isDemo) return res.status(410).json({ error: 'Artifact payload archives were replaced by Git source archives.' });
const { artifactTitle, versionNumber, files = [] } = req.body;
const zip = new JSZip();

361
server/routes/git.ts Normal file
View File

@@ -0,0 +1,361 @@
import crypto from 'node:crypto';
import { Router } from 'express';
import {
createCodingSessionRecord,
createGitRepositoryRecord,
createProjectRecord,
createSnapshotRecord,
getCodingSessionRecord,
getGitRepositoryRecord,
getProjectRecord,
getSnapshotRecord,
getSnapshotByCommit,
listCodingSessionRecords,
listProjectRecords,
listSnapshotRecords,
updateCodingSessionRecord,
updateGitRepositoryRecord,
updateProjectRecord,
type RokcetProjectRecord,
} from '../services/rokcetDb';
import { getGitProvider } from '../services/gitProvider';
import { gitRepositoryService } from '../services/gitRepository';
import { worktreeService } from '../services/worktreeService';
import { createDefaultProjectContract } from '../../shared/projectContracts';
const router = Router();
function auth(req: any): any {
return req.rokcetAuth;
}
function ownsWorkspace(req: any, workspaceId: string): boolean {
return auth(req).workspaces.some((workspace: any) => workspace.id === workspaceId);
}
function projectForRequest(req: any): RokcetProjectRecord {
const project = getProjectRecord(auth(req).user.id, String(req.params.projectId || ''));
if (!project || !ownsWorkspace(req, project.workspaceId)) throw Object.assign(new Error('Project not found'), { status: 404, code: 'PROJECT_NOT_FOUND' });
return project;
}
function repositoryPath(projectId: string): string {
const owner = process.env.ROKCET_GIT_OWNER?.trim() || 'rokcet';
const opaqueId = crypto.createHash('sha256').update(projectId).digest('hex').slice(0, 32);
return `${owner}/p-${opaqueId}`;
}
function projectJson(project: RokcetProjectRecord) {
const repository = getGitRepositoryRecord(project.id);
return {
id: project.id,
workspaceId: project.workspaceId,
name: project.name,
description: project.description,
tags: project.tags,
contract: project.contract,
buildMode: project.buildMode,
appTemplate: project.appTemplate,
status: project.status,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
repository: repository ? {
status: repository.status,
defaultBranch: repository.defaultBranch,
mainCommitSha: repository.mainCommitSha,
error: repository.errorMessage,
} : null,
};
}
async function provisionProject(project: RokcetProjectRecord): Promise<RokcetProjectRecord> {
const path = repositoryPath(project.id);
const repository = getGitRepositoryRecord(project.id) || createGitRepositoryRecord({ projectId: project.id, repositoryPath: path });
if (repository.status === 'ready' && repository.mainCommitSha) return updateProjectRecord(project.ownerUserId, project.id, { status: 'ready' }) || project;
try {
const provider = getGitProvider();
const provisioned = await provider.provisionPrivateRepository(repository.repositoryPath);
const sha = gitRepositoryService.initialize(repository, provisioned.cloneUrl);
updateGitRepositoryRecord(project.id, {
providerRepositoryId: provisioned.providerRepositoryId,
mainCommitSha: sha,
status: 'ready',
errorMessage: null,
});
return updateProjectRecord(project.ownerUserId, project.id, { status: 'ready' }) || project;
} catch (error: any) {
updateGitRepositoryRecord(project.id, { status: 'failed', errorMessage: String(error?.message || 'Repository provisioning failed').slice(0, 500) });
updateProjectRecord(project.ownerUserId, project.id, { status: 'provisioning_failed' });
throw error;
}
}
function readyRepository(project: RokcetProjectRecord) {
const repository = getGitRepositoryRecord(project.id);
if (!repository || repository.status !== 'ready' || !repository.mainCommitSha) throw Object.assign(new Error('Project repository is not ready'), { status: 409, code: 'REPOSITORY_NOT_READY' });
return repository;
}
function resolveRequestedRef(project: RokcetProjectRecord, value: unknown): string {
const ref = String(value || 'main');
if (ref === 'main') return 'main';
const snapshot = getSnapshotRecord(project.id, ref);
if (snapshot) return snapshot.commitSha;
const session = getCodingSessionRecord(project.id, ref);
if (session) return session.headCommitSha;
throw Object.assign(new Error('Unknown project source reference'), { status: 404, code: 'SOURCE_REF_NOT_FOUND' });
}
function snapshotJson(project: RokcetProjectRecord, snapshot: ReturnType<typeof getSnapshotRecord> extends infer T ? Exclude<T, null> : never) {
const repository = readyRepository(project);
return {
...snapshot,
shortSha: snapshot.commitSha.slice(0, 8),
onMain: gitRepositoryService.isAncestor(repository, snapshot.commitSha, 'main'),
isMainHead: gitRepositoryService.resolveCommit(repository, 'main') === snapshot.commitSha,
};
}
function sendError(res: any, error: any): void {
res.status(error?.status || 500).json({
success: false,
code: error?.code || 'ROKCET_GIT_ERROR',
error: error?.message || 'Rokcet Git operation failed',
conflicts: Array.isArray(error?.conflicts) ? error.conflicts : undefined,
});
}
router.use((req, res, next) => {
if (auth(req).isDemo && req.method !== 'GET') {
return res.status(403).json({ success: false, code: 'DEMO_READ_ONLY', error: 'Demo project source is read only' });
}
next();
});
router.get('/projects', async (req, res) => {
try {
const workspaceId = String(req.query.workspaceId || '');
if (!ownsWorkspace(req, workspaceId)) return res.status(403).json({ success: false, code: 'WORKSPACE_ACCESS_DENIED', error: 'Workspace access denied' });
const projects = listProjectRecords(auth(req).user.id, workspaceId);
for (const project of projects.filter((item) => item.status === 'pending_repository')) {
try { await provisionProject(project); } catch { /* surfaced in repository state */ }
}
res.json({ success: true, projects: listProjectRecords(auth(req).user.id, workspaceId).map(projectJson) });
} catch (error) { sendError(res, error); }
});
router.post('/projects', async (req, res) => {
try {
if (auth(req).isDemo) return res.status(403).json({ success: false, code: 'DEMO_READ_ONLY', error: 'Demo projects are read only' });
const workspaceId = String(req.body?.workspaceId || '');
const name = String(req.body?.name || '').trim();
if (!ownsWorkspace(req, workspaceId)) return res.status(403).json({ success: false, code: 'WORKSPACE_ACCESS_DENIED', error: 'Workspace access denied' });
if (!name || name.length > 160) return res.status(422).json({ success: false, code: 'INVALID_PROJECT_NAME', error: 'A project name of at most 160 characters is required' });
const project = createProjectRecord({
userId: auth(req).user.id,
workspaceId,
name,
description: String(req.body?.description || ''),
tags: ['Rokcet', 'Buckets.id'],
contract: createDefaultProjectContract() as unknown as Record<string, unknown>,
});
const provisioned = await provisionProject(project);
res.status(201).json({ success: true, project: projectJson(provisioned) });
} catch (error) { sendError(res, error); }
});
router.post('/projects/:projectId/repository/retry', async (req, res) => {
try {
const project = projectForRequest(req);
const updated = await provisionProject(project);
res.json({ success: true, project: projectJson(updated) });
} catch (error) { sendError(res, error); }
});
router.patch('/projects/:projectId', (req, res) => {
try {
const project = projectForRequest(req);
const updated = updateProjectRecord(auth(req).user.id, project.id, {
name: typeof req.body?.name === 'string' ? req.body.name : undefined,
description: typeof req.body?.description === 'string' ? req.body.description : undefined,
contract: req.body?.contract && typeof req.body.contract === 'object' ? req.body.contract : undefined,
buildMode: typeof req.body?.buildMode === 'string' ? req.body.buildMode : undefined,
appTemplate: req.body?.appTemplate && typeof req.body.appTemplate === 'object' ? req.body.appTemplate : undefined,
});
res.json({ success: true, project: projectJson(updated!) });
} catch (error) { sendError(res, error); }
});
router.get('/projects/:projectId/source/tree', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const ref = resolveRequestedRef(project, req.query.ref);
const commitSha = gitRepositoryService.resolveCommit(repository, ref);
res.json({ success: true, commitSha, files: gitRepositoryService.listFiles(repository, commitSha) });
} catch (error) { sendError(res, error); }
});
router.get('/projects/:projectId/source/file', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const ref = resolveRequestedRef(project, req.query.ref);
const commitSha = gitRepositoryService.resolveCommit(repository, ref);
const path = String(req.query.path || '');
res.json({ success: true, commitSha, path, content: gitRepositoryService.readFile(repository, commitSha, path) });
} catch (error) { sendError(res, error); }
});
router.get('/projects/:projectId/source/files', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const ref = resolveRequestedRef(project, req.query.ref);
const commitSha = gitRepositoryService.resolveCommit(repository, ref);
res.json({ success: true, commitSha, files: gitRepositoryService.filesAt(repository, commitSha) });
} catch (error) { sendError(res, error); }
});
router.get('/projects/:projectId/source/archive', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const ref = resolveRequestedRef(project, req.query.ref);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="rokcet-${project.id}.zip"`);
res.send(gitRepositoryService.archive(repository, ref));
} catch (error) { sendError(res, error); }
});
router.get('/projects/:projectId/sessions', (req, res) => {
try {
const project = projectForRequest(req);
res.json({ success: true, sessions: listCodingSessionRecords(project.id) });
} catch (error) { sendError(res, error); }
});
router.post('/projects/:projectId/sessions', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const mainSha = gitRepositoryService.resolveCommit(repository, 'main');
const session = createCodingSessionRecord({ projectId: project.id, baseCommitSha: mainSha, title: String(req.body?.title || 'Coding session') });
worktreeService.ensure(repository, session);
res.status(201).json({ success: true, session });
} catch (error) { sendError(res, error); }
});
router.patch('/projects/:projectId/sessions/:sessionId', (req, res) => {
try {
const project = projectForRequest(req);
const session = getCodingSessionRecord(project.id, String(req.params.sessionId || ''));
if (!session) return res.status(404).json({ success: false, code: 'SESSION_NOT_FOUND', error: 'Session not found' });
const updated = updateCodingSessionRecord(project.id, session.id, {
title: typeof req.body?.title === 'string' ? req.body.title : undefined,
messages: Array.isArray(req.body?.messages) ? req.body.messages : undefined,
});
res.json({ success: true, session: updated });
} catch (error) { sendError(res, error); }
});
router.post('/projects/:projectId/sessions/:sessionId/commits', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const session = getCodingSessionRecord(project.id, String(req.params.sessionId || ''));
if (!session || session.status !== 'open') return res.status(404).json({ success: false, code: 'SESSION_NOT_FOUND', error: 'Open session not found' });
const commitSha = gitRepositoryService.commitChanges(repository, session, {
expectedHeadSha: String(req.body?.expectedHeadSha || ''),
files: Array.isArray(req.body?.files) ? req.body.files : [],
summary: String(req.body?.summary || 'Update project source'),
});
const updatedSession = updateCodingSessionRecord(project.id, session.id, { headCommitSha: commitSha });
const snapshotId = crypto.randomUUID();
gitRepositoryService.pinSnapshot(repository, snapshotId, commitSha);
const snapshot = createSnapshotRecord({
id: snapshotId,
projectId: project.id,
commitSha,
sessionId: session.id,
kind: String(req.body?.kind || 'edit'),
summary: String(req.body?.summary || 'Update project source').slice(0, 500),
prompt: typeof req.body?.prompt === 'string' ? req.body.prompt.slice(0, 50_000) : null,
modelMetadata: req.body?.modelMetadata && typeof req.body.modelMetadata === 'object' ? req.body.modelMetadata : {},
createdByUserId: auth(req).user.id,
});
res.status(201).json({ success: true, session: updatedSession, snapshot: snapshotJson(project, snapshot) });
} catch (error) { sendError(res, error); }
});
router.post('/projects/:projectId/sessions/:sessionId/apply', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const session = getCodingSessionRecord(project.id, String(req.params.sessionId || ''));
if (!session || session.status !== 'open') return res.status(404).json({ success: false, code: 'SESSION_NOT_FOUND', error: 'Open session not found' });
const result = gitRepositoryService.applySession(repository, session, String(req.body?.expectedMainSha || ''));
updateGitRepositoryRecord(project.id, { mainCommitSha: result.commitSha });
const updatedSession = updateCodingSessionRecord(project.id, session.id, { status: 'applied', closedAt: new Date().toISOString() });
let snapshot = getSnapshotByCommit(project.id, result.commitSha);
if (!snapshot) {
const snapshotId = crypto.randomUUID();
gitRepositoryService.pinSnapshot(repository, snapshotId, result.commitSha);
snapshot = createSnapshotRecord({
id: snapshotId, projectId: project.id, commitSha: result.commitSha, sessionId: session.id,
kind: 'apply', summary: `Applied ${session.title}`, createdByUserId: auth(req).user.id,
});
}
worktreeService.remove(repository, session);
res.json({ success: true, merged: result.merged, session: updatedSession, snapshot: snapshotJson(project, snapshot) });
} catch (error) { sendError(res, error); }
});
router.post('/projects/:projectId/sessions/:sessionId/discard', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const session = getCodingSessionRecord(project.id, String(req.params.sessionId || ''));
if (!session || session.status !== 'open') return res.status(404).json({ success: false, code: 'SESSION_NOT_FOUND', error: 'Open session not found' });
worktreeService.remove(repository, session);
const updated = updateCodingSessionRecord(project.id, session.id, { status: 'discarded', closedAt: new Date().toISOString() });
res.json({ success: true, session: updated });
} catch (error) { sendError(res, error); }
});
router.get('/projects/:projectId/snapshots', (req, res) => {
try {
const project = projectForRequest(req);
res.json({ success: true, snapshots: listSnapshotRecords(project.id).map((snapshot) => snapshotJson(project, snapshot)) });
} catch (error) { sendError(res, error); }
});
router.get('/projects/:projectId/diff', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const from = resolveRequestedRef(project, req.query.from);
const to = resolveRequestedRef(project, req.query.to);
res.json({ success: true, from, to, ...gitRepositoryService.diff(repository, from, to) });
} catch (error) { sendError(res, error); }
});
router.post('/projects/:projectId/restore', (req, res) => {
try {
const project = projectForRequest(req);
const repository = readyRepository(project);
const target = getSnapshotRecord(project.id, String(req.body?.snapshotId || ''));
if (!target) return res.status(404).json({ success: false, code: 'SNAPSHOT_NOT_FOUND', error: 'Snapshot not found' });
const commitSha = gitRepositoryService.restore(repository, target.commitSha, String(req.body?.expectedMainSha || ''));
updateGitRepositoryRecord(project.id, { mainCommitSha: commitSha });
const snapshotId = crypto.randomUUID();
gitRepositoryService.pinSnapshot(repository, snapshotId, commitSha);
const snapshot = createSnapshotRecord({
id: snapshotId, projectId: project.id, commitSha, kind: 'restore',
summary: `Restore ${target.versionLabel}`, createdByUserId: auth(req).user.id,
});
res.status(201).json({ success: true, snapshot: snapshotJson(project, snapshot) });
} catch (error) { sendError(res, error); }
});
export default router;

View File

@@ -6,7 +6,17 @@ import {
type ConversationMessage,
type ProviderApiKeys,
} from '../services/llmProviders';
import { getBucketsMcpConnection, getLlmSettings } from '../services/rokcetDb';
import {
createSnapshotRecord,
getBucketsMcpConnection,
getCodingSessionRecord,
getGitRepositoryRecord,
getLlmSettings,
getProjectRecord,
updateCodingSessionRecord,
updateProjectRecord,
} from '../services/rokcetDb';
import { gitRepositoryService } from '../services/gitRepository';
import { validateDeploymentFiles } from '../services/projectAdapters';
import { validatePreviewFiles } from '../services/previewEngine';
import {
@@ -56,6 +66,8 @@ function parseRequest(body: any): {
projectId: string;
buildMode: RokcetBuildMode;
appTemplate: RokcetAppTemplateSelection;
sessionId: string;
expectedHeadSha: string;
} {
const {
prompt,
@@ -71,6 +83,8 @@ function parseRequest(body: any): {
projectId = '',
buildMode = 'working_app',
appTemplate = undefined,
sessionId = '',
expectedHeadSha = '',
} = body || {};
if (typeof prompt !== 'string' || !prompt.trim()) throw new Error('A non-empty prompt is required.');
@@ -104,6 +118,8 @@ function parseRequest(body: any): {
projectId: String(projectId || '').trim(),
buildMode: normalizeRokcetBuildMode(buildMode),
appTemplate: normalizeRokcetAppTemplate(appTemplate, String(prompt || '')),
sessionId: String(sessionId || '').trim(),
expectedHeadSha: String(expectedHeadSha || '').trim(),
effectiveKeys: {
geminiApiKey: apiKeys.geminiApiKey || credentials?.apiKeys?.geminiApiKey,
openaiApiKey: apiKeys.openaiApiKey || credentials?.apiKeys?.openaiApiKey,
@@ -113,16 +129,47 @@ function parseRequest(body: any): {
};
}
function authoritativeChatArtifacts(parsed: ReturnType<typeof parseRequest>, auth: any): any[] {
if (auth?.isDemo) return parsed.referencedArtifacts;
const project = getProjectRecord(auth.user.id, parsed.projectId);
const repository = project ? getGitRepositoryRecord(project.id) : null;
const session = project ? getCodingSessionRecord(project.id, parsed.sessionId) : null;
if (!project || !repository || repository.status !== 'ready' || !session) {
throw Object.assign(new Error('A valid project coding session is required'), { status: 409, code: 'GIT_SESSION_REQUIRED' });
}
parsed.projectName = project.name;
parsed.projectContract = project.contract;
parsed.workspaceId = project.workspaceId;
const requestedPaths = new Set(
parsed.referencedArtifacts.flatMap((artifact: any) => Array.isArray(artifact?.files) ? artifact.files.map((file: any) => String(file?.path || '')) : []),
);
const files = gitRepositoryService.filesAt(repository, session.headCommitSha)
.filter((file) => requestedPaths.size === 0 || requestedPaths.has(file.path));
return files.length ? [{ id: 'git-source', title: project.name, type: 'application', versionNumber: 1, files }] : [];
}
router.post('/llm/execute', async (req, res) => {
try {
const parsed = parseRequest(req.body);
const auth = (req as any).rokcetAuth;
const sourceArtifacts = authoritativeChatArtifacts(parsed, auth);
if (!auth?.isDemo) {
const authoritativeProject = getProjectRecord(auth.user.id, parsed.projectId);
if (!authoritativeProject || !auth.workspaces.some((workspace: any) => workspace.id === authoritativeProject.workspaceId)) {
throw Object.assign(new Error('Project not found'), { status: 404, code: 'PROJECT_NOT_FOUND' });
}
parsed.workspaceId = authoritativeProject.workspaceId;
parsed.projectName = authoritativeProject.name;
parsed.projectContract = authoritativeProject.contract;
parsed.buildMode = normalizeRokcetBuildMode(authoritativeProject.buildMode);
parsed.appTemplate = normalizeRokcetAppTemplate(authoritativeProject.appTemplate, parsed.prompt);
}
const storedSettings = auth?.isDemo ? null : getLlmSettings(auth.user.id);
const effectiveKeys = mergeProviderKeys(storedSettings?.apiKeys, parsed.effectiveKeys);
const result = await generateMultiModelResponses(
`CHAT MODE — discussion only. Do not implement, mutate, or emit filename-addressed deliverables. You may inspect supplied project context, explain code, brainstorm, recommend changes, and show short illustrative snippets when useful. If the user asks to implement something, explain that Chat is read-only and tell them to switch to BUILD (or use Build This) to apply changes.\n\nUser message:\n${parsed.prompt}`,
parsed.projectName,
parsed.referencedArtifacts,
sourceArtifacts,
parsed.selectedModels,
effectiveKeys,
parsed.history,
@@ -393,7 +440,23 @@ router.post('/agent/runs', async (req, res) => {
}
const externalEvidence = bucketsGenerationEvidence(bucketsEvidence);
const hasExistingArtifacts = parsed.referencedArtifacts.length > 0;
let gitContext: { project: any; repository: any; session: any } | null = null;
let authoritativeArtifacts = parsed.referencedArtifacts;
if (!auth?.isDemo) {
const project = getProjectRecord(auth.user.id, parsed.projectId);
const repository = project ? getGitRepositoryRecord(project.id) : null;
const session = project ? getCodingSessionRecord(project.id, parsed.sessionId) : null;
if (!project || project.workspaceId !== parsed.workspaceId || !repository || repository.status !== 'ready' || !session || session.status !== 'open') {
throw Object.assign(new Error('A ready project and open coding session are required'), { status: 409, code: 'GIT_SESSION_REQUIRED' });
}
if (session.headCommitSha !== parsed.expectedHeadSha) {
throw Object.assign(new Error('Session head advanced; reload before building'), { status: 409, code: 'STALE_SESSION_HEAD' });
}
const files = gitRepositoryService.filesAt(repository, session.headCommitSha);
authoritativeArtifacts = files.length ? [{ id: 'git-source', title: project.name, type: 'application', versionNumber: 1, files }] : [];
gitContext = { project, repository, session };
}
const hasExistingArtifacts = authoritativeArtifacts.length > 0;
const effectiveTemplate = normalizeRokcetAppTemplate(parsed.appTemplate, parsed.prompt);
const effectiveBuildMode = effectiveRokcetBuildMode(parsed.buildMode, parsed.prompt);
const starterArtifact = hasExistingArtifacts
@@ -403,7 +466,7 @@ router.post('/agent/runs', async (req, res) => {
: buildRokcetStarterArtifact(parsed.projectName, effectiveTemplate, parsed.prompt);
const effectiveReferencedArtifacts = starterArtifact
? [starterArtifact]
: parsed.referencedArtifacts;
: authoritativeArtifacts;
const selectedBuildModeInstruction = buildModeInstruction(effectiveBuildMode);
const scopeSignal = `${selectedBuildModeInstruction}
${hasExistingArtifacts
@@ -546,13 +609,42 @@ Fix the validation failures and return the smallest runnable/previewable result
effectiveTemplate,
);
let committedSnapshot: any = null;
let committedSession: any = null;
if (gitContext) {
const changedFiles = responseDeliverables.flatMap((deliverable: any) => Array.isArray(deliverable?.files) ? deliverable.files : []);
const commitSha = gitRepositoryService.commitChanges(gitContext.repository, gitContext.session, {
expectedHeadSha: parsed.expectedHeadSha,
files: changedFiles.map((file: any) => ({ path: String(file.path || ''), content: String(file.content || '') })),
summary: parsed.prompt,
});
committedSession = updateCodingSessionRecord(gitContext.project.id, gitContext.session.id, { headCommitSha: commitSha });
const snapshotId = randomUUID();
gitRepositoryService.pinSnapshot(gitContext.repository, snapshotId, commitSha);
committedSnapshot = createSnapshotRecord({
id: snapshotId,
projectId: gitContext.project.id,
commitSha,
sessionId: gitContext.session.id,
kind: 'build',
summary: parsed.prompt.slice(0, 500),
prompt: parsed.prompt,
modelMetadata: { modelId: result.primaryModelId, modelsUsed: result.variants.map((variant) => variant.modelId) },
createdByUserId: auth.user.id,
});
updateProjectRecord(auth.user.id, gitContext.project.id, {
contract: effectiveContract as unknown as Record<string, unknown>,
buildMode: effectiveBuildMode,
appTemplate: effectiveTemplate as unknown as Record<string, unknown>,
});
}
finish({
success: true,
runId: `agent_${randomUUID()}`,
response: result.primaryContent,
variants: result.variants,
logs: [...bucketsLogs(bucketsEvidence), ...result.primaryLogs],
deliverables: responseDeliverables,
modelsUsed: result.variants.map((variant) => variant.modelId),
primaryModelId: result.primaryModelId,
modelErrors: result.modelErrors,
@@ -561,6 +653,11 @@ Fix the validation failures and return the smallest runnable/previewable result
buildMode: effectiveBuildMode,
appTemplate: effectiveTemplate,
buckets: bucketsEvidence,
snapshot: committedSnapshot,
session: committedSession,
// Demo fixtures still use disposable deliverables; authenticated projects
// read source from Git and only receive commit metadata.
deliverables: auth?.isDemo ? responseDeliverables : [],
});
} catch (error: any) {
console.error('Error in /api/agent/runs:', error?.message || error);
@@ -605,12 +702,13 @@ router.post('/llm/execute/stream', async (req, res) => {
try {
const auth = (req as any).rokcetAuth;
const sourceArtifacts = authoritativeChatArtifacts(parsed, auth);
const storedSettings = auth?.isDemo ? null : getLlmSettings(auth.user.id);
const effectiveKeys = mergeProviderKeys(storedSettings?.apiKeys, parsed.effectiveKeys);
const result = await generateMultiModelResponses(
`CHAT MODE — discussion only. Do not implement, mutate, or emit filename-addressed deliverables. You may inspect supplied project context, explain code, brainstorm, recommend changes, and show short illustrative snippets when useful. If the user asks to implement something, explain that Chat is read-only and tell them to switch to BUILD (or use Build This) to apply changes.\n\nUser message:\n${parsed.prompt}`,
parsed.projectName,
parsed.referencedArtifacts,
sourceArtifacts,
parsed.selectedModels,
effectiveKeys,
parsed.history,

View File

@@ -1,10 +1,33 @@
import { Router } from 'express';
import { renderPreviewHtmlFromFiles, validatePreviewFiles } from '../services/previewEngine';
import { getCodingSessionRecord, getGitRepositoryRecord, getProjectRecord, getSnapshotRecord } from '../services/rokcetDb';
import { gitRepositoryService } from '../services/gitRepository';
const router = Router();
router.post('/api/preview/render', (req, res) => {
const { files } = req.body || {};
const auth = (req as any).rokcetAuth;
if (auth.isDemo) {
const files = req.body?.files;
const errors = validatePreviewFiles(files);
if (errors.length > 0) return res.status(422).json({ success: false, code: 'PREVIEW_INVALID', errors });
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
return res.send(renderPreviewHtmlFromFiles(files));
}
const projectId = String(req.body?.projectId || '');
const project = getProjectRecord(auth.user.id, projectId);
if (!project || !auth.workspaces.some((workspace: any) => workspace.id === project.workspaceId)) {
return res.status(404).json({ success: false, code: 'PROJECT_NOT_FOUND', error: 'Project not found' });
}
const repository = getGitRepositoryRecord(project.id);
if (!repository || repository.status !== 'ready') return res.status(409).json({ success: false, code: 'REPOSITORY_NOT_READY', error: 'Project repository is not ready' });
const sourceRef = String(req.body?.ref || 'main');
const snapshot = getSnapshotRecord(project.id, sourceRef);
const session = getCodingSessionRecord(project.id, sourceRef);
if (sourceRef !== 'main' && !snapshot && !session) return res.status(404).json({ success: false, code: 'SOURCE_REF_NOT_FOUND', error: 'Source reference not found' });
const ref = snapshot?.commitSha || session?.headCommitSha || 'main';
const files = gitRepositoryService.filesAt(repository, ref);
const errors = validatePreviewFiles(files);
if (errors.length > 0) {
return res.status(422).json({ success: false, code: 'PREVIEW_INVALID', errors });

View File

@@ -19,6 +19,9 @@ import {
getAppState,
getDeploymentRecord,
getLlmSettings,
getGitRepositoryRecord,
getProjectRecord,
getSnapshotRecord,
latestDeploymentForProject,
listDeploymentRecords,
listInfrastructureResourcesForProject,
@@ -37,6 +40,7 @@ import { infrastructureProvidersForDeployment } from '../services/infrastructure
import { deploymentBuildSettings, validateDeploymentFiles } from '../services/projectAdapters';
import { callDeveloperMcpForProject } from '../services/bucketsDeveloperMcp';
import { reserveRokcetProjectDomains } from '../services/rokcetDomains';
import { gitRepositoryService } from '../services/gitRepository';
export const tenantRouter = Router();
@@ -217,8 +221,13 @@ tenantRouter.put('/app-state', (req, res) => {
if (auth.isDemo) return res.status(403).json({ success: false, code: 'DEMO_READ_ONLY', error: 'Demo workspace state is local-only and is not persisted.' });
const state = req.body?.state;
if (!state || typeof state !== 'object' || Array.isArray(state)) return res.status(422).json({ success: false, error: 'State object is required.' });
const preferences = {
activeWorkspaceId: typeof state.activeWorkspaceId === 'string' ? state.activeWorkspaceId : null,
activeProjectId: typeof state.activeProjectId === 'string' ? state.activeProjectId : null,
activeSessionId: typeof state.activeSessionId === 'string' ? state.activeSessionId : null,
};
const requestedRevision = Number(req.body?.revision);
const { record, applied } = upsertAppState(auth.user.id, state, Number.isFinite(requestedRevision) ? requestedRevision : undefined);
const { record, applied } = upsertAppState(auth.user.id, preferences, Number.isFinite(requestedRevision) ? requestedRevision : undefined);
// Do not echo the entire artifact/session tree back on every save. The client
// only needs an acknowledgement and the authoritative revision.
res.json({ success: true, applied, revision: record.revision, updatedAt: record.updatedAt });
@@ -265,6 +274,7 @@ function deploymentJson(record: RokcetDeploymentRecord) {
snapshotId: record.snapshotId,
snapshotLabel: record.snapshotLabel,
snapshotDigest: record.snapshotDigest,
commitSha: record.commitSha,
status: record.status,
providerStatus: record.providerStatus,
providerProjectId: record.providerProjectId,
@@ -543,22 +553,28 @@ tenantRouter.post('/deploy', async (req, res) => {
return res.status(403).json({ success: false, code: 'DEMO_DEPLOY_DISABLED', error: 'Real cloud deployments are disabled for the Rokcet demo account.' });
}
const workspaceId = String(req.body?.workspaceId || '').trim();
const projectId = String(req.body?.projectId || '').trim();
const projectName = String(req.body?.projectName || '').trim();
const snapshotId = String(req.body?.snapshotId || '').trim();
const snapshotLabel = String(req.body?.snapshotLabel || '').trim() || null;
const providerId = String(req.body?.provider || '').trim().toLowerCase();
if (!ownsWorkspace(auth, workspaceId)) {
const project = getProjectRecord(auth.user.id, projectId);
if (!project || !ownsWorkspace(auth, project.workspaceId)) {
return res.status(403).json({ success: false, code: 'WORKSPACE_ACCESS_DENIED', error: 'Workspace does not belong to the authenticated Rokcet account' });
}
if (!projectId || !projectName || !snapshotId || !providerId) {
const workspaceId = project.workspaceId;
const projectName = project.name;
const snapshot = getSnapshotRecord(project.id, snapshotId);
const repository = getGitRepositoryRecord(project.id);
if (!snapshot || !repository || repository.status !== 'ready') {
return res.status(422).json({
success: false,
code: 'DEPLOYMENT_CONTEXT_REQUIRED',
error: 'workspaceId, projectId, projectName, snapshotId and provider are required',
code: 'DEPLOYMENT_SOURCE_REQUIRED',
error: 'A valid Git-backed project Snapshot is required',
});
}
if (!gitRepositoryService.isAncestor(repository, snapshot.commitSha, 'main')) {
return res.status(409).json({ success: false, code: 'SNAPSHOT_NOT_ON_MAIN', error: 'Apply this Snapshot to main before deployment' });
}
const snapshotLabel = snapshot.versionLabel;
const providerId = String((project.contract as any)?.deployment?.provider || '').trim().toLowerCase();
let adapter;
try {
@@ -571,8 +587,8 @@ tenantRouter.post('/deploy', async (req, res) => {
let projectContract;
let buildSettings;
try {
files = adapter.prepareFiles(Array.isArray(req.body?.files) ? req.body.files : []);
projectContract = validateDeploymentFiles(req.body?.projectContract, files);
files = adapter.prepareFiles(gitRepositoryService.filesAt(repository, snapshot.commitSha));
projectContract = validateDeploymentFiles(project.contract, files);
if (projectContract.deployment.provider !== adapter.id) {
const mismatch: any = new Error(`Project contract targets ${projectContract.deployment.provider}, but deployment request selected ${adapter.id}.`);
mismatch.status = 422;
@@ -593,6 +609,7 @@ tenantRouter.post('/deploy', async (req, res) => {
snapshotId,
snapshotLabel,
snapshotDigest,
commitSha: snapshot.commitSha,
provider: adapter.id,
status: 'provisioning',
});

View File

@@ -0,0 +1,127 @@
import path from 'node:path';
import { mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
export interface ProvisionedGitRepository {
providerRepositoryId: string;
repositoryPath: string;
cloneUrl: string;
}
export interface GitProvider {
provisionPrivateRepository(repositoryPath: string): Promise<ProvisionedGitRepository>;
cloneUrl(repositoryPath: string): string;
}
function env(name: string): string {
return process.env[name]?.trim() || '';
}
function normalizedBase(value: string): string {
return value.replace(/\/+$/, '');
}
function providerError(status: number, payload: any, fallback: string): Error {
void payload;
const error: any = new Error(fallback);
error.status = status >= 400 && status < 500 ? status : 502;
error.code = status === 401 || status === 403 ? 'ROKCET_GIT_AUTH_FAILED' : 'ROKCET_GIT_PROVIDER_ERROR';
return error;
}
class GiteaCompatibleGitProvider implements GitProvider {
private readonly apiUrl = normalizedBase(env('ROKCET_GIT_API_URL'));
private readonly httpUrl = normalizedBase(env('ROKCET_GIT_HTTP_URL'));
private readonly owner = env('ROKCET_GIT_OWNER');
private readonly token = env('ROKCET_GIT_SERVICE_TOKEN');
private assertConfigured(): void {
if (!this.apiUrl || !this.httpUrl || !this.owner || !this.token) {
const error: any = new Error('Rokcet Git is not configured');
error.status = 503;
error.code = 'ROKCET_GIT_NOT_CONFIGURED';
throw error;
}
}
cloneUrl(repositoryPath: string): string {
this.assertConfigured();
return `${this.httpUrl}/${repositoryPath}.git`;
}
async provisionPrivateRepository(repositoryPath: string): Promise<ProvisionedGitRepository> {
this.assertConfigured();
const [owner, name] = repositoryPath.split('/');
if (owner !== this.owner || !name) throw new Error('Invalid Rokcet Git repository path');
const lookup = await fetch(`${this.apiUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
headers: { Authorization: `token ${this.token}`, Accept: 'application/json' },
signal: AbortSignal.timeout(15_000),
});
if (lookup.ok) {
const payload: any = await lookup.json();
if (payload?.private !== true) throw providerError(409, {}, 'Existing Rokcet Git repository is not private');
return { providerRepositoryId: String(payload.id), repositoryPath, cloneUrl: this.cloneUrl(repositoryPath) };
}
if (lookup.status !== 404) throw providerError(lookup.status, await lookup.json().catch(() => ({})), 'Unable to inspect Rokcet Git repository');
let response = await fetch(`${this.apiUrl}/orgs/${encodeURIComponent(owner)}/repos`, {
method: 'POST',
headers: {
Authorization: `token ${this.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, private: true, auto_init: false, default_branch: 'main' }),
signal: AbortSignal.timeout(15_000),
});
let payload: any = await response.json().catch(() => ({}));
if (response.status === 404) {
response = await fetch(`${this.apiUrl}/user/repos`, {
method: 'POST',
headers: {
Authorization: `token ${this.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, private: true, auto_init: false, default_branch: 'main' }),
signal: AbortSignal.timeout(15_000),
});
payload = await response.json().catch(() => ({}));
}
if (!response.ok) throw providerError(response.status, payload, 'Unable to provision Rokcet Git repository');
return { providerRepositoryId: String(payload.id), repositoryPath, cloneUrl: this.cloneUrl(repositoryPath) };
}
}
class LocalGitProvider implements GitProvider {
private root(): string {
return path.resolve(env('ROKCET_GIT_LOCAL_REMOTE_ROOT') || path.join(env('ROKCET_GIT_STORAGE_ROOT') || path.join(process.cwd(), 'data', 'git'), 'remotes'));
}
cloneUrl(repositoryPath: string): string {
return path.join(this.root(), `${repositoryPath}.git`);
}
async provisionPrivateRepository(repositoryPath: string): Promise<ProvisionedGitRepository> {
const target = this.cloneUrl(repositoryPath);
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
try {
execFileSync('git', ['init', '--bare', target], { stdio: 'ignore', timeout: 15_000 });
} catch {
// git init is idempotent; an existing valid bare repository is reused.
execFileSync('git', ['rev-parse', '--is-bare-repository'], { cwd: target, stdio: 'ignore', timeout: 5_000 });
}
return { providerRepositoryId: repositoryPath, repositoryPath, cloneUrl: target };
}
}
export function getGitProvider(): GitProvider {
if (env('ROKCET_GIT_API_URL')) return new GiteaCompatibleGitProvider();
if (process.env.NODE_ENV === 'production') {
const error: any = new Error('ROKCET_GIT_API_URL is required in production');
error.status = 503;
error.code = 'ROKCET_GIT_NOT_CONFIGURED';
throw error;
}
return new LocalGitProvider();
}

View File

@@ -0,0 +1,318 @@
import crypto from 'node:crypto';
import path from 'node:path';
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import type { RokcetGitRepositoryRecord } from './rokcetDb';
import { getGitProvider } from './gitProvider';
export interface GitSourceFile { path: string; content: string }
export interface GitTreeEntry { path: string; type: 'file'; size: number }
const SHA_PATTERN = /^[0-9a-f]{40,64}$/;
const FORBIDDEN_NAMES = new Set(['.git', '.env', '.gitmodules', 'node_modules']);
function env(name: string, fallback = ''): string {
return process.env[name]?.trim() || fallback;
}
function storageRoot(): string {
return path.resolve(env('ROKCET_GIT_STORAGE_ROOT', path.join(process.cwd(), 'data', 'git')));
}
function ensureInside(root: string, target: string): string {
const relative = path.relative(root, target);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Unsafe Rokcet Git storage path');
return target;
}
function askPassPath(): string {
const root = storageRoot();
mkdirSync(root, { recursive: true, mode: 0o700 });
const target = path.join(root, '.rokcet-git-askpass.sh');
if (!existsSync(target)) {
writeFileSync(target, '#!/bin/sh\ncase "$1" in *Username*) printf "%s" "oauth2" ;; *) printf "%s" "$ROKCET_GIT_SERVICE_TOKEN" ;; esac\n', { mode: 0o700 });
chmodSync(target, 0o700);
}
return target;
}
function git(cwd: string, args: string[], options?: { input?: string; encoding?: 'buffer' }): string | Buffer {
const output = execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], {
cwd,
input: options?.input,
encoding: options?.encoding === 'buffer' ? 'buffer' : 'utf8',
maxBuffer: 30 * 1024 * 1024,
timeout: 30_000,
env: {
PATH: process.env.PATH,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: askPassPath(),
ROKCET_GIT_SERVICE_TOKEN: env('ROKCET_GIT_SERVICE_TOKEN'),
LANG: 'C',
LC_ALL: 'C',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
return output;
}
function safeSha(value: string): string {
const sha = String(value || '').trim().toLowerCase();
if (!SHA_PATTERN.test(sha)) throw Object.assign(new Error('Invalid Git commit reference'), { status: 422, code: 'INVALID_GIT_REF' });
return sha;
}
export function normalizeSourcePath(value: string): string {
const candidate = String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
if (!candidate || candidate.startsWith('/') || candidate.includes('\0')) throw Object.assign(new Error('Invalid source path'), { status: 422, code: 'INVALID_SOURCE_PATH' });
const segments = candidate.split('/');
if (segments.some((segment) => !segment || segment === '.' || segment === '..' || FORBIDDEN_NAMES.has(segment.toLowerCase()) || /credential|secret/i.test(segment))) {
throw Object.assign(new Error(`Disallowed source path: ${candidate}`), { status: 422, code: 'DISALLOWED_SOURCE_PATH' });
}
return segments.join('/');
}
function sanitizeSummary(value: string): string {
return String(value || 'Update project source').replace(/[\r\n\0]+/g, ' ').trim().slice(0, 160) || 'Update project source';
}
export class GitRepositoryService {
private repositoryRoot(record: RokcetGitRepositoryRecord): string {
return ensureInside(storageRoot(), path.join(storageRoot(), 'repositories', record.id));
}
worktreeRoot(record: RokcetGitRepositoryRecord, key: string): string {
if (!/^[0-9a-f-]{20,64}$/i.test(key)) throw new Error('Invalid worktree key');
return ensureInside(storageRoot(), path.join(storageRoot(), 'worktrees', record.id, key));
}
integrationRoot(record: RokcetGitRepositoryRecord): string {
return ensureInside(storageRoot(), path.join(storageRoot(), 'integrations', record.id));
}
initialize(record: RokcetGitRepositoryRecord, cloneUrl: string): string {
const root = this.repositoryRoot(record);
if (!existsSync(path.join(root, '.git'))) {
mkdirSync(path.dirname(root), { recursive: true, mode: 0o700 });
rmSync(root, { recursive: true, force: true });
git(path.dirname(root), ['clone', cloneUrl, root]);
git(root, ['config', 'user.name', 'Rokcet']);
git(root, ['config', 'user.email', 'source@rokcet.invalid']);
try {
git(root, ['rev-parse', '--verify', 'origin/main^{commit}']);
git(root, ['checkout', '-B', 'main', 'origin/main']);
} catch {
git(root, ['checkout', '--orphan', 'main']);
git(root, ['commit', '--allow-empty', '-m', 'Initialize Rokcet project']);
git(root, ['push', '-u', 'origin', 'main']);
}
}
return this.resolveCommit(record, 'main');
}
ensureClone(record: RokcetGitRepositoryRecord): string {
const root = this.repositoryRoot(record);
if (!existsSync(path.join(root, '.git'))) {
mkdirSync(path.dirname(root), { recursive: true, mode: 0o700 });
git(path.dirname(root), ['clone', getGitProvider().cloneUrl(record.repositoryPath), root]);
git(root, ['config', 'user.name', 'Rokcet']);
git(root, ['config', 'user.email', 'source@rokcet.invalid']);
}
git(root, ['fetch', '--prune', 'origin', '+refs/heads/*:refs/remotes/origin/*', '+refs/rokcet/*:refs/rokcet/*']);
return root;
}
resolveCommit(record: RokcetGitRepositoryRecord, ref: string): string {
const root = this.ensureClone(record);
const allowed = ref === 'main' ? 'origin/main^{commit}' : `${safeSha(ref)}^{commit}`;
return String(git(root, ['rev-parse', '--verify', allowed])).trim().toLowerCase();
}
hasCommit(record: RokcetGitRepositoryRecord, sha: string): boolean {
try { return this.resolveCommit(record, safeSha(sha)) === safeSha(sha); } catch { return false; }
}
listFiles(record: RokcetGitRepositoryRecord, ref: string): GitTreeEntry[] {
const root = this.ensureClone(record);
const sha = ref === 'main' ? this.resolveCommit(record, 'main') : this.resolveCommit(record, ref);
const output = String(git(root, ['ls-tree', '-r', '-l', sha]));
return output.split('\n').filter(Boolean).map((line) => {
const match = line.match(/^(?:100644|100755) blob [0-9a-f]+\s+(\d+)\t(.+)$/);
if (!match) throw new Error('Unsupported Git tree entry');
return { path: match[2], type: 'file' as const, size: Number(match[1]) };
});
}
readFile(record: RokcetGitRepositoryRecord, ref: string, filePath: string): string {
const root = this.ensureClone(record);
const sha = ref === 'main' ? this.resolveCommit(record, 'main') : this.resolveCommit(record, ref);
const safePath = normalizeSourcePath(filePath);
return String(git(root, ['show', `${sha}:${safePath}`]));
}
filesAt(record: RokcetGitRepositoryRecord, ref: string): GitSourceFile[] {
return this.listFiles(record, ref).map((entry) => ({ path: entry.path, content: this.readFile(record, ref, entry.path) }));
}
createSessionWorktree(record: RokcetGitRepositoryRecord, sessionId: string, branchName: string, baseSha: string): string {
const root = this.ensureClone(record);
const target = this.worktreeRoot(record, sessionId);
if (existsSync(target)) return target;
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
try {
git(root, ['show-ref', '--verify', `refs/heads/${branchName}`]);
git(root, ['worktree', 'add', target, branchName]);
} catch {
git(root, ['worktree', 'add', '-b', branchName, target, safeSha(baseSha)]);
}
return target;
}
commitChanges(record: RokcetGitRepositoryRecord, session: { id: string; branchName: string; headCommitSha: string }, input: {
expectedHeadSha: string;
files: Array<{ path: string; content?: string; delete?: boolean }>;
summary: string;
}): string {
if (safeSha(input.expectedHeadSha) !== safeSha(session.headCommitSha)) throw Object.assign(new Error('Session head advanced; reload before saving'), { status: 409, code: 'STALE_SESSION_HEAD' });
const maxFiles = Math.max(1, Number(env('ROKCET_GIT_MAX_FILES_PER_COMMIT', '250')) || 250);
const maxBytes = Math.max(1024, Number(env('ROKCET_GIT_MAX_COMMIT_BYTES', String(10 * 1024 * 1024))) || 10 * 1024 * 1024);
if (!Array.isArray(input.files) || input.files.length === 0 || input.files.length > maxFiles) throw Object.assign(new Error('Invalid source change count'), { status: 422, code: 'INVALID_SOURCE_CHANGES' });
const seen = new Set<string>();
let bytes = 0;
const normalized = input.files.map((file) => {
const filePath = normalizeSourcePath(file.path);
if (seen.has(filePath)) throw Object.assign(new Error(`Duplicate source path: ${filePath}`), { status: 422, code: 'DUPLICATE_SOURCE_PATH' });
seen.add(filePath);
const content = String(file.content || '');
bytes += Buffer.byteLength(content);
return { path: filePath, content, delete: file.delete === true };
});
if (bytes > maxBytes) throw Object.assign(new Error('Source changes exceed the Rokcet limit'), { status: 413, code: 'SOURCE_CHANGES_TOO_LARGE' });
const target = this.createSessionWorktree(record, session.id, session.branchName, session.headCommitSha);
const actualHead = String(git(target, ['rev-parse', 'HEAD'])).trim();
if (actualHead !== session.headCommitSha) throw Object.assign(new Error('Session worktree is stale'), { status: 409, code: 'STALE_SESSION_WORKTREE' });
for (const file of normalized) {
const destination = ensureInside(target, path.resolve(target, file.path));
if (file.delete) rmSync(destination, { force: true });
else {
mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
writeFileSync(destination, file.content, { mode: 0o600, flag: 'w' });
}
}
git(target, ['add', '--all', '--', '.']);
const status = String(git(target, ['status', '--porcelain']));
if (!status.trim()) throw Object.assign(new Error('No source changes to commit'), { status: 409, code: 'NO_SOURCE_CHANGES' });
git(target, ['commit', '-m', sanitizeSummary(input.summary)]);
const commitSha = String(git(target, ['rev-parse', 'HEAD'])).trim().toLowerCase();
git(target, ['push', 'origin', `${session.branchName}:${session.branchName}`]);
return safeSha(commitSha);
}
pinSnapshot(record: RokcetGitRepositoryRecord, snapshotId: string, sha: string): void {
if (!/^[0-9a-f-]{20,64}$/i.test(snapshotId)) throw new Error('Invalid Snapshot id');
const root = this.ensureClone(record);
const ref = `refs/rokcet/snapshots/${snapshotId}`;
git(root, ['update-ref', ref, safeSha(sha)]);
git(root, ['push', 'origin', `${ref}:${ref}`]);
}
diff(record: RokcetGitRepositoryRecord, fromSha: string, toSha: string): { paths: Array<{ status: string; path: string }>; patch: string } {
const root = this.ensureClone(record);
const from = this.resolveCommit(record, fromSha);
const to = this.resolveCommit(record, toSha);
const names = String(git(root, ['diff', '--name-status', '--no-renames', from, to]));
const paths = names.split('\n').filter(Boolean).map((line) => {
const [status, ...parts] = line.split('\t');
return { status, path: parts.join('\t') };
});
const patch = String(git(root, ['diff', '--no-ext-diff', '--no-renames', from, to])).slice(0, 1_000_000);
return { paths, patch };
}
isAncestor(record: RokcetGitRepositoryRecord, ancestor: string, descendant = 'main'): boolean {
const root = this.ensureClone(record);
const left = this.resolveCommit(record, ancestor);
const right = descendant === 'main' ? this.resolveCommit(record, 'main') : this.resolveCommit(record, descendant);
try { git(root, ['merge-base', '--is-ancestor', left, right]); return true; } catch { return false; }
}
applySession(record: RokcetGitRepositoryRecord, session: { id: string; branchName: string; headCommitSha: string }, expectedMainSha: string): { commitSha: string; merged: boolean } {
const root = this.ensureClone(record);
const currentMain = this.resolveCommit(record, 'main');
if (safeSha(expectedMainSha) !== currentMain) throw Object.assign(new Error('Main advanced; reload before applying'), { status: 409, code: 'STALE_MAIN_HEAD' });
const sessionHead = this.resolveCommit(record, session.headCommitSha);
const target = this.integrationRoot(record);
if (existsSync(target)) {
try { git(root, ['worktree', 'remove', '--force', target]); } catch { rmSync(target, { recursive: true, force: true }); }
}
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
const integrationBranch = `rokcet/integration/${crypto.randomUUID()}`;
git(root, ['worktree', 'add', '-b', integrationBranch, target, currentMain]);
try {
let merged = false;
if (this.isAncestor(record, currentMain, sessionHead)) {
git(target, ['merge', '--ff-only', sessionHead]);
} else {
try {
git(target, ['merge', '--no-ff', '-m', sanitizeSummary(`Apply session ${session.id}`), sessionHead]);
merged = true;
} catch {
const conflicts = String(git(target, ['diff', '--name-only', '--diff-filter=U'])).split('\n').filter(Boolean);
git(target, ['merge', '--abort']);
throw Object.assign(new Error('Session conflicts with current main'), { status: 409, code: 'MERGE_CONFLICT', conflicts });
}
}
const commitSha = String(git(target, ['rev-parse', 'HEAD'])).trim().toLowerCase();
git(target, ['push', `--force-with-lease=refs/heads/main:${currentMain}`, 'origin', `${commitSha}:main`]);
return { commitSha: safeSha(commitSha), merged };
} finally {
try { git(root, ['worktree', 'remove', '--force', target]); } catch { rmSync(target, { recursive: true, force: true }); }
try { git(root, ['branch', '-D', integrationBranch]); } catch {}
}
}
restore(record: RokcetGitRepositoryRecord, targetSha: string, expectedMainSha: string): string {
const root = this.ensureClone(record);
const currentMain = this.resolveCommit(record, 'main');
if (safeSha(expectedMainSha) !== currentMain) throw Object.assign(new Error('Main advanced; reload before restoring'), { status: 409, code: 'STALE_MAIN_HEAD' });
const restoreSha = this.resolveCommit(record, targetSha);
const target = this.integrationRoot(record);
if (existsSync(target)) {
try { git(root, ['worktree', 'remove', '--force', target]); } catch { rmSync(target, { recursive: true, force: true }); }
}
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
const branch = `rokcet/restore/${crypto.randomUUID()}`;
git(root, ['worktree', 'add', '-b', branch, target, currentMain]);
try {
git(target, ['read-tree', `--reset`, '-u', restoreSha]);
git(target, ['commit', '--allow-empty', '-m', `Restore Snapshot ${restoreSha.slice(0, 8)}`]);
const commitSha = String(git(target, ['rev-parse', 'HEAD'])).trim().toLowerCase();
git(target, ['push', `--force-with-lease=refs/heads/main:${currentMain}`, 'origin', `${commitSha}:main`]);
return safeSha(commitSha);
} finally {
try { git(root, ['worktree', 'remove', '--force', target]); } catch { rmSync(target, { recursive: true, force: true }); }
try { git(root, ['branch', '-D', branch]); } catch {}
}
}
discardSession(record: RokcetGitRepositoryRecord, session: { id: string; branchName: string }): void {
const root = this.ensureClone(record);
const target = this.worktreeRoot(record, session.id);
if (existsSync(target)) {
try { git(root, ['worktree', 'remove', '--force', target]); } catch { rmSync(target, { recursive: true, force: true }); }
}
try { git(root, ['branch', '-D', session.branchName]); } catch {}
try { git(root, ['push', 'origin', '--delete', session.branchName]); } catch {}
}
archive(record: RokcetGitRepositoryRecord, ref: string): Buffer {
const root = this.ensureClone(record);
const sha = ref === 'main' ? this.resolveCommit(record, 'main') : this.resolveCommit(record, ref);
return git(root, ['archive', '--format=zip', sha], { encoding: 'buffer' }) as Buffer;
}
}
export const gitRepositoryService = new GitRepositoryService();

View File

@@ -49,6 +49,7 @@ export interface RokcetDeploymentRecord {
snapshotId: string;
snapshotLabel: string | null;
snapshotDigest: string;
commitSha: string | null;
provider: string;
status: string;
providerStatus: string | null;
@@ -124,6 +125,65 @@ export interface RokcetAppStateRecord {
updatedAt: string;
}
export interface RokcetProjectRecord {
id: string;
workspaceId: string;
ownerUserId: string;
name: string;
description: string;
tags: string[];
contract: Record<string, unknown>;
buildMode: string;
appTemplate: Record<string, unknown> | null;
status: string;
createdAt: string;
updatedAt: string;
}
export interface RokcetGitRepositoryRecord {
id: string;
projectId: string;
providerKey: string;
providerRepositoryId: string | null;
repositoryPath: string;
defaultBranch: string;
mainCommitSha: string | null;
status: string;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
export interface RokcetCodingSessionRecord {
id: string;
projectId: string;
branchName: string;
baseCommitSha: string;
headCommitSha: string;
worktreeKey: string;
title: string;
messages: unknown[];
status: string;
createdAt: string;
updatedAt: string;
closedAt: string | null;
}
export interface RokcetSnapshotRecord {
id: string;
projectId: string;
commitSha: string;
sequence: number;
versionLabel: string;
sessionId: string | null;
kind: string;
summary: string;
prompt: string | null;
modelMetadata: Record<string, unknown>;
createdByUserId: string;
createdAt: string;
}
export interface RokcetProjectSecretRecord {
userId: string;
workspaceId: string;
@@ -147,7 +207,13 @@ function connection(): DatabaseSync {
db = new DatabaseSync(target);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA foreign_keys = ON;');
db.exec(`
db.exec(`CREATE TABLE IF NOT EXISTS rokcet_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);`);
const baselineApplied = Boolean(db.prepare('SELECT 1 AS applied FROM rokcet_schema_migrations WHERE version = 1').get());
if (!baselineApplied) db.exec(`
CREATE TABLE IF NOT EXISTS rokcet_users (
id TEXT PRIMARY KEY,
buckets_user_id TEXT NOT NULL UNIQUE,
@@ -334,11 +400,145 @@ function connection(): DatabaseSync {
ON rokcet_infrastructure_resources(provider, provider_resource_id);
CREATE INDEX IF NOT EXISTS ix_rokcet_infrastructure_resources_management
ON rokcet_infrastructure_resources(management_provider, management_resource_id);
`);
applySchemaMigrations(db);
return db;
}
function hasColumn(conn: DatabaseSync, table: string, column: string): boolean {
return (conn.prepare(`PRAGMA table_info(${table})`).all() as any[]).some((row) => row.name === column);
}
function applySchemaMigrations(conn: DatabaseSync): void {
const applied = new Set((conn.prepare('SELECT version FROM rokcet_schema_migrations').all() as any[]).map((row) => Number(row.version)));
if (!applied.has(1)) {
conn.prepare('INSERT INTO rokcet_schema_migrations (version, name, applied_at) VALUES (1, ?, ?)')
.run('baseline_existing_schema', new Date().toISOString());
}
if (!applied.has(2)) {
conn.exec('BEGIN IMMEDIATE');
try {
conn.exec(`
CREATE TABLE rokcet_projects (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]',
contract_json TEXT NOT NULL DEFAULT '{}',
build_mode TEXT NOT NULL DEFAULT 'working_app',
app_template_json TEXT,
status TEXT NOT NULL DEFAULT 'provisioning',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(workspace_id) REFERENCES rokcet_workspaces(id) ON DELETE CASCADE,
FOREIGN KEY(owner_user_id) REFERENCES rokcet_users(id) ON DELETE CASCADE
);
CREATE INDEX ix_rokcet_projects_workspace ON rokcet_projects(owner_user_id, workspace_id, created_at);
CREATE TABLE rokcet_git_repositories (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL UNIQUE,
provider_key TEXT NOT NULL,
provider_repository_id TEXT,
repository_path TEXT NOT NULL UNIQUE,
default_branch TEXT NOT NULL DEFAULT 'main',
main_commit_sha TEXT,
status TEXT NOT NULL DEFAULT 'provisioning',
error_message TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(project_id) REFERENCES rokcet_projects(id) ON DELETE CASCADE
);
CREATE TABLE rokcet_coding_sessions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
branch_name TEXT NOT NULL,
base_commit_sha TEXT NOT NULL,
head_commit_sha TEXT NOT NULL,
worktree_key TEXT NOT NULL,
title TEXT NOT NULL,
messages_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
closed_at TEXT,
FOREIGN KEY(project_id) REFERENCES rokcet_projects(id) ON DELETE CASCADE,
UNIQUE(project_id, branch_name)
);
CREATE INDEX ix_rokcet_coding_sessions_project ON rokcet_coding_sessions(project_id, updated_at DESC);
CREATE TABLE rokcet_snapshots (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
commit_sha TEXT NOT NULL,
sequence INTEGER NOT NULL,
version_label TEXT NOT NULL,
session_id TEXT,
kind TEXT NOT NULL,
summary TEXT NOT NULL,
prompt TEXT,
model_metadata_json TEXT NOT NULL DEFAULT '{}',
created_by_user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(project_id) REFERENCES rokcet_projects(id) ON DELETE CASCADE,
FOREIGN KEY(session_id) REFERENCES rokcet_coding_sessions(id) ON DELETE SET NULL,
FOREIGN KEY(created_by_user_id) REFERENCES rokcet_users(id) ON DELETE RESTRICT,
UNIQUE(project_id, commit_sha),
UNIQUE(project_id, sequence),
UNIQUE(project_id, version_label)
);
CREATE INDEX ix_rokcet_snapshots_project_created ON rokcet_snapshots(project_id, created_at DESC);
`);
const appStates = conn.prepare('SELECT user_id, state_json FROM rokcet_app_state').all() as any[];
const insertProject = conn.prepare(`
INSERT OR IGNORE INTO rokcet_projects (
id, workspace_id, owner_user_id, name, description, tags_json, contract_json,
build_mode, app_template_json, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending_repository', ?, ?)
`);
const updateState = conn.prepare('UPDATE rokcet_app_state SET state_json = ?, revision = revision + 1, updated_at = ? WHERE user_id = ?');
for (const row of appStates) {
let state: any = {};
try { state = JSON.parse(String(row.state_json || '{}')); } catch { state = {}; }
const projects = Array.isArray(state?.projects) ? state.projects : [];
for (const project of projects) {
if (!project?.id || !project?.workspaceId || !project?.name) continue;
const now = new Date().toISOString();
insertProject.run(
String(project.id), String(project.workspaceId), String(row.user_id), String(project.name),
String(project.description || ''), JSON.stringify(Array.isArray(project.tags) ? project.tags : []),
JSON.stringify(project.contract || {}), String(project.buildMode || 'working_app'),
project.appTemplate ? JSON.stringify(project.appTemplate) : null,
String(project.createdAt || now), String(project.updatedAt || now),
);
}
const preferences = {
activeWorkspaceId: state?.activeWorkspaceId || null,
activeProjectId: state?.activeProjectId || null,
};
updateState.run(JSON.stringify(preferences), new Date().toISOString(), row.user_id);
}
conn.exec('DELETE FROM rokcet_deployments;');
if (!hasColumn(conn, 'rokcet_deployments', 'commit_sha')) {
conn.exec('ALTER TABLE rokcet_deployments ADD COLUMN commit_sha TEXT;');
}
conn.prepare('INSERT INTO rokcet_schema_migrations (version, name, applied_at) VALUES (2, ?, ?)')
.run('git_foundation', new Date().toISOString());
conn.exec('COMMIT');
} catch (error) {
conn.exec('ROLLBACK');
throw error;
}
}
}
function rowToUser(row: any): RokcetUserRecord {
return {
id: row.id,
@@ -369,6 +569,7 @@ function rowToDeployment(row: any): RokcetDeploymentRecord {
snapshotId: row.snapshot_id,
snapshotLabel: row.snapshot_label || null,
snapshotDigest: row.snapshot_digest,
commitSha: row.commit_sha || null,
provider: row.provider,
status: row.status,
providerStatus: row.provider_status || null,
@@ -631,9 +832,8 @@ export function upsertAppState(
? Math.max(1, Math.trunc(Number(requestedRevision)))
: (existing?.revision || 0) + 1;
// Reject stale/out-of-order browser writes. Large artifact saves can overlap;
// without a revision guard an older request may finish last and erase a newly
// created project or generated artifact set.
// This revision is optimistic concurrency for disposable UI preferences only;
// Git commit SHAs are the separate source-concurrency authority.
if (existing && normalizedRequestedRevision <= existing.revision) {
return { record: existing, applied: false };
}
@@ -885,6 +1085,7 @@ export function createDeploymentRecord(input: {
snapshotId: string;
snapshotLabel?: string | null;
snapshotDigest: string;
commitSha: string;
provider: string;
status?: string;
}): RokcetDeploymentRecord {
@@ -893,12 +1094,13 @@ export function createDeploymentRecord(input: {
connection().prepare(`
INSERT INTO rokcet_deployments (
id, user_id, workspace_id, project_id, project_name, snapshot_id, snapshot_label, snapshot_digest,
commit_sha,
provider, status, provider_status, provider_project_id, provider_project_name, provider_deployment_id,
deployment_url, error_code, error_message, created_at, updated_at, ready_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, NULL)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, NULL)
`).run(
id, input.userId, input.workspaceId, input.projectId, input.projectName, input.snapshotId, input.snapshotLabel || null,
input.snapshotDigest, input.provider, input.status || 'uploading', now, now
input.snapshotDigest, input.commitSha, input.provider, input.status || 'uploading', now, now
);
return getDeploymentRecord(input.userId, id)!;
}
@@ -1075,3 +1277,273 @@ export function upsertInfrastructureResource(input: {
);
return getInfrastructureResourceForProject(input.userId, input.workspaceId, input.projectId, input.resourceType, resourceKey)!;
}
function parseJsonObject(value: unknown): Record<string, unknown> {
try {
const parsed = JSON.parse(String(value || '{}'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function parseJsonArray(value: unknown): unknown[] {
try {
const parsed = JSON.parse(String(value || '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function rowToProject(row: any): RokcetProjectRecord {
return {
id: row.id,
workspaceId: row.workspace_id,
ownerUserId: row.owner_user_id,
name: row.name,
description: row.description || '',
tags: parseJsonArray(row.tags_json).map(String),
contract: parseJsonObject(row.contract_json),
buildMode: row.build_mode || 'working_app',
appTemplate: row.app_template_json ? parseJsonObject(row.app_template_json) : null,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function rowToGitRepository(row: any): RokcetGitRepositoryRecord {
return {
id: row.id,
projectId: row.project_id,
providerKey: row.provider_key,
providerRepositoryId: row.provider_repository_id || null,
repositoryPath: row.repository_path,
defaultBranch: row.default_branch,
mainCommitSha: row.main_commit_sha || null,
status: row.status,
errorMessage: row.error_message || null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function rowToCodingSession(row: any): RokcetCodingSessionRecord {
return {
id: row.id,
projectId: row.project_id,
branchName: row.branch_name,
baseCommitSha: row.base_commit_sha,
headCommitSha: row.head_commit_sha,
worktreeKey: row.worktree_key,
title: row.title,
messages: parseJsonArray(row.messages_json),
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
closedAt: row.closed_at || null,
};
}
function rowToSnapshot(row: any): RokcetSnapshotRecord {
return {
id: row.id,
projectId: row.project_id,
commitSha: row.commit_sha,
sequence: Number(row.sequence),
versionLabel: row.version_label,
sessionId: row.session_id || null,
kind: row.kind,
summary: row.summary,
prompt: row.prompt || null,
modelMetadata: parseJsonObject(row.model_metadata_json),
createdByUserId: row.created_by_user_id,
createdAt: row.created_at,
};
}
export function createProjectRecord(input: {
id?: string;
userId: string;
workspaceId: string;
name: string;
description?: string;
tags?: string[];
contract?: Record<string, unknown>;
buildMode?: string;
appTemplate?: Record<string, unknown> | null;
}): RokcetProjectRecord {
const id = input.id || crypto.randomUUID();
const now = new Date().toISOString();
connection().prepare(`
INSERT INTO rokcet_projects (
id, workspace_id, owner_user_id, name, description, tags_json, contract_json,
build_mode, app_template_json, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'provisioning', ?, ?)
`).run(
id, input.workspaceId, input.userId, input.name.trim(), input.description?.trim() || '',
JSON.stringify(input.tags || []), JSON.stringify(input.contract || {}), input.buildMode || 'working_app',
input.appTemplate ? JSON.stringify(input.appTemplate) : null, now, now,
);
return getProjectRecord(input.userId, id)!;
}
export function getProjectRecord(userId: string, projectId: string): RokcetProjectRecord | null {
const row = connection().prepare('SELECT * FROM rokcet_projects WHERE id = ? AND owner_user_id = ?').get(projectId, userId) as any;
return row ? rowToProject(row) : null;
}
export function listProjectRecords(userId: string, workspaceId: string): RokcetProjectRecord[] {
return (connection().prepare(`
SELECT * FROM rokcet_projects WHERE owner_user_id = ? AND workspace_id = ? ORDER BY created_at
`).all(userId, workspaceId) as any[]).map(rowToProject);
}
export function updateProjectRecord(userId: string, projectId: string, input: Partial<Pick<RokcetProjectRecord, 'name' | 'description' | 'contract' | 'buildMode' | 'appTemplate' | 'status'>>): RokcetProjectRecord | null {
const current = getProjectRecord(userId, projectId);
if (!current) return null;
connection().prepare(`
UPDATE rokcet_projects SET name = ?, description = ?, contract_json = ?, build_mode = ?,
app_template_json = ?, status = ?, updated_at = ? WHERE id = ? AND owner_user_id = ?
`).run(
input.name?.trim() || current.name,
input.description === undefined ? current.description : input.description,
JSON.stringify(input.contract || current.contract),
input.buildMode || current.buildMode,
input.appTemplate === undefined ? (current.appTemplate ? JSON.stringify(current.appTemplate) : null) : (input.appTemplate ? JSON.stringify(input.appTemplate) : null),
input.status || current.status,
new Date().toISOString(), projectId, userId,
);
return getProjectRecord(userId, projectId);
}
export function createGitRepositoryRecord(input: { projectId: string; repositoryPath: string }): RokcetGitRepositoryRecord {
const now = new Date().toISOString();
connection().prepare(`
INSERT INTO rokcet_git_repositories (
id, project_id, provider_key, repository_path, default_branch, status, created_at, updated_at
) VALUES (?, ?, 'rokcet-git-v1', ?, 'main', 'provisioning', ?, ?)
ON CONFLICT(project_id) DO NOTHING
`).run(crypto.randomUUID(), input.projectId, input.repositoryPath, now, now);
return getGitRepositoryRecord(input.projectId)!;
}
export function getGitRepositoryRecord(projectId: string): RokcetGitRepositoryRecord | null {
const row = connection().prepare('SELECT * FROM rokcet_git_repositories WHERE project_id = ?').get(projectId) as any;
return row ? rowToGitRepository(row) : null;
}
export function updateGitRepositoryRecord(projectId: string, input: Partial<Pick<RokcetGitRepositoryRecord, 'providerRepositoryId' | 'mainCommitSha' | 'status' | 'errorMessage'>>): RokcetGitRepositoryRecord | null {
const current = getGitRepositoryRecord(projectId);
if (!current) return null;
connection().prepare(`
UPDATE rokcet_git_repositories SET provider_repository_id = ?, main_commit_sha = ?, status = ?,
error_message = ?, updated_at = ? WHERE project_id = ?
`).run(
input.providerRepositoryId === undefined ? current.providerRepositoryId : input.providerRepositoryId,
input.mainCommitSha === undefined ? current.mainCommitSha : input.mainCommitSha,
input.status || current.status,
input.errorMessage === undefined ? current.errorMessage : input.errorMessage,
new Date().toISOString(), projectId,
);
return getGitRepositoryRecord(projectId);
}
export function createCodingSessionRecord(input: {
id?: string;
projectId: string;
baseCommitSha: string;
title: string;
}): RokcetCodingSessionRecord {
const id = input.id || crypto.randomUUID();
const now = new Date().toISOString();
const branchName = `rokcet/session/${id}`;
connection().prepare(`
INSERT INTO rokcet_coding_sessions (
id, project_id, branch_name, base_commit_sha, head_commit_sha, worktree_key,
title, messages_json, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, '[]', 'open', ?, ?)
`).run(id, input.projectId, branchName, input.baseCommitSha, input.baseCommitSha, id, input.title.trim() || 'Coding session', now, now);
return getCodingSessionRecord(input.projectId, id)!;
}
export function getCodingSessionRecord(projectId: string, sessionId: string): RokcetCodingSessionRecord | null {
const row = connection().prepare('SELECT * FROM rokcet_coding_sessions WHERE id = ? AND project_id = ?').get(sessionId, projectId) as any;
return row ? rowToCodingSession(row) : null;
}
export function listCodingSessionRecords(projectId: string): RokcetCodingSessionRecord[] {
return (connection().prepare('SELECT * FROM rokcet_coding_sessions WHERE project_id = ? ORDER BY updated_at DESC').all(projectId) as any[]).map(rowToCodingSession);
}
export function updateCodingSessionRecord(projectId: string, sessionId: string, input: Partial<Pick<RokcetCodingSessionRecord, 'headCommitSha' | 'title' | 'messages' | 'status' | 'closedAt'>>): RokcetCodingSessionRecord | null {
const current = getCodingSessionRecord(projectId, sessionId);
if (!current) return null;
connection().prepare(`
UPDATE rokcet_coding_sessions SET head_commit_sha = ?, title = ?, messages_json = ?, status = ?,
closed_at = ?, updated_at = ? WHERE id = ? AND project_id = ?
`).run(
input.headCommitSha || current.headCommitSha,
input.title?.trim() || current.title,
JSON.stringify(input.messages || current.messages),
input.status || current.status,
input.closedAt === undefined ? current.closedAt : input.closedAt,
new Date().toISOString(), sessionId, projectId,
);
return getCodingSessionRecord(projectId, sessionId);
}
export function createSnapshotRecord(input: {
id?: string;
projectId: string;
commitSha: string;
sessionId?: string | null;
kind: string;
summary: string;
prompt?: string | null;
modelMetadata?: Record<string, unknown>;
createdByUserId: string;
}): RokcetSnapshotRecord {
const conn = connection();
conn.exec('BEGIN IMMEDIATE');
try {
const row = conn.prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM rokcet_snapshots WHERE project_id = ?').get(input.projectId) as any;
const sequence = Number(row.next_sequence);
const id = input.id || crypto.randomUUID();
conn.prepare(`
INSERT INTO rokcet_snapshots (
id, project_id, commit_sha, sequence, version_label, session_id, kind, summary,
prompt, model_metadata_json, created_by_user_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, input.projectId, input.commitSha, sequence, `v${sequence}`, input.sessionId || null,
input.kind, input.summary, input.prompt || null, JSON.stringify(input.modelMetadata || {}),
input.createdByUserId, new Date().toISOString(),
);
conn.exec('COMMIT');
return getSnapshotRecord(input.projectId, id)!;
} catch (error) {
conn.exec('ROLLBACK');
throw error;
}
}
export function getSnapshotRecord(projectId: string, snapshotId: string): RokcetSnapshotRecord | null {
const row = connection().prepare('SELECT * FROM rokcet_snapshots WHERE id = ? AND project_id = ?').get(snapshotId, projectId) as any;
return row ? rowToSnapshot(row) : null;
}
export function getSnapshotByCommit(projectId: string, commitSha: string): RokcetSnapshotRecord | null {
const row = connection().prepare('SELECT * FROM rokcet_snapshots WHERE project_id = ? AND commit_sha = ?').get(projectId, commitSha) as any;
return row ? rowToSnapshot(row) : null;
}
export function listSnapshotRecords(projectId: string): RokcetSnapshotRecord[] {
return (connection().prepare('SELECT * FROM rokcet_snapshots WHERE project_id = ? ORDER BY sequence DESC').all(projectId) as any[]).map(rowToSnapshot);
}
export function resetRokcetDbForTests(): void {
db?.close();
db = null;
}

View File

@@ -0,0 +1,15 @@
import type { RokcetCodingSessionRecord, RokcetGitRepositoryRecord } from './rokcetDb';
import { gitRepositoryService } from './gitRepository';
export class WorktreeService {
ensure(repository: RokcetGitRepositoryRecord, session: RokcetCodingSessionRecord): string {
if (session.status !== 'open') throw Object.assign(new Error('Coding session is closed'), { status: 409, code: 'SESSION_CLOSED' });
return gitRepositoryService.createSessionWorktree(repository, session.id, session.branchName, session.headCommitSha);
}
remove(repository: RokcetGitRepositoryRecord, session: RokcetCodingSessionRecord): void {
gitRepositoryService.discardSession(repository, session);
}
}
export const worktreeService = new WorktreeService();

View File

@@ -213,6 +213,70 @@ function nextProjectSnapshotNumber(existingSnapshots: SnapshotBinding[], project
return existingSnapshots.filter((snapshot) => snapshotBelongsToProject(snapshot, projectArtifacts)).length + 1;
}
async function fetchGitArtifacts(projectId: string, ref: string): Promise<Artifact[]> {
const sourceResponse = await rokcetFetch(`/api/projects/${encodeURIComponent(projectId)}/source/files?ref=${encodeURIComponent(ref)}`);
const sourcePayload = await sourceResponse.json().catch(() => ({}));
if (!sourceResponse.ok) throw new Error(sourcePayload.error || 'Unable to load project source');
const files = (sourcePayload.files || []).map((file: any) => ({
path: file.path,
content: file.content,
size: new TextEncoder().encode(String(file.content || '')).byteLength,
language: String(file.path).split('.').pop() || 'text',
}));
if (!files.length) return [];
return [{
id: `git-${projectId}`,
projectId,
title: 'Project source',
type: 'frontend',
currentVersion: 1,
updatedAt: new Date().toISOString(),
description: 'Git-backed source tree',
versions: [{ id: sourcePayload.commitSha, versionNumber: 1, createdAt: new Date().toISOString(), summary: 'Git source', files }],
}];
}
async function fetchGitProjectState(projectId: string, preferredSessionId?: string | null): Promise<{
artifacts: Artifact[];
snapshots: SnapshotBinding[];
sessions: Session[];
activeSession: Session | null;
}> {
const [sessionsResponse, snapshotsResponse] = await Promise.all([
rokcetFetch(`/api/projects/${encodeURIComponent(projectId)}/sessions`),
rokcetFetch(`/api/projects/${encodeURIComponent(projectId)}/snapshots`),
]);
const [sessionsPayload, snapshotsPayload] = await Promise.all([
sessionsResponse.json().catch(() => ({})),
snapshotsResponse.json().catch(() => ({})),
]);
if (!sessionsResponse.ok) throw new Error(sessionsPayload.error || 'Unable to load coding sessions');
if (!snapshotsResponse.ok) throw new Error(snapshotsPayload.error || 'Unable to load Snapshots');
const sessions: Session[] = (sessionsPayload.sessions || []).map((session: any) => ({
...session,
referencedArtifacts: [],
messages: Array.isArray(session.messages) ? session.messages : [],
}));
const activeSession = sessions.find((session) => session.id === preferredSessionId && session.status === 'open')
|| sessions.find((session) => session.status === 'open')
|| null;
const ref = activeSession?.id || snapshotsPayload.snapshots?.find((snapshot: any) => snapshot.isMainHead)?.id || 'main';
const artifacts = await fetchGitArtifacts(projectId, ref);
const artifactId = `git-${projectId}`;
const hasFiles = artifacts.length > 0;
const snapshots: SnapshotBinding[] = (snapshotsPayload.snapshots || []).map((snapshot: any) => ({
...snapshot,
commitHash: snapshot.shortSha,
createdByModel: String(snapshot.modelMetadata?.modelId || 'Rokcet'),
provider: 'openai',
branchName: snapshot.onMain ? 'main' : 'session',
isMainBranch: Boolean(snapshot.isMainHead),
boundArtifactVersions: hasFiles ? { [artifactId]: 1 } : {},
boundVersions: hasFiles ? [{ artifactId, versionNumber: 1 }] : [],
}));
return { artifacts, snapshots, sessions, activeSession };
}
export default function App() {
const [workspaces, setWorkspaces] = useState<Workspace[]>(INITIAL_WORKSPACES);
@@ -299,16 +363,24 @@ export default function App() {
: project;
const defaultProjects = INITIAL_PROJECTS.map(bindSampleProjectToWorkspace);
let serverState: any = null;
let serverProjects: Project[] = [];
if (!session.isDemo) {
try {
const response = await rokcetFetch('/api/app-state');
const data = await response.json().catch(() => ({}));
if (response.ok && data.success) {
const [stateResponse, projectsResponse] = await Promise.all([
rokcetFetch('/api/app-state'),
rokcetFetch(`/api/projects?workspaceId=${encodeURIComponent(firstWorkspace.id)}`),
]);
const [data, projectsData] = await Promise.all([
stateResponse.json().catch(() => ({})),
projectsResponse.json().catch(() => ({})),
]);
if (stateResponse.ok && data.success) {
serverState = data.state || null;
appStateRevisionRef.current = Math.max(0, Number(data.revision || 0));
}
if (projectsResponse.ok && projectsData.success) serverProjects = projectsData.projects || [];
} catch (error) {
console.warn('Unable to load DB app state:', error);
console.warn('Unable to load Rokcet project state:', error);
}
}
// Account state is server-authoritative from day one. Rokcet has not launched,
@@ -318,7 +390,7 @@ export default function App() {
const demoSeedChanged = session.isDemo && (storedDemoRevision !== DEMO_SEED_REVISION || storedDemoFingerprint !== DEMO_ARTIFACT_FINGERPRINT);
const cachedProjects = session.isDemo
? loadDemoStorage<Project[]>(demoKey('projects'), defaultProjects)
: Array.isArray(serverState?.projects) ? serverState.projects : [];
: serverProjects;
const loadedProjects = (session.isDemo
? (demoSeedChanged ? defaultProjects : mergeById(defaultProjects, cachedProjects))
: cachedProjects)
@@ -335,7 +407,7 @@ export default function App() {
const cachedArtifacts = session.isDemo
? loadDemoStorage<Artifact[]>(demoKey('artifacts'), INITIAL_ARTIFACTS)
: Array.isArray(serverState?.artifacts) ? serverState.artifacts : [];
const loadedArtifacts = sanitizeAndDeduplicateArtifacts(
let loadedArtifacts = sanitizeAndDeduplicateArtifacts(
session.isDemo
? (demoSeedChanged ? INITIAL_ARTIFACTS : mergeById(INITIAL_ARTIFACTS, cachedArtifacts))
: cachedArtifacts
@@ -344,7 +416,7 @@ export default function App() {
const cachedSnapshots = session.isDemo
? loadDemoStorage<SnapshotBinding[]>(demoKey('snapshots'), INITIAL_SNAPSHOTS)
: Array.isArray(serverState?.snapshots) ? serverState.snapshots : [];
const loadedSnapshots = session.isDemo
let loadedSnapshots = session.isDemo
? (demoSeedChanged ? INITIAL_SNAPSHOTS : mergeById(INITIAL_SNAPSHOTS, cachedSnapshots))
: cachedSnapshots;
if (session.isDemo && demoSeedChanged) {
@@ -354,7 +426,7 @@ export default function App() {
const cachedSessions = session.isDemo
? loadDemoStorage<Session[]>(demoKey('sessions'), [])
: Array.isArray(serverState?.sessions) ? serverState.sessions : [];
const loadedSessions = session.isDemo && demoSeedChanged ? [] : cachedSessions;
let loadedSessions = session.isDemo && demoSeedChanged ? [] : cachedSessions;
const loadedCredentials = mergeCredentials(session.llmSettings || DEFAULT_CREDENTIALS);
const savedWorkspaceId = session.isDemo
@@ -369,14 +441,26 @@ export default function App() {
const nextProject = loadedProjects.find((project) => project.id === savedProjectId && project.workspaceId === nextWorkspace.id)
|| loadedProjects.find((project) => project.workspaceId === nextWorkspace.id)
|| null;
let gitActiveSession: Session | null = null;
if (!session.isDemo && nextProject?.repository?.status === 'ready') {
try {
const gitState = await fetchGitProjectState(nextProject.id, typeof serverState?.activeSessionId === 'string' ? serverState.activeSessionId : null);
loadedArtifacts = gitState.artifacts;
loadedSnapshots = gitState.snapshots;
loadedSessions = gitState.sessions;
gitActiveSession = gitState.activeSession;
} catch (error) {
console.warn('Unable to load Git project state:', error);
}
}
const savedSessionId = session.isDemo
? loadDemoStorage<Session | null>(demoKey('active_session'), null)?.id
: typeof serverState?.activeSessionId === 'string' ? serverState.activeSessionId : null;
const nextSession = nextProject
const nextSession = gitActiveSession || (nextProject
? loadedSessions.find((sessionItem) => sessionItem.id === savedSessionId && sessionItem.projectId === nextProject.id)
|| loadedSessions.find((sessionItem) => sessionItem.projectId === nextProject.id)
|| null
: null;
: null);
const savedSelectedArtifactId = session.isDemo
? loadDemoStorage<string | null>(demoKey('selected_artifact_id'), null)
: typeof serverState?.selectedArtifactId === 'string' ? serverState.selectedArtifactId : null;
@@ -586,14 +670,8 @@ export default function App() {
React.useEffect(() => {
if (!currentUser || currentUser.isDemo || !appStateHydrated) return;
const state: Record<string, unknown> = {
projects,
activeWorkspaceId: activeWorkspace?.id || null,
activeProjectId: activeProject?.id || null,
artifacts,
selectedArtifactId,
snapshots,
activeSnapshotId,
sessions,
activeSessionId: activeSession?.id || null,
};
latestAccountStateRef.current = state;
@@ -604,14 +682,8 @@ export default function App() {
}, [
currentUser,
appStateHydrated,
projects,
activeWorkspace,
activeProject,
artifacts,
selectedArtifactId,
snapshots,
activeSnapshotId,
sessions,
activeSession,
persistAccountState,
]);
@@ -690,10 +762,21 @@ export default function App() {
));
// Time Travel: Activate a snapshot across all bound artifacts
const handleSelectSnapshot = (snapshotId: string) => {
const handleSelectSnapshot = async (snapshotId: string) => {
setActiveSnapshotId(snapshotId);
const targetSnap = snapshots.find((s) => s.id === snapshotId);
if (!targetSnap) return;
if (!currentUser?.isDemo && activeProject) {
try {
const gitArtifacts = await fetchGitArtifacts(activeProject.id, snapshotId);
setArtifacts((previous) => [...previous.filter((artifact) => artifact.projectId !== activeProject.id), ...gitArtifacts]);
setSelectedArtifactId(gitArtifacts[0]?.id || null);
setPreviewResetNonce((value) => value + 1);
} catch (error) {
console.error('Unable to select Git Snapshot:', error);
}
return;
}
// Update currentVersion of artifacts bound in this snapshot.
setArtifacts((prev) =>
@@ -711,7 +794,33 @@ export default function App() {
};
// Promote Snapshot to Main
const handlePromoteSnapshotToMain = (snapshotId: string) => {
const handlePromoteSnapshotToMain = async (snapshotId: string) => {
if (!currentUser?.isDemo && activeProject) {
const target = snapshots.find((snapshot) => snapshot.id === snapshotId);
if (!target?.sessionId || target.onMain) return;
const expectedMainSha = activeProject.repository?.mainCommitSha;
if (!expectedMainSha) return;
try {
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/sessions/${encodeURIComponent(target.sessionId)}/apply`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedMainSha }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw Object.assign(new Error(payload.error || 'Unable to apply session'), { conflicts: payload.conflicts });
const nextProject = { ...activeProject, repository: { ...activeProject.repository!, mainCommitSha: payload.snapshot.commitSha } };
setProjects((previous) => previous.map((project) => project.id === nextProject.id ? nextProject : project));
setActiveProject(nextProject);
const gitState = await fetchGitProjectState(activeProject.id, null);
setArtifacts((previous) => [...previous.filter((artifact) => artifact.projectId !== activeProject.id), ...gitState.artifacts]);
setSnapshots((previous) => [...previous.filter((snapshot) => !snapshotBelongsToProject(snapshot, projectArtifacts)), ...gitState.snapshots]);
setSessions((previous) => [...previous.filter((session) => session.projectId !== activeProject.id), ...gitState.sessions]);
setActiveSession(gitState.activeSession);
setActiveSnapshotId(payload.snapshot.id);
} catch (error: any) {
const conflictText = Array.isArray(error?.conflicts) ? ` Conflicts: ${error.conflicts.join(', ')}` : '';
window.alert(`${error?.message || 'Unable to apply session.'}${conflictText}`);
}
return;
}
const targetProjectArtifacts = activeProject
? artifacts.filter((artifact) => artifact.projectId === activeProject.id)
: [];
@@ -725,6 +834,29 @@ export default function App() {
handleSelectSnapshot(snapshotId);
};
const handleRestoreSnapshot = async (snapshotId: string) => {
if (!activeProject?.repository?.mainCommitSha || currentUser?.isDemo) return;
const confirmed = window.confirm('Restore this Snapshot as a new commit on main? Existing history will be preserved.');
if (!confirmed) return;
try {
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/restore`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ snapshotId, expectedMainSha: activeProject.repository.mainCommitSha }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Unable to restore Snapshot');
const nextProject = { ...activeProject, repository: { ...activeProject.repository, mainCommitSha: payload.snapshot.commitSha } };
setProjects((previous) => previous.map((project) => project.id === nextProject.id ? nextProject : project));
setActiveProject(nextProject);
const gitState = await fetchGitProjectState(activeProject.id, null);
setArtifacts((previous) => [...previous.filter((artifact) => artifact.projectId !== activeProject.id), ...gitState.artifacts]);
setSnapshots((previous) => [...previous.filter((snapshot) => !snapshotBelongsToProject(snapshot, [{ id: `git-${activeProject.id}` } as Artifact])), ...gitState.snapshots]);
setActiveSnapshotId(payload.snapshot.id);
} catch (error: any) {
window.alert(error?.message || 'Unable to restore Snapshot');
}
};
// Promote a specific Model Variant from chat response to Main Branch
const handlePromoteVariant = (messageId: string, variantIndex: number) => {
if (!activeSession) return;
@@ -806,21 +938,63 @@ export default function App() {
// Workspace and project selection share one scope transition. A project from a
// previous workspace is never allowed to survive the switch.
const handleSelectWorkspace = (ws: Workspace) => {
const handleSelectWorkspace = async (ws: Workspace) => {
if (activeProject?.workspaceId === activeWorkspace.id) {
setLastProjectByWorkspace((previous) => ({ ...previous, [activeWorkspace.id]: activeProject.id }));
}
let availableProjects = projects;
if (!currentUser?.isDemo) {
try {
const response = await rokcetFetch(`/api/projects?workspaceId=${encodeURIComponent(ws.id)}`);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Unable to load workspace projects');
const scoped = payload.projects as Project[];
availableProjects = [...projects.filter((project) => project.workspaceId !== ws.id), ...scoped];
setProjects(availableProjects);
} catch (error) {
console.error('Unable to switch workspace:', error);
}
}
const rememberedId = lastProjectByWorkspace[ws.id];
const rememberedProject = rememberedId
? projects.find((project) => project.id === rememberedId && project.workspaceId === ws.id) || null
? availableProjects.find((project) => project.id === rememberedId && project.workspaceId === ws.id) || null
: null;
const fallbackProject = projects.find((project) => project.workspaceId === ws.id) || null;
applyProjectScope(ws, rememberedProject || fallbackProject);
const fallbackProject = availableProjects.find((project) => project.workspaceId === ws.id) || null;
const target = rememberedProject || fallbackProject;
if (target) await handleSelectProject(target);
else applyProjectScope(ws, null);
};
const handleSelectProject = (proj: Project) => {
const handleSelectProject = async (proj: Project) => {
const workspace = workspaces.find((candidate) => candidate.id === proj.workspaceId) || activeWorkspace;
applyProjectScope(workspace, proj);
if (!currentUser?.isDemo && proj.repository?.status === 'ready') {
try {
const gitState = await fetchGitProjectState(proj.id);
setArtifacts((previous) => [...previous.filter((artifact) => artifact.projectId !== proj.id), ...gitState.artifacts]);
setSnapshots((previous) => [...previous.filter((snapshot) => !snapshotBelongsToProject(snapshot, [{ id: `git-${proj.id}` } as Artifact])), ...gitState.snapshots]);
setSessions((previous) => [...previous.filter((session) => session.projectId !== proj.id), ...gitState.sessions]);
setActiveSession(gitState.activeSession);
setSelectedArtifactId(gitState.artifacts[0]?.id || null);
setActiveSnapshotId(gitState.snapshots[0]?.id || null);
} catch (error) {
console.error('Unable to switch Git project:', error);
}
}
};
const handleRetryRepository = async () => {
if (!activeProject || currentUser?.isDemo) return;
try {
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/repository/retry`, { method: 'POST' });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Repository provisioning failed');
const updated = payload.project as Project;
setProjects((previous) => previous.map((project) => project.id === updated.id ? updated : project));
setActiveProject(updated);
} catch (error: any) {
window.alert(error?.message || 'Repository provisioning failed');
}
};
const handleUpdateProjectBuildMode = (mode: RokcetBuildMode) => {
@@ -835,6 +1009,9 @@ export default function App() {
setActiveProject((project) =>
project ? { ...project, buildMode: normalizedMode, updatedAt } : project
);
if (!currentUser?.isDemo) void rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ buildMode: normalizedMode }),
});
};
const handleUpdateProjectAppTemplate = (selection: RokcetAppTemplateSelection) => {
@@ -849,10 +1026,13 @@ export default function App() {
setActiveProject((project) =>
project ? { ...project, appTemplate: normalizedSelection, updatedAt } : project
);
if (!currentUser?.isDemo) void rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ appTemplate: normalizedSelection }),
});
};
// Create a manual session only after the tutorial has initialized the project.
const handleNewSession = () => {
const handleNewSession = async () => {
if (!activeProject) return;
const hasInitializedThread = sessions.some((session) =>
session.projectId === activeProject.id && session.messages.length > 0
@@ -868,6 +1048,19 @@ export default function App() {
versionNumber: art.currentVersion,
}));
if (!currentUser?.isDemo) {
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/sessions`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: `Coding session #${sessions.length + 1}` }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) { window.alert(payload.error || 'Unable to create coding session'); return; }
const newSession: Session = { ...payload.session, referencedArtifacts: [], messages: payload.session.messages || [] };
setSessions((previous) => [newSession, ...previous]);
setActiveSession(newSession);
setActiveTab('chat');
return;
}
const newSess: Session = {
id: 'sess-' + Date.now(),
projectId: activeProject.id,
@@ -901,10 +1094,18 @@ export default function App() {
? { ...session, title: trimmedTitle, updatedAt: new Date().toISOString() }
: session
);
if (!currentUser?.isDemo && activeProject) {
void rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/sessions/${encodeURIComponent(sessionId)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: trimmedTitle }),
});
}
};
// Delete session handler
const handleDeleteSession = (sessionId: string) => {
if (!currentUser?.isDemo && activeProject) {
void rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/sessions/${encodeURIComponent(sessionId)}/discard`, { method: 'POST' });
}
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
if (activeSession?.id === sessionId) {
const remaining = sessions.filter(
@@ -939,14 +1140,14 @@ export default function App() {
versionNumber: ver.versionNumber,
title: art.title,
type: art.type,
files: ver.files,
...(currentUser?.isDemo ? { files: ver.files } : {}),
});
payloadForServer.push({
artifactId: art.id,
title: art.title,
type: art.type,
versionNumber: ver.versionNumber,
files: ver.files,
files: currentUser?.isDemo ? ver.files : ver.files.map((file) => ({ path: file.path })),
});
});
@@ -1010,6 +1211,10 @@ export default function App() {
referencedArtifacts: payloadForServer,
projectName: activeProject.name,
projectContract: normalizeProjectContract(activeProject.contract),
workspaceId: activeProject.workspaceId,
projectId: activeProject.id,
sessionId: currentSess.id,
expectedHeadSha: currentSess.headCommitSha,
models: selectedModels,
apiKeys: credentials.apiKeys,
};
@@ -1100,124 +1305,6 @@ export default function App() {
const data = completionData;
const deliverablesExtractedList: any[] = [];
// Valid code deliverables are always imported into the Artifact Vault.
// This is core Rokcet build behavior and is intentionally not user-configurable.
const primaryDeliverables: any[] = []; // CHAT is strictly read-only; provider file output is never committed.
primaryDeliverables.forEach((deliv: any) => {
if (!Array.isArray(deliv?.files) || deliv.files.length === 0) return;
const delivType = String(deliv.type || 'other');
const existingArt = artifacts.find(
(a) => a.projectId === activeProject.id && (a.type === delivType || a.title.toLowerCase().includes(delivType.toLowerCase()))
);
if (existingArt) {
const nextVersionNum = existingArt.currentVersion + 1;
const newVer: ArtifactVersion = {
id: 'ver-' + nextVersionNum + '-' + Date.now(),
versionNumber: nextVersionNum,
createdAt: new Date().toISOString(),
summary: deliv.summary || 'Updated via live LLM provider output',
zipFileName: `${existingArt.title.toLowerCase().replace(/\s+/g, '_')}_v${nextVersionNum}.zip`,
files: deliv.files,
createdByPrompt: promptText,
createdByModel: data.primaryModelId,
};
setArtifacts((prev) => prev.map((a) => a.id === existingArt.id
? { ...a, currentVersion: nextVersionNum, updatedAt: new Date().toISOString(), versions: [...a.versions, newVer] }
: a));
deliverablesExtractedList.push({
artifactId: existingArt.id,
versionNumber: nextVersionNum,
title: existingArt.title,
summary: deliv.summary,
filesCount: deliv.files.length,
});
} else {
const newArtId = 'art-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const title = String(deliv.title || `${delivType.toUpperCase()} Deliverable`);
const newArt: Artifact = {
id: newArtId,
projectId: activeProject.id,
title,
type: delivType as any,
currentVersion: 1,
description: deliv.summary,
updatedAt: new Date().toISOString(),
versions: [{
id: 'ver-1-' + Date.now(),
versionNumber: 1,
createdAt: new Date().toISOString(),
summary: deliv.summary || 'Generated by live LLM provider output',
zipFileName: `${title.toLowerCase().replace(/\s+/g, '_')}_v1.zip`,
files: deliv.files,
createdByPrompt: promptText,
createdByModel: data.primaryModelId,
}],
};
setArtifacts((prev) => [...prev, newArt]);
deliverablesExtractedList.push({
artifactId: newArtId,
versionNumber: 1,
title: newArt.title,
summary: deliv.summary,
filesCount: deliv.files.length,
});
}
});
let generatedSnapshotId: string | undefined;
if (deliverablesExtractedList.length > 0) {
const snapshotArtifactsForProject = [
...projectArtifacts,
...deliverablesExtractedList
.filter((item) => !projectArtifacts.some((artifact) => artifact.id === item.artifactId))
.map((item) => ({ id: item.artifactId } as Artifact)),
];
const nextSnapNum = nextProjectSnapshotNumber(snapshots, snapshotArtifactsForProject);
generatedSnapshotId = `snap-${Date.now()}`;
const primaryVariant = (data.variants || []).find((variant: any) => variant.modelId === data.primaryModelId);
const boundVersions = projectArtifacts.map((art) => {
const generated = deliverablesExtractedList.find((item) => item.artifactId === art.id);
return { artifactId: art.id, versionNumber: generated?.versionNumber || art.currentVersion };
});
deliverablesExtractedList.forEach((item) => {
if (!boundVersions.some((binding) => binding.artifactId === item.artifactId)) {
boundVersions.push({ artifactId: item.artifactId, versionNumber: item.versionNumber });
}
});
const boundArtifactVersions = boundVersions.reduce((acc, binding) => {
acc[binding.artifactId] = binding.versionNumber;
return acc;
}, {} as Record<string, number>);
const newSnapshot: SnapshotBinding = {
id: generatedSnapshotId,
versionLabel: `v${nextSnapNum}`,
versionTag: `v${nextSnapNum}.0.0`,
commitHash: Math.random().toString(36).substring(2, 9),
commitMessage: promptText.length > 40 ? promptText.slice(0, 40) + '...' : promptText,
createdAt: new Date().toISOString(),
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
createdByModel: data.primaryModelId,
provider: primaryVariant?.provider || 'google',
branchName: 'main',
isMainBranch: true,
author: data.primaryModelId,
boundArtifactVersions,
boundVersions,
summary: `Snapshot generated from ${deliverablesExtractedList.length} real code deliverable(s) via ${data.primaryModelId}`,
};
setSnapshots((prev) => [
newSnapshot,
...prev.map((snapshot) => (
snapshotBelongsToProject(snapshot, snapshotArtifactsForProject)
? { ...snapshot, isMainBranch: false }
: snapshot
)),
]);
setActiveSnapshotId(newSnapshot.id);
}
const warningLogs = [...(data.modelErrors || modelFailures)].map((failure: any, index: number) => ({
id: `provider-warning-${Date.now()}-${index}`,
@@ -1247,7 +1334,6 @@ export default function App() {
variants: normalizedVariants,
activeVariantIndex: 0,
selectedModelId: data.primaryModelId,
snapshotId: generatedSnapshotId,
executionId: data.executionId,
};
@@ -1255,6 +1341,9 @@ export default function App() {
const finalSess = { ...currentSess, updatedAt: new Date().toISOString(), messages: finalMessages };
setSessions((prev) => prev.map((s) => (s.id === currentSess!.id ? finalSess : s)));
setActiveSession(finalSess);
if (!currentUser?.isDemo) void rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/sessions/${encodeURIComponent(currentSess.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: finalMessages }),
});
} catch (err: any) {
console.error('Live provider chat execution failed:', err);
const errorMsg: ChatMessage = {
@@ -1295,7 +1384,15 @@ export default function App() {
// a project's first thread. Normal build/chat actions must target an
// existing session.
if (!options?.initializeProject && !options?.resume) return;
currentSess = {
if (!currentUser?.isDemo) {
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(runProject.id)}/sessions`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: `Agent: ${promptText.slice(0, 28)}${promptText.length > 28 ? '...' : ''}` }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Unable to initialize coding session');
currentSess = { ...payload.session, referencedArtifacts: [], messages: payload.session.messages || [] };
} else currentSess = {
id: requestedSessionId || ('sess-' + Date.now()),
projectId: runProject.id,
title: `Agent: ${promptText.slice(0, 28)}${promptText.length > 28 ? '...' : ''}`,
@@ -1321,14 +1418,14 @@ export default function App() {
versionNumber: ver.versionNumber,
title: art.title,
type: art.type,
files: ver.files,
...(currentUser?.isDemo ? { files: ver.files } : {}),
});
payloadForServer.push({
artifactId: art.id,
title: art.title,
type: art.type,
versionNumber: ver.versionNumber,
files: ver.files,
files: currentUser?.isDemo ? ver.files : ver.files.map((file) => ({ path: file.path })),
});
});
@@ -1387,6 +1484,8 @@ export default function App() {
apiKeys: credentials.apiKeys,
buildMode,
appTemplate: normalizeRokcetAppTemplate(runProject.appTemplate, runProject.description),
sessionId: currentSess.id,
expectedHeadSha: currentSess.headCommitSha,
}),
});
const data = await response.json().catch(() => ({}));
@@ -1441,6 +1540,36 @@ export default function App() {
setActiveProject((current) => current?.id === runProject.id ? { ...current, contract: nextContract, updatedAt: new Date().toISOString() } : current);
}
if (!currentUser?.isDemo) {
const committedMessage: ChatMessage = {
id: 'msg-agent-result-' + Date.now(),
sessionId: currentSess.id,
role: 'assistant',
content: `${data.response}\n\nCreated Git-backed Snapshot ${data.snapshot?.versionLabel || ''} (${String(data.snapshot?.commitSha || '').slice(0, 8)}).`,
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
logs: data.logs || [],
snapshotId: data.snapshot?.id,
};
const committedSession: Session = {
...currentSess,
...data.session,
referencedArtifacts: [],
messages: [...messagesBeforeWorking, committedMessage],
};
await rokcetFetch(`/api/projects/${encodeURIComponent(runProject.id)}/sessions/${encodeURIComponent(currentSess.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: committedSession.messages }),
});
const gitState = await fetchGitProjectState(runProject.id, currentSess.id);
setArtifacts((previous) => [...previous.filter((artifact) => artifact.projectId !== runProject.id), ...gitState.artifacts]);
setSnapshots((previous) => [...previous.filter((snapshot) => !snapshotBelongsToProject(snapshot, [{ id: `git-${runProject.id}` } as Artifact])), ...gitState.snapshots]);
setSessions((previous) => [...previous.filter((session) => session.projectId !== runProject.id), ...gitState.sessions]);
setActiveSession(gitState.sessions.find((session) => session.id === currentSess.id) || committedSession);
setActiveSnapshotId(data.snapshot?.id || null);
setSelectedArtifactId(gitState.artifacts[0]?.id || null);
setPreviewResetNonce((value) => value + 1);
return;
}
const deliverables = data.deliverables || []; // Agent Mode always applies validated build outputs.
const deliverablesExtractedList: any[] = [];
const nowIso = new Date().toISOString();
@@ -1713,12 +1842,36 @@ export default function App() {
}, [appStateHydrated, currentUser, projects, workspaces]);
// Callback to update code file content inside an artifact version
const handleUpdateFileContent = (
const handleUpdateFileContent = async (
artifactId: string,
versionNumber: number,
filePath: string,
newContent: string
) => {
if (!currentUser?.isDemo && activeProject && activeSession?.headCommitSha) {
try {
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/sessions/${encodeURIComponent(activeSession.id)}/commits`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expectedHeadSha: activeSession.headCommitSha,
files: [{ path: filePath, content: newContent }],
summary: `Update ${filePath}`,
kind: 'edit',
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Unable to commit source file');
const gitState = await fetchGitProjectState(activeProject.id, activeSession.id);
setArtifacts((previous) => [...previous.filter((artifact) => artifact.projectId !== activeProject.id), ...gitState.artifacts]);
setSnapshots((previous) => [...previous.filter((snapshot) => !snapshotBelongsToProject(snapshot, [{ id: `git-${activeProject.id}` } as Artifact])), ...gitState.snapshots]);
setSessions((previous) => [...previous.filter((session) => session.projectId !== activeProject.id), ...gitState.sessions]);
setActiveSession(gitState.sessions.find((session) => session.id === activeSession.id) || null);
setActiveSnapshotId(payload.snapshot.id);
} catch (error: any) {
window.alert(error?.message || 'Unable to commit source file');
}
return;
}
setArtifacts((prev) =>
prev.map((art) => {
if (art.id === artifactId) {
@@ -1825,7 +1978,7 @@ export default function App() {
};
// 1-Click Scaffolding to Project Handler
const handleScaffoldAppToProject = (scaffoldedArtifacts: Artifact[], snapshotSummary: string, enabledCapabilities: string[] = []) => {
const handleScaffoldAppToProject = async (scaffoldedArtifacts: Artifact[], snapshotSummary: string, enabledCapabilities: string[] = []) => {
if (!activeProject) return;
const enabled = new Set(enabledCapabilities);
@@ -1853,6 +2006,26 @@ export default function App() {
setProjects((prev) => prev.map((project) => project.id === activeProject.id ? updatedProject : project));
setActiveProject(updatedProject);
if (!currentUser?.isDemo && activeSession?.headCommitSha) {
const files = scaffoldedArtifacts.flatMap((artifact) => artifact.versions.find((version) => version.versionNumber === artifact.currentVersion)?.files || []);
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/sessions/${encodeURIComponent(activeSession.id)}/commits`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedHeadSha: activeSession.headCommitSha, files, summary: snapshotSummary, kind: 'build' }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Unable to commit Buckets scaffold');
await rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contract: nextContract }),
});
const gitState = await fetchGitProjectState(activeProject.id, activeSession.id);
setArtifacts((previous) => [...previous.filter((artifact) => artifact.projectId !== activeProject.id), ...gitState.artifacts]);
setSnapshots((previous) => [...previous.filter((snapshot) => !snapshotBelongsToProject(snapshot, [{ id: `git-${activeProject.id}` } as Artifact])), ...gitState.snapshots]);
setSessions((previous) => [...previous.filter((session) => session.projectId !== activeProject.id), ...gitState.sessions]);
setActiveSession(gitState.sessions.find((session) => session.id === activeSession.id) || null);
setActiveSnapshotId(payload.snapshot.id);
return;
}
setArtifacts((prev) => {
// Remove any duplicate or previous Buckets artifacts for this project to keep things clean and idempotent
const remaining = prev.filter((a) => {
@@ -2039,6 +2212,14 @@ export default function App() {
/>
)}
{!currentUser?.isDemo && activeProject && activeProject.repository?.status !== 'ready' ? (
<div role="status" className="border-b border-amber-300 bg-amber-50 px-5 py-3 text-xs font-bold text-amber-950">
Rokcet Git is {activeProject.repository?.status || activeProject.status || 'provisioning'}.
{activeProject.repository?.error ? ` ${activeProject.repository.error}` : ''}
<button type="button" onClick={() => void handleRetryRepository()} className="ml-3 border border-amber-700 px-2 py-1 uppercase">Retry</button>
</div>
) : null}
{/* Tab View Switcher */}
{activeTab === 'chat' && (
activeProject ? (
@@ -2103,10 +2284,29 @@ export default function App() {
)
);
}}
onCreateNewArtifact={(newArt) => setArtifacts((prev) => [...prev, newArt])}
onCreateNewArtifact={async (newArt) => {
if (!currentUser?.isDemo && activeSession?.headCommitSha) {
const files = newArt.versions[0]?.files || [];
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(activeProject.id)}/sessions/${encodeURIComponent(activeSession.id)}/commits`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expectedHeadSha: activeSession.headCommitSha, files, summary: `Add ${newArt.title}`, kind: 'edit' }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Unable to add source files');
const gitState = await fetchGitProjectState(activeProject.id, activeSession.id);
setArtifacts((previous) => [...previous.filter((artifact) => artifact.projectId !== activeProject.id), ...gitState.artifacts]);
setSnapshots((previous) => [...previous.filter((snapshot) => !snapshotBelongsToProject(snapshot, [{ id: `git-${activeProject.id}` } as Artifact])), ...gitState.snapshots]);
setSessions((previous) => [...previous.filter((session) => session.projectId !== activeProject.id), ...gitState.sessions]);
setActiveSession(gitState.sessions.find((session) => session.id === activeSession.id) || null);
setActiveSnapshotId(payload.snapshot.id);
return;
}
setArtifacts((prev) => [...prev, newArt]);
}}
onUpdateFileContent={handleUpdateFileContent}
onSelectSnapshot={handleSelectSnapshot}
onPromoteSnapshotToMain={handlePromoteSnapshotToMain}
onRestoreSnapshot={handleRestoreSnapshot}
onNavigateToPreview={() => setActiveTab('preview')}
areSidebarsOpen={areSidebarsOpen}
onToggleSidebarsOpen={(open) => setAreSidebarsOpen(open)}
@@ -2123,6 +2323,9 @@ export default function App() {
<LiveSandboxPreview
key={`${activeWorkspace.id}:${activeProject.id}:${previewResetNonce}`}
artifacts={projectArtifacts}
projectId={activeProject.id}
sourceRef={activeSnapshotId || activeSession?.id || 'main'}
gitBacked={!currentUser?.isDemo}
onDeploy={() => setActiveTab('deploy')}
onSelectArtifactVersion={(artId, verNum) => {
setArtifacts((prev) =>
@@ -2215,9 +2418,19 @@ export default function App() {
{isNewProjModalOpen && activeWorkspace && (
<NewProjectModal
workspaceId={activeWorkspace.id}
onCreateProject={(newProj) => {
setProjects((prev) => [...prev, newProj]);
applyProjectScope(activeWorkspace, newProj);
onCreateProject={async (newProj) => {
let createdProject = newProj;
if (!currentUser?.isDemo) {
const response = await rokcetFetch('/api/projects', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId: activeWorkspace.id, name: newProj.name }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Unable to create project');
createdProject = payload.project;
}
setProjects((prev) => [...prev, createdProject]);
applyProjectScope(activeWorkspace, createdProject);
setIsArtifactsExpanded(false);
setActiveTab('chat');
}}

View File

@@ -33,9 +33,10 @@ interface ArtifactsVaultProps {
onSelectArtifact: (artifactId: string) => void;
onAddArtifactVersion: (artifactId: string, newVersion: ArtifactVersion) => void;
onCreateNewArtifact: (artifact: Artifact) => void;
onUpdateFileContent: (artifactId: string, versionNumber: number, filePath: string, newContent: string) => void;
onUpdateFileContent: (artifactId: string, versionNumber: number, filePath: string, newContent: string) => void | Promise<void>;
onSelectSnapshot?: (snapshotId: string) => void;
onPromoteSnapshotToMain?: (snapshotId: string) => void;
onRestoreSnapshot?: (snapshotId: string) => void | Promise<void>;
onNavigateToPreview?: () => void;
areSidebarsOpen?: boolean;
onToggleSidebarsOpen?: (open: boolean) => void;
@@ -54,6 +55,7 @@ export function ArtifactsVault({
onUpdateFileContent,
onSelectSnapshot,
onPromoteSnapshotToMain,
onRestoreSnapshot,
onNavigateToPreview,
areSidebarsOpen = true,
onToggleSidebarsOpen,
@@ -124,19 +126,23 @@ export function ArtifactsVault({
}
}, [activeFile?.path, activeVersionNumber, activeArtifact?.id]);
const handleSaveFile = () => {
const handleSaveFile = async () => {
if (!activeArtifact || !activeVersion || !activeFile) return;
onUpdateFileContent(activeArtifact.id, activeVersion.versionNumber, activeFile.path, editingCode);
await onUpdateFileContent(activeArtifact.id, activeVersion.versionNumber, activeFile.path, editingCode);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 2000);
};
const handleDownloadZip = async (artifact: Artifact, version: ArtifactVersion) => {
try {
const response = await rokcetFetch('/api/artifacts/zip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
const gitBacked = project.repository?.status === 'ready';
const archiveRef = activeSnapshotId || 'main';
const response = await rokcetFetch(gitBacked
? `/api/projects/${encodeURIComponent(project.id)}/source/archive?ref=${encodeURIComponent(archiveRef)}`
: '/api/artifacts/zip', {
method: gitBacked ? 'GET' : 'POST',
headers: gitBacked ? undefined : { 'Content-Type': 'application/json' },
body: gitBacked ? undefined : JSON.stringify({
artifactTitle: artifact.title,
versionNumber: version.versionNumber,
files: version.files,
@@ -263,6 +269,7 @@ export function ArtifactsVault({
activeSnapshotId={activeSnapshotId}
onSelectSnapshot={onSelectSnapshot || (() => {})}
onPromoteSnapshotToMain={onPromoteSnapshotToMain || (() => {})}
onRestoreSnapshot={onRestoreSnapshot}
onNavigateToPreview={onNavigateToPreview}
areSidebarsOpen={areSidebarsOpen}
/>

View File

@@ -8,7 +8,7 @@ import {
DeploymentProviderType,
SnapshotBinding,
} from '../types';
import { buildSnapshotDeploymentBundle, snapshotBelongsToArtifacts } from '../lib/deploymentBundle';
import { snapshotBelongsToArtifacts } from '../lib/deploymentBundle';
import type { RokcetProjectContract } from '../../shared/projectContracts';
import { normalizeProjectContract } from '../../shared/projectContracts';
import {
@@ -168,7 +168,7 @@ export function DeploymentView({
const effectiveProjectContract = normalizeProjectContract(projectContract);
const deploymentProviderId = effectiveProjectContract.deployment.provider;
const projectSnapshots = snapshots.filter((snapshot) => snapshotBelongsToArtifacts(snapshot, artifacts));
const projectSnapshots = snapshots.filter((snapshot) => snapshotBelongsToArtifacts(snapshot, artifacts) && snapshot.onMain !== false);
const snapshotCreatedAtMs = (snapshot: SnapshotBinding) => {
const createdAt = Date.parse(snapshot.createdAt || '');
@@ -551,23 +551,15 @@ export function DeploymentView({
const deploySnapshotToProvider = async (
environment?: Record<string, string>,
onBuilding?: () => void,
preparedFiles?: ReturnType<typeof buildSnapshotDeploymentBundle>,
): Promise<PersistedDeployment> => {
if (!selectedDeploySnapshot) throw new Error('Select a deployable snapshot first.');
const files = preparedFiles || buildSnapshotDeploymentBundle(artifacts, selectedDeploySnapshot, effectiveProjectContract);
const response = await rokcetFetch('/api/deploy', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId: activeWorkspaceId,
projectId: activeProjectId,
projectName: activeProjectName,
snapshotId: selectedDeploySnapshot.id,
snapshotLabel: projectSnapshotVersionLabel(selectedDeploySnapshot),
provider: deploymentProviderId,
projectContract: effectiveProjectContract,
files,
environment,
}),
});
@@ -599,12 +591,8 @@ export function DeploymentView({
setIsHistoryExpanded(false);
setIsStreamExpanded(false);
try {
// Bundle validation happens synchronously before any provider request.
// Give React a frame to render step 1 as active before the validation
// work starts; otherwise the UI can jump straight to step 2 and make the
// immutable snapshot validation look skipped while the browser is busy.
// Source validation and resolution are authoritative on the server.
await waitForDeployProgressPaint();
const files = buildSnapshotDeploymentBundle(artifacts, selectedDeploySnapshot, effectiveProjectContract);
appendDeploymentLog(
`${providerDisplayName(deploymentProviderId).toUpperCase()} // SNAPSHOT_VALIDATED`,
'info',
@@ -615,7 +603,7 @@ export function DeploymentView({
const deployment = await deploySnapshotToProvider(undefined, () => {
setGeneralDeployStep(3);
appendDeploymentLog(`${providerDisplayName(deploymentProviderId).toUpperCase()} // BUILD_STARTED`, 'info', 'Rokcet accepted the deployment and is building the uploaded source bundle.');
}, files);
});
if (deployment.status === 'ready' && deployment.url) {
setGeneralDeployStep(4);
setGeneralDomain(new URL(deployment.url).host);
@@ -1869,4 +1857,3 @@ export function DeploymentView({
</div>
);
}

View File

@@ -28,6 +28,9 @@ interface LiveSandboxPreviewProps {
onDeploy?: () => void;
onDeployToPloi?: () => void;
onSelectArtifactVersion?: (artifactId: string, versionNumber: number) => void;
projectId?: string;
sourceRef?: string;
gitBacked?: boolean;
}
export function LiveSandboxPreview({
@@ -37,6 +40,9 @@ export function LiveSandboxPreview({
onDeploy,
onDeployToPloi,
onSelectArtifactVersion,
projectId,
sourceRef = 'main',
gitBacked = false,
}: LiveSandboxPreviewProps) {
const handleDeployTrigger = onDeploy || onDeployToPloi;
// Preview is a frontend concern. Backend/config artifacts stay available in the
@@ -160,7 +166,7 @@ export function LiveSandboxPreview({
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
signal: controller.signal,
body: JSON.stringify({ files: combinedFiles }),
body: JSON.stringify(gitBacked ? { projectId, ref: sourceRef } : { files: combinedFiles }),
});
if (!response.ok) {
if (response.status === 401) throw new Error('Your Rokcet session expired. Please sign in again.');
@@ -185,7 +191,7 @@ export function LiveSandboxPreview({
renderFiles();
return () => controller.abort();
}, [JSON.stringify(combinedFiles), renderAttempt]);
}, [gitBacked, projectId, sourceRef, JSON.stringify(combinedFiles), renderAttempt]);
// Progress is tied to real render phases rather than a blind timer. It can
// advance quickly while files compile, pauses below 100% until the iframe

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { rokcetFetch } from '../services/rokcetFetch';
import { SnapshotBinding, Artifact } from '../types';
import { snapshotBelongsToArtifacts } from '../lib/deploymentBundle';
import {
@@ -13,6 +14,7 @@ import {
Clock,
ArrowUpRight,
GitPullRequest,
GitCompare,
Check,
Rocket,
} from 'lucide-react';
@@ -23,6 +25,7 @@ interface MapViewProps {
activeSnapshotId: string | null;
onSelectSnapshot: (snapshotId: string) => void;
onPromoteSnapshotToMain: (snapshotId: string) => void;
onRestoreSnapshot?: (snapshotId: string) => void | Promise<void>;
onNavigateToPreview?: () => void;
areSidebarsOpen?: boolean;
}
@@ -33,6 +36,7 @@ export function MapView({
activeSnapshotId,
onSelectSnapshot,
onPromoteSnapshotToMain,
onRestoreSnapshot,
onNavigateToPreview,
areSidebarsOpen = true,
}: MapViewProps) {
@@ -45,6 +49,8 @@ export function MapView({
: (projectSnapshots.length > 0 ? projectSnapshots[0].id : null)
);
const [isInspectorOpen, setIsInspectorOpen] = useState<boolean>(areSidebarsOpen);
const [snapshotDiff, setSnapshotDiff] = useState('');
const [snapshotDiffError, setSnapshotDiffError] = useState('');
React.useEffect(() => {
if (areSidebarsOpen !== undefined) {
@@ -477,10 +483,40 @@ export function MapView({
className="w-full py-2 px-3 text-xs font-black uppercase flex items-center justify-center gap-2 bg-white hover:bg-zinc-100 text-zinc-800 border border-zinc-300 rounded transition cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5 text-black" />
<span>ROLL BACK</span>
<span>VIEW SOURCE</span>
</button>
)}
<button
onClick={async () => {
if (!selectedSnapshot) return;
setSnapshotDiffError('');
const projectId = artifacts[0]?.projectId;
if (!projectId) return;
const response = await rokcetFetch(`/api/projects/${encodeURIComponent(projectId)}/diff?from=${encodeURIComponent(selectedSnapshot.id)}&to=main`);
const payload = await response.json().catch(() => ({}));
if (!response.ok) { setSnapshotDiffError(payload.error || 'Unable to load diff'); return; }
setSnapshotDiff(payload.patch || 'No source differences.');
}}
className="w-full py-2 px-3 text-xs font-black uppercase flex items-center justify-center gap-2 bg-white hover:bg-zinc-100 text-zinc-800 border border-zinc-300 rounded transition cursor-pointer"
>
<GitCompare className="w-3.5 h-3.5 text-black" />
<span>COMPARE WITH MAIN</span>
</button>
{onRestoreSnapshot && selectedSnapshot.onMain && !selectedSnapshot.isMainBranch ? (
<button
onClick={() => onRestoreSnapshot(selectedSnapshot.id)}
className="w-full py-2 px-3 text-xs font-black uppercase flex items-center justify-center gap-2 bg-white hover:bg-zinc-100 text-zinc-800 border border-zinc-300 rounded transition cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5 text-black" />
<span>RESTORE AS NEW COMMIT</span>
</button>
) : null}
{snapshotDiffError ? <p className="text-[10px] text-red-700 font-bold">{snapshotDiffError}</p> : null}
{snapshotDiff ? <pre className="max-h-48 overflow-auto whitespace-pre-wrap border border-zinc-300 bg-zinc-950 p-3 text-[9px] text-zinc-100">{snapshotDiff}</pre> : null}
{selectedSnapshot.isMainBranch ? (
<div className="w-full py-2 px-3 text-xs font-black uppercase flex items-center justify-center gap-2 bg-zinc-100 text-black border border-zinc-300 rounded cursor-default">
<Star className="w-3.5 h-3.5 fill-black text-black" />

View File

@@ -7,14 +7,16 @@ import { Layers, X } from 'lucide-react';
interface NewProjectModalProps {
workspaceId: string;
onCreateProject: (proj: Project) => void;
onCreateProject: (proj: Project) => void | Promise<void>;
onClose: () => void;
}
export function NewProjectModal({ workspaceId, onCreateProject, onClose }: NewProjectModalProps) {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isCreating, setIsCreating] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const projectName = name.trim();
if (!projectName) return;
@@ -32,8 +34,15 @@ export function NewProjectModal({ workspaceId, onCreateProject, onClose }: NewPr
updatedAt: now,
};
onCreateProject(newProj);
onClose();
setIsCreating(true);
setError('');
try {
await onCreateProject(newProj);
onClose();
} catch (cause: any) {
setError(cause?.message || 'Unable to create the project repository.');
setIsCreating(false);
}
};
const modal = (
@@ -71,10 +80,11 @@ export function NewProjectModal({ workspaceId, onCreateProject, onClose }: NewPr
<button type="button" onClick={onClose} className="px-4 py-2.5 bg-zinc-100 text-zinc-800 font-black uppercase border border-zinc-300 hover:bg-zinc-200">
Cancel
</button>
<button type="submit" disabled={!name.trim()} className="px-5 py-2.5 bg-black disabled:bg-zinc-300 disabled:border-zinc-300 disabled:text-zinc-500 text-white font-black uppercase border border-black btn-tactical">
Create project →
<button type="submit" disabled={!name.trim() || isCreating} className="px-5 py-2.5 bg-black disabled:bg-zinc-300 disabled:border-zinc-300 disabled:text-zinc-500 text-white font-black uppercase border border-black btn-tactical">
{isCreating ? 'Creating repository…' : 'Create project →'}
</button>
</div>
{error ? <p role="alert" className="text-[11px] text-red-700">{error}</p> : null}
</form>
</div>
</div>

View File

@@ -1,54 +1,8 @@
import type { Artifact, ArtifactFile, ArtifactVersion, SnapshotBinding } from '../types';
import type { RokcetProjectContract } from '../../shared/projectContracts';
import { normalizeProjectContract } from '../../shared/projectContracts';
export interface DeploymentBundleFile {
path: string;
content: string;
}
interface BoundArtifact {
artifact: Artifact;
version: ArtifactVersion;
}
function normalizePath(value: string): string {
return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/{2,}/g, '/');
}
function boundArtifactsForSnapshot(artifacts: Artifact[], snapshot: SnapshotBinding): BoundArtifact[] {
const artifactsById = new Map(artifacts.map((artifact) => [artifact.id, artifact]));
const entries = Object.entries(snapshot.boundArtifactVersions || {}).filter(([artifactId]) => artifactsById.has(artifactId));
if (entries.length === 0) throw new Error('The selected snapshot does not belong to the active project.');
return entries.map(([artifactId, versionNumber]) => {
const artifact = artifactsById.get(artifactId)!;
const version = artifact.versions.find((candidate) => candidate.versionNumber === versionNumber);
if (!version) throw new Error(`Snapshot references missing ${artifact.title} v${versionNumber}.`);
return { artifact, version };
});
}
function addFiles(
output: Map<string, { content: string; artifactTitle: string }>,
files: ArtifactFile[],
artifactTitle: string,
) {
const conflicts: string[] = [];
for (const file of files || []) {
const path = normalizePath(file.path);
if (!path) continue;
const content = file.content || '';
const existing = output.get(path);
if (existing && existing.content !== content) {
conflicts.push(`${path} (${existing.artifactTitle} ↔ ${artifactTitle})`);
continue;
}
if (!existing) output.set(path, { content, artifactTitle });
}
return conflicts;
}
import type { Artifact, SnapshotBinding } from '../types';
// Snapshot/source contents are resolved by the server from Git. This helper is
// UI-only project scoping for the temporary codebase projection used by the
// current workspace components.
export function snapshotBelongsToArtifacts(snapshot: SnapshotBinding, artifacts: Artifact[]): boolean {
const artifactIds = new Set(artifacts.map((artifact) => artifact.id));
const boundArtifactIds = new Set([
@@ -57,42 +11,3 @@ export function snapshotBelongsToArtifacts(snapshot: SnapshotBinding, artifacts:
]);
return [...boundArtifactIds].some((artifactId) => artifactIds.has(artifactId));
}
/**
* Build the deployable source exactly as authored by the agent. Rokcet no longer
* rewrites an arbitrary project into a Vite app at deployment time: the project
* contract determines the stack, and the server-side stack adapter validates it.
*/
export function buildSnapshotDeploymentBundle(
artifacts: Artifact[],
snapshot: SnapshotBinding | undefined,
inputContract?: RokcetProjectContract,
): DeploymentBundleFile[] {
if (!snapshot) throw new Error('Select a snapshot before deploying.');
if (Object.keys(snapshot.boundArtifactVersions || {}).length === 0) throw new Error('The selected snapshot has no bound artifact versions.');
const contract = normalizeProjectContract(inputContract);
const bound = boundArtifactsForSnapshot(artifacts, snapshot);
const backends = bound.filter(({ artifact }) => artifact.type === 'backend');
if (contract.stack.profile === 'vite_react_typescript' && backends.length > 0) {
throw new Error('This project uses the frontend-only Vite stack contract, but the snapshot contains a backend artifact. Switch the project contract to a full-stack profile or remove the backend before deploying.');
}
const frontends = bound.filter(({ artifact }) => artifact.type === 'frontend');
if (contract.stack.profile === 'express_typescript' && frontends.length > 0) {
throw new Error('This project uses the backend-only Express stack contract, but the snapshot contains a frontend artifact. Switch the project contract to a full-stack/frontend profile or remove the frontend before deploying.');
}
const output = new Map<string, { content: string; artifactTitle: string }>();
const conflicts: string[] = [];
for (const entry of bound) conflicts.push(...addFiles(output, entry.version.files, entry.artifact.title));
if (conflicts.length > 0) {
const detail = conflicts.slice(0, 5).join(', ');
const remaining = conflicts.length > 5 ? ` and ${conflicts.length - 5} more` : '';
throw new Error(`Snapshot has conflicting deployment file paths: ${detail}${remaining}. Make the artifact paths unique before deploying.`);
}
const bundle = [...output.entries()].map(([path, entry]) => ({ path, content: entry.content }));
if (bundle.length === 0) throw new Error('The selected snapshot has no deployable source files.');
return bundle.sort((a, b) => a.path.localeCompare(b.path));
}

View File

@@ -48,6 +48,13 @@ export interface Project {
buildMode?: RokcetBuildMode;
contract?: RokcetProjectContract;
appTemplate?: RokcetAppTemplateSelection;
status?: string;
repository?: {
status: string;
defaultBranch: string;
mainCommitSha: string | null;
error?: string | null;
} | null;
}
export interface Workspace {
@@ -68,6 +75,11 @@ export interface Session {
referencedArtifacts: { artifactId: string; versionNumber: number }[];
messages: ChatMessage[];
executionId?: string;
branchName?: string;
baseCommitSha?: string;
headCommitSha?: string;
status?: 'open' | 'applied' | 'discarded';
closedAt?: string | null;
}
export type LlmProvider = 'google' | 'openai' | 'anthropic' | 'groq';
@@ -178,6 +190,13 @@ export interface SnapshotBinding {
author?: string;
summary?: string;
boundVersions?: { artifactId: string; versionNumber: number }[];
commitSha?: string;
shortSha?: string;
onMain?: boolean;
sequence?: number;
sessionId?: string | null;
kind?: string;
modelMetadata?: Record<string, unknown>;
}
export type DeploymentProviderType = 'ploi' | 'vercel' | 'forge' | 'custom_mcp';

99
tests/gitApi.test.ts Normal file
View File

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Server } from 'node:http';
import { createApp } from '../server/index';
import { createProjectRecord, createSession, resetRokcetDbForTests, upsertBucketsUser } from '../server/services/rokcetDb';
const root = mkdtempSync(path.join(os.tmpdir(), 'rokcet-git-api-'));
process.env.ROKCET_DATABASE_PATH = path.join(root, 'rokcet.sqlite');
process.env.ROKCET_GIT_STORAGE_ROOT = path.join(root, 'git');
process.env.ROKCET_GIT_LOCAL_REMOTE_ROOT = path.join(root, 'remotes');
process.env.ROKCET_SESSION_SECRET = 'test-only-session-secret-with-enough-entropy';
delete process.env.ROKCET_GIT_API_URL;
let server: Server;
let baseUrl = '';
let cookie = '';
let workspaceId = '';
before(async () => {
const identity = upsertBucketsUser({ id: 'api-user', email: 'api@example.com' });
workspaceId = identity.workspaces[0].id;
const session = createSession(identity.user.id);
cookie = `rokcet_session=${encodeURIComponent(session.token)}`;
server = createApp().listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.once('listening', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Test server did not bind');
baseUrl = `http://127.0.0.1:${address.port}`;
});
after(async () => {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
resetRokcetDbForTests();
rmSync(root, { recursive: true, force: true });
});
async function request(pathname: string, init: RequestInit = {}) {
return fetch(`${baseUrl}${pathname}`, { ...init, headers: { Cookie: cookie, ...(init.headers || {}) } });
}
test('creates server-authoritative projects and denies unowned workspaces', async () => {
const denied = await request('/api/projects', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workspaceId: 'not-owned', name: 'Denied' }),
});
assert.equal(denied.status, 403);
const created = await request('/api/projects', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workspaceId, name: 'Git API' }),
});
const payload: any = await created.json();
assert.equal(created.status, 201);
assert.equal(payload.project.repository.status, 'ready');
assert.match(payload.project.repository.mainCommitSha, /^[0-9a-f]{40}$/);
const codingSession = await request(`/api/projects/${payload.project.id}/sessions`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'API session' }),
});
const sessionPayload: any = await codingSession.json();
assert.equal(codingSession.status, 201);
assert.match(sessionPayload.session.branchName, /^rokcet\/session\//);
const committed = await request(`/api/projects/${payload.project.id}/sessions/${sessionPayload.session.id}/commits`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
expectedHeadSha: sessionPayload.session.headCommitSha,
summary: 'API commit',
files: [{ path: 'src/index.ts', content: 'export const api = true;\n' }],
}),
});
const commitPayload: any = await committed.json();
assert.equal(committed.status, 201);
assert.match(commitPayload.snapshot.commitSha, /^[0-9a-f]{40}$/);
assert.equal(commitPayload.snapshot.onMain, false);
const traversal = await request(`/api/projects/${payload.project.id}/source/file?ref=${commitPayload.snapshot.id}&path=${encodeURIComponent('../rokcet.sqlite')}`);
assert.equal(traversal.status, 422);
});
test('does not expose another user project', async () => {
const other = upsertBucketsUser({ id: 'api-other-user', email: 'other@example.com' });
const hidden = createProjectRecord({ userId: other.user.id, workspaceId: other.workspaces[0].id, name: 'Hidden' });
const response = await request(`/api/projects/${hidden.id}/source/tree`);
assert.equal(response.status, 404);
});
test('persists UI preferences without accepting browser source history', async () => {
const saved = await request('/api/app-state', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
revision: 1,
state: { activeWorkspaceId: workspaceId, activeProjectId: 'project-ui', artifacts: [{ content: 'must-not-persist' }], snapshots: [{ commitHash: 'fake' }] },
}),
});
assert.equal(saved.status, 200);
const loaded = await request('/api/app-state');
const payload: any = await loaded.json();
assert.deepEqual(payload.state, { activeWorkspaceId: workspaceId, activeProjectId: 'project-ui', activeSessionId: null });
});

136
tests/gitFoundation.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {
createCodingSessionRecord,
createDeploymentRecord,
createGitRepositoryRecord,
createProjectRecord,
createSnapshotRecord,
getGitRepositoryRecord,
listSnapshotRecords,
resetRokcetDbForTests,
updateCodingSessionRecord,
updateGitRepositoryRecord,
upsertBucketsUser,
} from '../server/services/rokcetDb';
import { getGitProvider } from '../server/services/gitProvider';
import { gitRepositoryService, normalizeSourcePath } from '../server/services/gitRepository';
const temporaryRoot = mkdtempSync(path.join(os.tmpdir(), 'rokcet-git-foundation-'));
process.env.ROKCET_DATABASE_PATH = path.join(temporaryRoot, 'rokcet.sqlite');
process.env.ROKCET_GIT_STORAGE_ROOT = path.join(temporaryRoot, 'git');
process.env.ROKCET_GIT_LOCAL_REMOTE_ROOT = path.join(temporaryRoot, 'remotes');
delete process.env.ROKCET_GIT_API_URL;
delete process.env.ROKCET_GIT_SERVICE_TOKEN;
let userId = '';
let workspaceId = '';
before(() => {
const identity = upsertBucketsUser({ id: 'buckets-test-user', email: 'git-test@example.com' });
userId = identity.user.id;
workspaceId = identity.workspaces[0].id;
});
after(() => {
resetRokcetDbForTests();
rmSync(temporaryRoot, { recursive: true, force: true });
});
async function readyProject(name: string) {
const project = createProjectRecord({ userId, workspaceId, name });
const repository = createGitRepositoryRecord({ projectId: project.id, repositoryPath: `rokcet/p-${project.id.replaceAll('-', '')}` });
const remote = await getGitProvider().provisionPrivateRepository(repository.repositoryPath);
const mainCommitSha = gitRepositoryService.initialize(repository, remote.cloneUrl);
updateGitRepositoryRecord(project.id, { providerRepositoryId: remote.providerRepositoryId, mainCommitSha, status: 'ready', errorMessage: null });
return { project, repository: getGitRepositoryRecord(project.id)!, mainCommitSha };
}
function commit(repository: any, projectId: string, session: any, files: Array<{ path: string; content: string }>, summary: string) {
const commitSha = gitRepositoryService.commitChanges(repository, session, { expectedHeadSha: session.headCommitSha, files, summary });
return { commitSha, session: updateCodingSessionRecord(projectId, session.id, { headCommitSha: commitSha })! };
}
test('provisions an idempotent private repository with a real root commit', async () => {
const { repository, mainCommitSha } = await readyProject('Provisioning');
assert.match(mainCommitSha, /^[0-9a-f]{40}$/);
assert.equal(gitRepositoryService.resolveCommit(repository, 'main'), mainCommitSha);
const again = await getGitProvider().provisionPrivateRepository(repository.repositoryPath);
assert.equal(again.repositoryPath, repository.repositoryPath);
});
test('creates immutable Snapshots, diffs, archives, and non-destructive restores', async () => {
const { project, repository, mainCommitSha } = await readyProject('History');
const originalSession = createCodingSessionRecord({ projectId: project.id, baseCommitSha: mainCommitSha, title: 'Original source' });
const first = commit(repository, project.id, originalSession, [{ path: 'src/app.ts', content: 'export const value = 1;\n' }], 'Create app');
const snapshot = createSnapshotRecord({ projectId: project.id, commitSha: first.commitSha, sessionId: first.session.id, kind: 'build', summary: 'Create app', createdByUserId: userId });
gitRepositoryService.pinSnapshot(repository, snapshot.id, snapshot.commitSha);
const applied = gitRepositoryService.applySession(repository, first.session, mainCommitSha);
updateGitRepositoryRecord(project.id, { mainCommitSha: applied.commitSha });
assert.equal(applied.commitSha, first.commitSha);
const secondSession = createCodingSessionRecord({ projectId: project.id, baseCommitSha: applied.commitSha, title: 'Update source' });
const second = commit(repository, project.id, secondSession, [{ path: 'src/app.ts', content: 'export const value = 2;\n' }], 'Update app');
const diff = gitRepositoryService.diff(repository, first.commitSha, second.commitSha);
assert.deepEqual(diff.paths, [{ status: 'M', path: 'src/app.ts' }]);
assert.match(diff.patch, /value = 2/);
assert.ok(gitRepositoryService.archive(repository, second.commitSha).byteLength > 0);
const deployment = createDeploymentRecord({
userId, workspaceId, projectId: project.id, projectName: project.name,
snapshotId: snapshot.id, snapshotLabel: snapshot.versionLabel, snapshotDigest: 'digest',
commitSha: first.commitSha, provider: 'vercel',
});
assert.equal(deployment.commitSha, first.commitSha);
const secondApplied = gitRepositoryService.applySession(repository, second.session, first.commitSha);
updateGitRepositoryRecord(project.id, { mainCommitSha: secondApplied.commitSha });
const restored = gitRepositoryService.restore(repository, first.commitSha, secondApplied.commitSha);
assert.notEqual(restored, first.commitSha);
assert.equal(gitRepositoryService.readFile(repository, restored, 'src/app.ts'), 'export const value = 1;\n');
assert.equal(listSnapshotRecords(project.id).length, 1);
});
test('isolates concurrent sessions, safely merges, and preserves conflicts', async () => {
const { project, repository, mainCommitSha } = await readyProject('Concurrency');
const seed = createCodingSessionRecord({ projectId: project.id, baseCommitSha: mainCommitSha, title: 'Seed' });
const seeded = commit(repository, project.id, seed, [{ path: 'shared.txt', content: 'base\n' }], 'Seed');
const seededMain = gitRepositoryService.applySession(repository, seeded.session, mainCommitSha).commitSha;
updateGitRepositoryRecord(project.id, { mainCommitSha: seededMain });
const left = createCodingSessionRecord({ projectId: project.id, baseCommitSha: seededMain, title: 'Left' });
const right = createCodingSessionRecord({ projectId: project.id, baseCommitSha: seededMain, title: 'Right' });
const leftCommit = commit(repository, project.id, left, [{ path: 'left.txt', content: 'left\n' }], 'Left');
const rightCommit = commit(repository, project.id, right, [{ path: 'right.txt', content: 'right\n' }], 'Right');
const leftMain = gitRepositoryService.applySession(repository, leftCommit.session, seededMain).commitSha;
const merged = gitRepositoryService.applySession(repository, rightCommit.session, leftMain);
assert.equal(merged.merged, true);
assert.equal(gitRepositoryService.readFile(repository, merged.commitSha, 'left.txt'), 'left\n');
assert.equal(gitRepositoryService.readFile(repository, merged.commitSha, 'right.txt'), 'right\n');
updateGitRepositoryRecord(project.id, { mainCommitSha: merged.commitSha });
const conflictA = createCodingSessionRecord({ projectId: project.id, baseCommitSha: merged.commitSha, title: 'Conflict A' });
const conflictB = createCodingSessionRecord({ projectId: project.id, baseCommitSha: merged.commitSha, title: 'Conflict B' });
const a = commit(repository, project.id, conflictA, [{ path: 'shared.txt', content: 'a\n' }], 'A');
const b = commit(repository, project.id, conflictB, [{ path: 'shared.txt', content: 'b\n' }], 'B');
const aMain = gitRepositoryService.applySession(repository, a.session, merged.commitSha).commitSha;
assert.throws(() => gitRepositoryService.applySession(repository, b.session, aMain), (error: any) => {
assert.equal(error.code, 'MERGE_CONFLICT');
assert.deepEqual(error.conflicts, ['shared.txt']);
return true;
});
assert.equal(gitRepositoryService.resolveCommit(repository, b.commitSha), b.commitSha);
});
test('rejects unsafe source paths and stale writers', async () => {
for (const candidate of ['/absolute.ts', '../escape.ts', '.git/config', '.env', 'node_modules/x.js', 'api-secret.txt']) {
assert.throws(() => normalizeSourcePath(candidate));
}
const { project, repository, mainCommitSha } = await readyProject('Security');
const session = createCodingSessionRecord({ projectId: project.id, baseCommitSha: mainCommitSha, title: 'Writer' });
assert.throws(() => gitRepositoryService.commitChanges(repository, session, {
expectedHeadSha: '0'.repeat(40), files: [{ path: 'ok.ts', content: 'ok' }], summary: 'stale',
}), (error: any) => error.code === 'STALE_SESSION_HEAD');
});

49
tests/gitProvider.test.ts Normal file
View File

@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import { getGitProvider } from '../server/services/gitProvider';
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.ROKCET_GIT_API_URL;
delete process.env.ROKCET_GIT_HTTP_URL;
delete process.env.ROKCET_GIT_OWNER;
delete process.env.ROKCET_GIT_SERVICE_TOKEN;
});
test('uses the private Gitea/Forgejo-compatible provisioning contract without credential URLs', async () => {
process.env.ROKCET_GIT_API_URL = 'https://git.internal.test/api/v1';
process.env.ROKCET_GIT_HTTP_URL = 'https://git.internal.test';
process.env.ROKCET_GIT_OWNER = 'rokcet';
process.env.ROKCET_GIT_SERVICE_TOKEN = 'do-not-leak';
const calls: Array<{ url: string; init: RequestInit }> = [];
globalThis.fetch = (async (input: string | URL | Request, init: RequestInit = {}) => {
calls.push({ url: String(input), init });
if (init.method === 'POST') return new Response(JSON.stringify({ id: 42, private: true }), { status: 201, headers: { 'Content-Type': 'application/json' } });
return new Response(JSON.stringify({ message: 'not found' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
}) as typeof fetch;
const result = await getGitProvider().provisionPrivateRepository('rokcet/p-abc');
assert.equal(result.cloneUrl, 'https://git.internal.test/rokcet/p-abc.git');
assert.equal(result.cloneUrl.includes('do-not-leak'), false);
const create = calls.find((call) => call.init.method === 'POST');
assert.ok(create);
assert.equal(JSON.parse(String(create.init.body)).private, true);
});
test('normalizes provider failures without exposing upstream details', async () => {
process.env.ROKCET_GIT_API_URL = 'https://forge.internal.test/api/v1';
process.env.ROKCET_GIT_HTTP_URL = 'https://forge.internal.test';
process.env.ROKCET_GIT_OWNER = 'rokcet';
process.env.ROKCET_GIT_SERVICE_TOKEN = 'do-not-leak';
globalThis.fetch = (async () => new Response(JSON.stringify({ message: 'provider-specific sensitive detail' }), {
status: 403, headers: { 'Content-Type': 'application/json' },
})) as typeof fetch;
await assert.rejects(getGitProvider().provisionPrivateRepository('rokcet/p-abc'), (error: any) => {
assert.equal(error.code, 'ROKCET_GIT_AUTH_FAILED');
assert.equal(error.message.includes('sensitive'), false);
return true;
});
});

55
tests/migration.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { after, test } from 'node:test';
import { mkdtempSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { getAppState, listProjectRecords, resetRokcetDbForTests } from '../server/services/rokcetDb';
const root = mkdtempSync(path.join(os.tmpdir(), 'rokcet-migration-'));
const databasePath = path.join(root, 'legacy.sqlite');
process.env.ROKCET_DATABASE_PATH = databasePath;
after(() => {
resetRokcetDbForTests();
rmSync(root, { recursive: true, force: true });
});
test('extracts project metadata and removes legacy source history', () => {
const database = new DatabaseSync(databasePath);
database.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE rokcet_users (
id TEXT PRIMARY KEY, buckets_user_id TEXT NOT NULL UNIQUE, email TEXT NOT NULL,
name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE rokcet_workspaces (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL,
owner_user_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE rokcet_app_state (
user_id TEXT PRIMARY KEY, state_json TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
`);
const now = new Date().toISOString();
database.prepare('INSERT INTO rokcet_users VALUES (?, ?, ?, ?, ?, ?)').run('user-1', 'buckets-1', 'test@example.com', 'Test', now, now);
database.prepare('INSERT INTO rokcet_workspaces VALUES (?, ?, ?, ?, ?, ?)').run('workspace-1', 'Workspace', '', 'user-1', now, now);
database.prepare('INSERT INTO rokcet_app_state VALUES (?, ?, ?, ?, ?)').run('user-1', JSON.stringify({
projects: [{ id: 'project-1', workspaceId: 'workspace-1', name: 'Migrated', description: 'Kept', tags: ['Rokcet'], contract: { deployment: { provider: 'vercel' } } }],
artifacts: [{ id: 'artifact-1', files: [{ path: 'secret.ts', content: 'legacy' }] }],
snapshots: [{ id: 'fake-snapshot', commitHash: 'random' }],
sessions: [{ id: 'legacy-session' }],
activeWorkspaceId: 'workspace-1',
activeProjectId: 'project-1',
}), 7, now, now);
database.close();
const projects = listProjectRecords('user-1', 'workspace-1');
assert.equal(projects.length, 1);
assert.equal(projects[0].name, 'Migrated');
assert.equal(projects[0].status, 'pending_repository');
assert.deepEqual(getAppState('user-1')?.state, { activeWorkspaceId: 'workspace-1', activeProjectId: 'project-1' });
assert.equal(getAppState('user-1')?.revision, 8);
});