diff --git a/.specify/logs/audit/security.jsonl b/.specify/logs/audit/security.jsonl index e95ee10..22afd31 100644 --- a/.specify/logs/audit/security.jsonl +++ b/.specify/logs/audit/security.jsonl @@ -171,3 +171,12 @@ {"timestamp":"2026-07-11T06:51:41Z","trace_id":"e22f32cf-615f-416c-8148-7d0c1d438dc1","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"b837d3eb2da4298e443aad246d4a8b13092af5332cd49c58a44c8ac9144db2b7","output_hash":"c56dc3b5c36c8da3652796b65c47b52f36d7a4ac0fe60dcbf5632471575369f7"} {"timestamp":"2026-07-11T06:52:39Z","trace_id":"22155f0f-2095-4c9e-8624-4e476167d6ca","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"b837d3eb2da4298e443aad246d4a8b13092af5332cd49c58a44c8ac9144db2b7","output_hash":"c56dc3b5c36c8da3652796b65c47b52f36d7a4ac0fe60dcbf5632471575369f7"} {"timestamp":"2026-07-11T06:53:49Z","trace_id":"d70ead8a-1f79-473b-aefe-62746cda2ac4","harness":"H4-security","mode":"input","status":"blocked","action":"block","risk_level":"high","input_hash":"578eaad95e6441e9e3221d3117d11ef179a9c041b768a1de1ac9cc8b8f1320d7","output_hash":"578eaad95e6441e9e3221d3117d11ef179a9c041b768a1de1ac9cc8b8f1320d7"} +{"timestamp":"2026-07-11T06:56:50Z","trace_id":"trace-1783753010-281","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80","output_hash":"d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80"} +{"timestamp":"2026-07-11T06:56:51Z","trace_id":"trace-1783753011-757","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"1cdfd3f9eea8387b0b05dcd72f3f243282c3f3beebc49bebe74b1b2cfbc56bd5","output_hash":"d235b5973c2eda0dd9493e85a90452d33a3035e2e4f7042cb3559902fa35acf1"} +{"timestamp":"2026-07-11T06:57:52Z","trace_id":"trace-1783753072-1525","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"686d70223e0b33754a14bb18db12fe9331d04810578815bd456a2587e27329c6","output_hash":"686d70223e0b33754a14bb18db12fe9331d04810578815bd456a2587e27329c6"} +{"timestamp":"2026-07-11T06:59:07Z","trace_id":"trace-1783753147-1896","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"a8040c2049a45cc0a8f1b9b5bba42fdfb613ec0f4a8cc746ea330f5b5a560318","output_hash":"a8040c2049a45cc0a8f1b9b5bba42fdfb613ec0f4a8cc746ea330f5b5a560318"} +{"timestamp":"2026-07-11T06:59:07Z","trace_id":"trace-1783753147-2483","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"e1128c8ee75ea2135e9b3f8169c682b85b039a9e04a5be27a92323786b0dab41","output_hash":"34e3e8a172d525f18f5b432e7509011fdce5f721802bfd033b5e1c63038f24b0"} +{"timestamp":"2026-07-11T07:00:08Z","trace_id":"trace-1783753208-3246","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"6ec5725464f966f4d58c9e4ad45e982312fa2d3c97a1e4a1586a31101fa25ceb","output_hash":"6ec5725464f966f4d58c9e4ad45e982312fa2d3c97a1e4a1586a31101fa25ceb"} +{"timestamp":"2026-07-11T08:21:56Z","trace_id":"trace-1783758116-4061","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"7b77f4a52022a531c0a8598358a588846eda6a28da14ab394208572e076be2db","output_hash":"7b77f4a52022a531c0a8598358a588846eda6a28da14ab394208572e076be2db"} +{"timestamp":"2026-07-11T08:21:56Z","trace_id":"trace-1783758116-4542","harness":"H4-security","mode":"input","status":"pass","action":"allow","risk_level":"low","input_hash":"f593d60a891794f31f42af1392f179a2ba7cf98fb280a07aecc0d089efeece25","output_hash":"f593d60a891794f31f42af1392f179a2ba7cf98fb280a07aecc0d089efeece25"} +{"timestamp":"2026-07-11T08:23:30Z","trace_id":"trace-1783758210-5443","harness":"H4-security","mode":"output","status":"pass","action":"allow","risk_level":"low","input_hash":"9eb9836790c7004a65222047c0bfe8cc10489fc161356a661a1d74842c7c31e2","output_hash":"9eb9836790c7004a65222047c0bfe8cc10489fc161356a661a1d74842c7c31e2"} diff --git a/.specify/logs/cost/metrics.jsonl b/.specify/logs/cost/metrics.jsonl index 688a0f0..7d3abc9 100644 --- a/.specify/logs/cost/metrics.jsonl +++ b/.specify/logs/cost/metrics.jsonl @@ -35,3 +35,6 @@ {"timestamp": "2026-07-11T06:32:57Z", "trace_id": "07fbb2de-5044-4cad-90de-ac87387d74e8", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "degraded", "exit_code": 0, "latency_ms": 647, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "2b844ca284348014088be9eea9455a4bb00ce40960799ca974c239bd9a562cca", "output_hash": "a96d7829c4e1453035563d1e36c0e1cc5e8aabcf18ecb79e0934a54c53df579f"} {"timestamp": "2026-07-11T06:34:56Z", "trace_id": "092ded09-3bbf-4469-9438-f4fe5b9e3d61", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "degraded", "exit_code": 0, "latency_ms": 9940, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "2b844ca284348014088be9eea9455a4bb00ce40960799ca974c239bd9a562cca", "output_hash": "a96d7829c4e1453035563d1e36c0e1cc5e8aabcf18ecb79e0934a54c53df579f"} {"timestamp": "2026-07-11T06:38:58Z", "trace_id": "8cb7ab8b-c84e-425c-a761-673888ebce72", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "success", "exit_code": 0, "latency_ms": 124131, "input_tokens": 2435, "output_tokens": 1400, "total_tokens": 3835, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80", "output_hash": "62dd13b6753d71fd098d5929443afe324b0b8e965f43e97bf88d8eddcf797ecd"} +{"timestamp": "2026-07-11T06:57:57Z", "trace_id": "6d02ac98-35ca-4629-90d0-2086b0575cdd", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "degraded", "exit_code": 0, "latency_ms": 66358, "input_tokens": 2476, "output_tokens": 774, "total_tokens": 3250, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "d445befd39096a8026a34bd3284860a37e41797b21c9aad95fe18ca084c49f80", "output_hash": "686d70223e0b33754a14bb18db12fe9331d04810578815bd456a2587e27329c6"} +{"timestamp": "2026-07-11T07:00:15Z", "trace_id": "d5a68140-987a-46e3-985d-3d0b098e6156", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "degraded", "exit_code": 0, "latency_ms": 67755, "input_tokens": 2241, "output_tokens": 911, "total_tokens": 3152, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "a8040c2049a45cc0a8f1b9b5bba42fdfb613ec0f4a8cc746ea330f5b5a560318", "output_hash": "6ec5725464f966f4d58c9e4ad45e982312fa2d3c97a1e4a1586a31101fa25ceb"} +{"timestamp": "2026-07-11T08:23:36Z", "trace_id": "d3054a9b-967c-4c9b-9c93-cff40abd1116", "harness": "H6-agentops", "agent": "goal.orchestrator", "step": "local-worker-cloud-reviewer", "status": "degraded", "exit_code": 0, "latency_ms": 100369, "input_tokens": 2448, "output_tokens": 1400, "total_tokens": 3848, "cost_estimate": 0.0, "cost_source": "provider_usage_logs", "input_hash": "7b77f4a52022a531c0a8598358a588846eda6a28da14ab394208572e076be2db", "output_hash": "9eb9836790c7004a65222047c0bfe8cc10489fc161356a661a1d74842c7c31e2"} diff --git a/.specify/logs/level5/provider-usage.jsonl b/.specify/logs/level5/provider-usage.jsonl index 7d5d131..511412e 100644 --- a/.specify/logs/level5/provider-usage.jsonl +++ b/.specify/logs/level5/provider-usage.jsonl @@ -50,3 +50,6 @@ {"timestamp": "2026-07-11T06:15:26Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 126, "output_tokens": 213, "total_tokens": 339, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 12167, "status": "success"} {"timestamp": "2026-07-11T06:17:12Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 499, "output_tokens": 454, "total_tokens": 953, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 27972, "status": "success"} {"timestamp": "2026-07-11T06:38:28Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 2435, "output_tokens": 1400, "total_tokens": 3835, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 93084, "status": "success"} +{"timestamp": "2026-07-11T06:57:52Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 2476, "output_tokens": 774, "total_tokens": 3250, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 61199, "status": "success"} +{"timestamp": "2026-07-11T07:00:08Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 2241, "output_tokens": 911, "total_tokens": 3152, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 60429, "status": "success"} +{"timestamp": "2026-07-11T08:23:30Z", "harness": "L5-provider-telemetry", "provider": "ollama", "model": "ornith:9b", "run_id": "adhoc", "step": "generate", "role": "generate", "input_tokens": 2448, "output_tokens": 1400, "total_tokens": 3848, "cost_usd": 0.0, "cost_source": "ollama_local_real_tokens", "latency_ms": 94149, "status": "success"} diff --git a/packages/casan-control-panel/backend/src/goals/goals.controller.ts b/packages/casan-control-panel/backend/src/goals/goals.controller.ts index 984ee9d..b7834f2 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.controller.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.controller.ts @@ -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) { + return ok(this.service.createProject(body, actorFromHeaders(headers))); + } + @Get() list(@Headers() headers: Record, @Query('limit') limit?: string) { return ok(this.service.list(actorFromHeaders(headers), Number(limit) || 20)); diff --git a/packages/casan-control-panel/backend/src/goals/goals.service.ts b/packages/casan-control-panel/backend/src/goals/goals.service.ts index 25abd58..abaebd6 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.service.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts @@ -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> }>(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); diff --git a/packages/casan-control-panel/backend/test/goals.test.ts b/packages/casan-control-panel/backend/test/goals.test.ts index 37d2481..c9d616c 100644 --- a/packages/casan-control-panel/backend/test/goals.test.ts +++ b/packages/casan-control-panel/backend/test/goals.test.ts @@ -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'); diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts index 3b5a9c4..f26e569 100644 --- a/packages/casan-control-panel/frontend/src/lib/api.ts +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -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('goals/projects', body, actorHeaders(actor)), startGoal: (actor: SettingsActor, goal: string, projectId: string) => post('goals', { goal, projectId }, actorHeaders(actor)), goal: (actor: SettingsActor, id: string) => diff --git a/packages/casan-control-panel/frontend/src/pages/Goals.tsx b/packages/casan-control-panel/frontend/src/pages/Goals.tsx index 165454d..3d2f104 100644 --- a/packages/casan-control-panel/frontend/src/pages/Goals.tsx +++ b/packages/casan-control-panel/frontend/src/pages/Goals.tsx @@ -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() { -