feat: create new project added
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -293,6 +293,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;
|
||||
}
|
||||
|
||||
export interface GoalProject {
|
||||
@@ -481,6 +494,8 @@ export const api = {
|
||||
post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)),
|
||||
goalProjects: (actor: SettingsActor) =>
|
||||
getWithHeaders<{ count: number; projects: GoalProject[] }>('goals/projects', actorHeaders(actor)),
|
||||
createGoalProject: (actor: SettingsActor, body: { projectId: string; domain: string }) =>
|
||||
post<GoalProject>('goals/projects', body, actorHeaders(actor)),
|
||||
startGoal: (actor: SettingsActor, goal: string, projectId: string) =>
|
||||
post<GoalJob>('goals', { goal, projectId }, actorHeaders(actor)),
|
||||
goal: (actor: SettingsActor, id: string) =>
|
||||
|
||||
@@ -46,6 +46,9 @@ function WorkerCard({ title, subtitle, status, detail, provider, model }: {
|
||||
export function Goals() {
|
||||
const [goal, setGoal] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [newProjectId, setNewProjectId] = useState('');
|
||||
const [newProjectDomain, setNewProjectDomain] = useState('');
|
||||
const [actor] = useState(DEFAULT_ACTOR);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get('id') ?? '';
|
||||
@@ -73,6 +76,16 @@ export function Goals() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
const createProject = useMutation({
|
||||
mutationFn: () => api.createGoalProject(actor, { projectId: newProjectId.trim(), domain: newProjectDomain.trim() }),
|
||||
onSuccess: (project) => {
|
||||
setProjectId(project.project_id);
|
||||
setNewProjectId('');
|
||||
setNewProjectDomain('');
|
||||
setCreatingProject(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ['goal-projects'] });
|
||||
},
|
||||
});
|
||||
|
||||
const selected = selectedQuery.data;
|
||||
const localStage = selected?.stages.find((stage) => stage.id === 'local-worker');
|
||||
@@ -89,15 +102,32 @@ export function Goals() {
|
||||
</section>
|
||||
|
||||
<Card title="Give CASAN an objective">
|
||||
<label className="mb-4 block">
|
||||
<span className="text-sm font-semibold text-slate-800">Project workspace</span>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-semibold text-slate-800">Project workspace</span>
|
||||
<button type="button" onClick={() => setCreatingProject((value) => !value)} className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-xs font-semibold text-indigo-700 transition hover:border-indigo-300 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||
{creatingProject ? 'Cancel' : '+ New project'}
|
||||
</button>
|
||||
</div>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">Only server-registered, allowlisted roots are available. Models receive a bounded redacted snapshot—not filesystem access.</span>
|
||||
<select value={effectiveProject} onChange={(event) => setProjectId(event.target.value)} disabled={projectsQuery.isLoading || projects.length === 0} className="mt-2 w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100 disabled:bg-slate-100">
|
||||
{projects.map((project) => <option key={project.project_id} value={project.project_id}>{project.domain} · {project.project_id}</option>)}
|
||||
</select>
|
||||
{projectsQuery.isError && <span role="alert" className="mt-2 block text-xs font-medium text-rose-700">Could not load the allowlisted project registry.</span>}
|
||||
{effectiveProject && <span className="mt-2 block font-mono text-[11px] text-slate-400">Context roots: {projects.find((project) => project.project_id === effectiveProject)?.context_roots.join(', ')}</span>}
|
||||
</label>
|
||||
{creatingProject && (
|
||||
<div className="mt-4 rounded-2xl border border-indigo-200 bg-indigo-50/60 p-4">
|
||||
<div className="text-sm font-semibold text-indigo-950">Register a new governed workspace</div>
|
||||
<p className="mt-1 text-xs leading-5 text-indigo-800/75">CASAN creates an empty directory under <span className="font-mono">apps/projects/<project-id></span>. Absolute paths and external roots are never accepted.</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<label><span className="text-xs font-semibold text-slate-700">Project ID</span><input value={newProjectId} onChange={(event) => setNewProjectId(event.target.value)} placeholder="customer-portal" maxLength={64} className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-sm outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" /></label>
|
||||
<label><span className="text-xs font-semibold text-slate-700">Display name</span><input value={newProjectDomain} onChange={(event) => setNewProjectDomain(event.target.value)} placeholder="Customer Portal" maxLength={100} className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-sm outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100" /></label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end"><button type="button" disabled={!/^[A-Za-z][A-Za-z0-9._-]{2,63}$/.test(newProjectId.trim()) || newProjectDomain.trim().length < 3 || createProject.isPending} onClick={() => createProject.mutate()} className="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300">{createProject.isPending ? 'Creating…' : 'Create and select'}</button></div>
|
||||
{createProject.isError && <div role="alert" className="mt-3 rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs font-medium text-rose-700">{errorMessage(createProject.error)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={goal}
|
||||
onChange={(event) => setGoal(event.target.value)}
|
||||
@@ -143,6 +173,7 @@ export function Goals() {
|
||||
{selected.error && <div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{selected.error}</div>}
|
||||
{selected.approval && <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"><div className="font-semibold">Workspace side effect withheld</div><p className="mt-1 leading-6">Proposal <span className="font-mono">{selected.approval.id}</span> is {selected.approval.status}. No source file or runtime action was changed.</p><Link to="/approvals" className="mt-3 inline-flex rounded-lg bg-amber-700 px-3 py-2 text-xs font-semibold text-white hover:bg-amber-800 focus:outline-none focus:ring-2 focus:ring-amber-500">Open Approvals</Link></div>}
|
||||
{selected.local_draft && <details className="mt-5 rounded-xl border border-slate-200 p-4"><summary className="cursor-pointer text-sm font-semibold text-slate-700">Inspect local worker draft</summary><div className="mt-4"><MarkdownText text={selected.local_draft} /></div></details>}
|
||||
{selected.reviewer_attempts && selected.reviewer_attempts.length > 0 && <details className="mt-5 rounded-xl border border-slate-200 p-4"><summary className="cursor-pointer text-sm font-semibold text-slate-700">Fallback attempt ledger ({selected.reviewer_attempts.length})</summary><div className="mt-4 space-y-2">{selected.reviewer_attempts.map((attempt) => <div key={attempt.attempt} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-slate-50 px-3 py-2 text-xs"><span className="font-semibold text-slate-700">{attempt.attempt}. reviewer</span><span className="font-mono text-slate-500">{attempt.provider} · {attempt.model}</span><StatusBadge value={attempt.status} /></div>)}</div></details>}
|
||||
</Card>
|
||||
|
||||
<TraceExplorer traceId={selected.trace_id} />
|
||||
|
||||
Reference in New Issue
Block a user