feat: updade workspace
This commit is contained in:
@@ -12,6 +12,11 @@ export class GoalsController {
|
||||
return ok(await this.service.start(body, actorFromHeaders(headers)));
|
||||
}
|
||||
|
||||
@Get('projects')
|
||||
projects(@Headers() headers: Record<string, string | string[] | undefined>) {
|
||||
return ok(this.service.projects(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, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
@@ -8,6 +8,14 @@ import type { SettingsActor } from '../settings/settings.service.js';
|
||||
|
||||
export interface GoalStartInput {
|
||||
goal: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface GoalProject {
|
||||
project_id: string;
|
||||
domain: string;
|
||||
domain_root: string;
|
||||
context_roots: string[];
|
||||
}
|
||||
|
||||
export interface GoalStage {
|
||||
@@ -23,7 +31,7 @@ export interface GoalJob {
|
||||
id: string;
|
||||
trace_id: string;
|
||||
goal: string;
|
||||
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed';
|
||||
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed' | 'requires_approval';
|
||||
actor: string;
|
||||
tenant: string;
|
||||
project: string;
|
||||
@@ -42,6 +50,9 @@ export interface GoalJob {
|
||||
audit_hash?: string;
|
||||
local_usage?: Record<string, number>;
|
||||
cloud_usage?: Record<string, number>;
|
||||
workspace?: GoalProject;
|
||||
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
|
||||
approval?: { id: string; status: string; action: string };
|
||||
}
|
||||
|
||||
interface ModelConnection {
|
||||
@@ -67,6 +78,7 @@ const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash
|
||||
const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
|
||||
const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py');
|
||||
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
|
||||
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json');
|
||||
|
||||
function parseJson<T>(value: string): T | null {
|
||||
try {
|
||||
@@ -86,7 +98,8 @@ export class GoalsService {
|
||||
private readonly startWindows = new Map<string, number[]>();
|
||||
|
||||
async start(input: GoalStartInput, actor: SettingsActor): Promise<GoalJob> {
|
||||
this.requireRead(actor);
|
||||
const workspace = this.resolveProject(String(input.projectId ?? ''));
|
||||
this.requireRead(actor, workspace.project_id);
|
||||
const goal = String(input.goal ?? '').trim();
|
||||
if (goal.length < 10 || goal.length > 8000) {
|
||||
throw new BadRequestException('GOAL_LENGTH_INVALID');
|
||||
@@ -115,7 +128,8 @@ export class GoalsService {
|
||||
status: 'queued',
|
||||
actor: actor.actor,
|
||||
tenant: actor.tenant,
|
||||
project: actor.project,
|
||||
project: workspace.project_id,
|
||||
workspace,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
local_provider: local?.id || 'local-policy',
|
||||
@@ -156,24 +170,39 @@ export class GoalsService {
|
||||
return job;
|
||||
}
|
||||
|
||||
projects(actor: SettingsActor): { count: number; projects: GoalProject[] } {
|
||||
const projects = this.registeredProjects().filter((project) => {
|
||||
try {
|
||||
this.requireRead(actor, project.project_id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return { count: projects.length, projects };
|
||||
}
|
||||
|
||||
get(id: string, actor: SettingsActor): GoalJob {
|
||||
this.requireRead(actor);
|
||||
if (!/^[a-f0-9-]{36}$/.test(id)) throw new NotFoundException('GOAL_NOT_FOUND');
|
||||
const path = this.jobPath(actor.tenant, id);
|
||||
if (!existsSync(path)) throw new NotFoundException('GOAL_NOT_FOUND');
|
||||
const job = parseJson<GoalJob>(readFileSync(path, 'utf8'));
|
||||
if (!job || job.tenant !== actor.tenant) throw new NotFoundException('GOAL_NOT_FOUND');
|
||||
this.requireRead(actor, job.project);
|
||||
return job;
|
||||
}
|
||||
|
||||
list(actor: SettingsActor, limit = 20): { count: number; goals: GoalJob[] } {
|
||||
this.requireRead(actor);
|
||||
this.requireRead(actor, actor.project);
|
||||
const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant));
|
||||
if (!existsSync(directory)) return { count: 0, goals: [] };
|
||||
const goals = readdirSync(directory)
|
||||
.filter((name) => /^[a-f0-9-]{36}\.json$/.test(name))
|
||||
.map((name) => parseJson<GoalJob>(readFileSync(join(directory, name), 'utf8')))
|
||||
.filter((job): job is GoalJob => Boolean(job && job.tenant === actor.tenant))
|
||||
.filter((job) => {
|
||||
try { this.requireRead(actor, job.project); return true; } catch { return false; }
|
||||
})
|
||||
.sort((left, right) => right.created_at.localeCompare(left.created_at));
|
||||
return { count: goals.length, goals: goals.slice(0, Math.max(1, Math.min(limit, 100))) };
|
||||
}
|
||||
@@ -182,6 +211,34 @@ export class GoalsService {
|
||||
return join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(tenant), `${id}.json`);
|
||||
}
|
||||
|
||||
private registeredProjects(): GoalProject[] {
|
||||
const parsed = parseJson<{ projects?: Array<Record<string, unknown>> }>(readFileSync(PROJECT_REGISTRY, 'utf8'));
|
||||
const root = realpathSync(APP_ROOT);
|
||||
return (parsed?.projects ?? []).filter((entry) => entry.status === 'active').map((entry) => {
|
||||
const projectId = String(entry.project_id ?? '');
|
||||
const domainRoot = String(entry.domain_root ?? '');
|
||||
const rawRoots = Array.isArray(entry.context_roots) ? entry.context_roots.map(String) : [domainRoot];
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(projectId) || !domainRoot || rawRoots.length === 0) {
|
||||
throw new InternalServerErrorException('GOAL_PROJECT_REGISTRY_INVALID');
|
||||
}
|
||||
const contextRoots = rawRoots.map((relative) => {
|
||||
const absolute = realpathSync(join(APP_ROOT, relative));
|
||||
if (!(absolute === root || absolute.startsWith(`${root}/`)) || !statSync(absolute).isDirectory() && !statSync(absolute).isFile()) {
|
||||
throw new InternalServerErrorException('GOAL_PROJECT_CONTEXT_ROOT_DENIED');
|
||||
}
|
||||
return relative;
|
||||
});
|
||||
return { project_id: projectId, domain: String(entry.domain ?? projectId), domain_root: domainRoot, context_roots: contextRoots };
|
||||
});
|
||||
}
|
||||
|
||||
private resolveProject(projectId: string): GoalProject {
|
||||
if (!projectId) throw new BadRequestException('GOAL_PROJECT_REQUIRED');
|
||||
const project = this.registeredProjects().find((entry) => entry.project_id === projectId);
|
||||
if (!project) throw new BadRequestException('GOAL_PROJECT_NOT_ALLOWED');
|
||||
return project;
|
||||
}
|
||||
|
||||
private connections(actor: SettingsActor): ModelConnection[] {
|
||||
const payload = this.runPython(CONNECTIONS_CLI, ['list'], { CASAN_TENANT_ID: actor.tenant || 'default' });
|
||||
const parsed = parseJson<ConnectionList>(payload);
|
||||
@@ -259,10 +316,10 @@ export class GoalsService {
|
||||
}
|
||||
}
|
||||
|
||||
private requireRead(actor: SettingsActor): void {
|
||||
private requireRead(actor: SettingsActor, targetProject: string): void {
|
||||
try {
|
||||
execFileSync('python3', [RBAC_CLI, 'check', '--role', actor.role, '--resource', 'monitoring', '--action', 'read',
|
||||
'--role-project', actor.project, '--target-project', actor.project,
|
||||
'--role-project', actor.project, '--target-project', targetProject,
|
||||
'--role-tenant', actor.tenant, '--target-tenant', actor.tenant], {
|
||||
cwd: APP_ROOT,
|
||||
env: process.env,
|
||||
|
||||
Reference in New Issue
Block a user