feat: updade workspace

This commit is contained in:
thanhnv
2026-07-11 15:56:31 +09:00
parent 4fc72332f5
commit 193a449829
120 changed files with 868 additions and 350 deletions
@@ -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,