feat: create new project added

This commit is contained in:
thanhnv
2026-07-11 22:42:42 +09:00
parent 193a449829
commit 159022c73f
11 changed files with 405 additions and 28 deletions
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Headers, Inject, Param, Post, Query } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js';
import { GoalsService, type GoalStartInput } from './goals.service.js';
import { GoalsService, type GoalProjectCreateInput, type GoalStartInput } from './goals.service.js';
@Controller('api/v1/goals')
export class GoalsController {
@@ -17,6 +17,11 @@ export class GoalsController {
return ok(this.service.projects(actorFromHeaders(headers)));
}
@Post('projects')
createProject(@Body() body: GoalProjectCreateInput, @Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.service.createProject(body, actorFromHeaders(headers)));
}
@Get()
list(@Headers() headers: Record<string, string | string[] | undefined>, @Query('limit') limit?: string) {
return ok(this.service.list(actorFromHeaders(headers), Number(limit) || 20));
@@ -1,5 +1,5 @@
import { BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { execFileSync, spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
@@ -18,6 +18,11 @@ export interface GoalProject {
context_roots: string[];
}
export interface GoalProjectCreateInput {
projectId: string;
domain: string;
}
export interface GoalStage {
id: string;
status: string;
@@ -53,6 +58,19 @@ export interface GoalJob {
workspace?: GoalProject;
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string };
reviewer_attempts?: GoalReviewerAttempt[];
}
export interface GoalReviewerAttempt {
attempt: number;
provider: string;
model: string;
status: 'pass' | 'failed';
reason: string;
retryable: boolean;
started_at: string;
finished_at: string;
latency_ms: number;
}
interface ModelConnection {
@@ -108,17 +126,32 @@ export class GoalsService {
const connections = this.connections(actor);
const local = connections.find((connection) => connection.connected && connection.kind === 'local');
const cloud = connections.find((connection) => connection.connected && connection.kind === 'cloud')
?? connections.find((connection) => connection.connected && connection.kind === 'gateway');
const cloudConnections = connections.filter((connection) => connection.connected && connection.kind === 'cloud' && (connection.defaultModel || connection.models[0]));
const cloud = cloudConnections[0];
const gateway = connections.find((connection) => connection.connected && connection.kind === 'gateway');
const account = await this.accountReviewer();
const localModel = local?.defaultModel || local?.models[0] || 'ornith:9b';
const cloudModel = cloud?.defaultModel || cloud?.models[0] || '';
const cloudModel = cloud?.defaultModel || cloud?.models[0] || gateway?.defaultModel || gateway?.models[0] || '';
const localRuntime = local ? this.runtime(local.id, localModel, actor) : {
CASAN_CHAT_SELECTED_MODEL: `ollama:${localModel}`,
CASAN_OLLAMA_HOST: process.env.CASAN_OLLAMA_HOST || 'host.docker.internal:11434',
OLLAMA_HOST: process.env.OLLAMA_HOST || 'host.docker.internal:11434',
};
const cloudRuntime = cloud ? this.runtime(cloud.id, cloudModel, actor) : {};
const selectedReviewer = cloud ?? gateway;
const cloudRuntime = selectedReviewer ? this.runtime(selectedReviewer.id, cloudModel, actor) : {};
const gatewayRuntime = gateway
? this.runtime(gateway.id, gateway.defaultModel || gateway.models[0] || '', actor)
: {};
const gatewayCredentials = Object.fromEntries(
Object.entries(gatewayRuntime).filter(([key]) => key.startsWith('CASAN_OPENAI_COMPATIBLE_')),
);
const cloudCandidates = cloudConnections.map((connection) => {
const model = connection.defaultModel || connection.models[0];
const runtime = this.runtime(connection.id, model, actor);
return { model: String(runtime.CASAN_CHAT_SELECTED_MODEL || model), runtime };
});
const cloudCredentials = Object.assign({}, ...cloudCandidates.map(({ runtime }) => runtime));
const gatewayModels = gateway?.models.slice(0, 5).map((model) => `openai-compatible:${model}`) ?? [];
const id = randomUUID();
const timestamp = new Date().toISOString();
const job: GoalJob = {
@@ -134,11 +167,11 @@ export class GoalsService {
updated_at: timestamp,
local_provider: local?.id || 'local-policy',
local_model: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
cloud_provider: account ? `${account}-account` : (cloud?.id || 'unavailable'),
cloud_provider: account ? `${account}-account` : (selectedReviewer?.id || 'unavailable'),
cloud_model: account ? `${account}-account-default` : String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
stages: [
{ id: 'local-worker', status: 'queued', detail: 'Waiting for local worker', provider: local?.id || 'local-policy', model: localModel },
{ id: 'cloud-reviewer', status: 'queued', detail: account || cloud ? 'Waiting for cloud reviewer' : 'No cloud connection; local fallback will be explicit', provider: account ? `${account}-account` : (cloud?.id || 'unavailable'), model: account ? `${account}-account-default` : cloudModel },
{ id: 'cloud-reviewer', status: 'queued', detail: account || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: account ? `${account}-account` : (selectedReviewer?.id || 'local-policy'), model: account ? `${account}-account-default` : cloudModel || localModel },
],
};
const jobFile = this.jobPath(actor.tenant, id);
@@ -151,7 +184,9 @@ export class GoalsService {
env: {
...process.env,
...localRuntime,
...cloudCredentials,
...cloudRuntime,
...gatewayCredentials,
CASAN_TENANT_ID: actor.tenant || 'default',
CASAN_GOAL_LOCAL_MODEL: job.local_model,
CASAN_GOAL_CLOUD_MODEL: job.cloud_model,
@@ -159,6 +194,11 @@ export class GoalsService {
CASAN_GOAL_CLOUD_PROVIDER: job.cloud_provider,
CASAN_GOAL_ACCOUNT_PROVIDER: account || '',
CASAN_GOAL_CLOUD_FALLBACK_MODEL: String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
CASAN_GOAL_CLOUD_MODELS: cloudCandidates.map(({ model }) => model).join(','),
CASAN_GOAL_OMNIROUTE_MODELS: gatewayModels.join(','),
CASAN_GOAL_LOCAL_REVIEWER_MODEL: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
CASAN_GOAL_REVIEWER_MAX_ATTEMPTS: process.env.CASAN_GOAL_REVIEWER_MAX_ATTEMPTS || '8',
CASAN_GOAL_REVIEWER_DEADLINE_SEC: process.env.CASAN_GOAL_REVIEWER_DEADLINE_SEC || '600',
},
stdio: 'ignore',
});
@@ -182,6 +222,58 @@ export class GoalsService {
return { count: projects.length, projects };
}
createProject(input: GoalProjectCreateInput, actor: SettingsActor): GoalProject {
if (actor.role !== 'org-admin') throw new ForbiddenException('GOAL_PROJECT_CREATE_DENIED');
const projectId = String(input.projectId ?? '').trim();
const domain = String(input.domain ?? '').trim();
if (!/^[A-Za-z][A-Za-z0-9._-]{2,63}$/.test(projectId)) {
throw new BadRequestException('GOAL_PROJECT_ID_INVALID');
}
if (domain.length < 3 || domain.length > 100) {
throw new BadRequestException('GOAL_PROJECT_DOMAIN_INVALID');
}
const lockPath = `${PROJECT_REGISTRY}.lock`;
let lockFd: number | undefined;
for (let attempt = 0; attempt < 100 && lockFd === undefined; attempt += 1) {
try { lockFd = openSync(lockPath, 'wx', 0o600); } catch {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
}
}
if (lockFd === undefined) throw new HttpException('GOAL_PROJECT_REGISTRY_BUSY', HttpStatus.CONFLICT);
let absoluteRoot = '';
let createdRoot = false;
try {
const registry = parseJson<{ projects?: Array<Record<string, unknown>> }>(readFileSync(PROJECT_REGISTRY, 'utf8'));
if (!registry || !Array.isArray(registry.projects)) throw new InternalServerErrorException('GOAL_PROJECT_REGISTRY_INVALID');
if (registry.projects.some((entry) => String(entry.project_id) === projectId)) throw new BadRequestException('GOAL_PROJECT_ALREADY_EXISTS');
const canonicalRoot = realpathSync(APP_ROOT);
const appsRoot = realpathSync(join(APP_ROOT, 'apps'));
if (!appsRoot.startsWith(`${canonicalRoot}/`)) throw new ForbiddenException('GOAL_PROJECT_PARENT_DENIED');
const projectsRoot = join(appsRoot, 'projects');
if (!existsSync(projectsRoot)) mkdirSync(projectsRoot, { mode: 0o750 });
const canonicalProjects = realpathSync(projectsRoot);
if (!canonicalProjects.startsWith(`${canonicalRoot}/`)) throw new ForbiddenException('GOAL_PROJECT_PARENT_DENIED');
absoluteRoot = join(canonicalProjects, projectId);
mkdirSync(absoluteRoot, { mode: 0o750 });
createdRoot = true;
const relativeRoot = join('apps', 'projects', projectId);
const entry = { project_id: projectId, domain, domain_root: relativeRoot, context_roots: [relativeRoot], harness_package: 'fpt-casan-sdd-harness', harness_version: '1.0.0', status: 'active' };
const updated = { ...registry, projects: [...registry.projects, entry] };
const temporary = `${PROJECT_REGISTRY}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(temporary, `${JSON.stringify(updated, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
renameSync(temporary, PROJECT_REGISTRY);
return { project_id: projectId, domain, domain_root: relativeRoot, context_roots: [relativeRoot] };
} catch (error) {
if (createdRoot && absoluteRoot) rmSync(absoluteRoot, { recursive: true, force: true });
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException('GOAL_PROJECT_CREATE_FAILED');
} finally {
closeSync(lockFd);
unlinkSync(lockPath);
}
}
get(id: string, actor: SettingsActor): GoalJob {
if (!/^[a-f0-9-]{36}$/.test(id)) throw new NotFoundException('GOAL_NOT_FOUND');
const path = this.jobPath(actor.tenant, id);
@@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { GoalsService } from '../src/goals/goals.service.js';
const root = join(import.meta.dirname, '..', '..', '..', '..');
@@ -24,6 +24,13 @@ test('goal start rejects project ids outside the server registry before model ro
);
});
test('goal project creation is restricted to organization administrators', () => {
assert.throws(
() => new GoalsService().createProject({ projectId: 'denied-project', domain: 'Denied Project' }, { ...admin, role: 'viewer' }),
ForbiddenException,
);
});
test('H1 creates a bounded manifest and routes workspace side effects to approval without writing source', () => {
const stateRoot = mkdtempSync(join(tmpdir(), 'casan-goal-context-'));
const tenantRoot = join(stateRoot, 'tenants');