|
|
|
@@ -0,0 +1,250 @@
|
|
|
|
|
import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
|
|
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
|
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import { APP_ROOT } from '../common/app-root.js';
|
|
|
|
|
import type { SettingsActor } from '../settings/settings.service.js';
|
|
|
|
|
|
|
|
|
|
export interface GoalStartInput {
|
|
|
|
|
goal: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GoalStage {
|
|
|
|
|
id: string;
|
|
|
|
|
status: string;
|
|
|
|
|
detail: string;
|
|
|
|
|
provider: string;
|
|
|
|
|
model: string;
|
|
|
|
|
updated_at?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GoalJob {
|
|
|
|
|
id: string;
|
|
|
|
|
trace_id: string;
|
|
|
|
|
goal: string;
|
|
|
|
|
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed';
|
|
|
|
|
actor: string;
|
|
|
|
|
tenant: string;
|
|
|
|
|
project: string;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
started_at?: string;
|
|
|
|
|
finished_at?: string;
|
|
|
|
|
local_provider: string;
|
|
|
|
|
local_model: string;
|
|
|
|
|
cloud_provider: string;
|
|
|
|
|
cloud_model: string;
|
|
|
|
|
stages: GoalStage[];
|
|
|
|
|
local_draft?: string;
|
|
|
|
|
result?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
audit_hash?: string;
|
|
|
|
|
local_usage?: Record<string, number>;
|
|
|
|
|
cloud_usage?: Record<string, number>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ModelConnection {
|
|
|
|
|
id: string;
|
|
|
|
|
kind: 'local' | 'cloud' | 'gateway';
|
|
|
|
|
connected: boolean;
|
|
|
|
|
models: string[];
|
|
|
|
|
defaultModel: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ConnectionList {
|
|
|
|
|
success: boolean;
|
|
|
|
|
connections: ModelConnection[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AccountProviderStatus {
|
|
|
|
|
id: 'codex' | 'claude';
|
|
|
|
|
available: boolean;
|
|
|
|
|
loggedIn: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
|
|
|
function parseJson<T>(value: string): T | null {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(value) as T;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeTenant(value: string): string {
|
|
|
|
|
const safe = value.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80);
|
|
|
|
|
return safe || 'default';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class GoalsService {
|
|
|
|
|
async start(input: GoalStartInput, actor: SettingsActor): Promise<GoalJob> {
|
|
|
|
|
this.requireRead(actor);
|
|
|
|
|
const goal = String(input.goal ?? '').trim();
|
|
|
|
|
if (goal.length < 10 || goal.length > 8000) {
|
|
|
|
|
throw new BadRequestException('GOAL_LENGTH_INVALID');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 account = await this.accountReviewer();
|
|
|
|
|
const localModel = local?.defaultModel || local?.models[0] || 'ornith:9b';
|
|
|
|
|
const cloudModel = cloud?.defaultModel || cloud?.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 id = randomUUID();
|
|
|
|
|
const timestamp = new Date().toISOString();
|
|
|
|
|
const job: GoalJob = {
|
|
|
|
|
id,
|
|
|
|
|
trace_id: id,
|
|
|
|
|
goal,
|
|
|
|
|
status: 'queued',
|
|
|
|
|
actor: actor.actor,
|
|
|
|
|
tenant: actor.tenant,
|
|
|
|
|
project: actor.project,
|
|
|
|
|
created_at: timestamp,
|
|
|
|
|
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_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 },
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
const jobFile = this.jobPath(actor.tenant, id);
|
|
|
|
|
mkdirSync(join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant)), { recursive: true, mode: 0o700 });
|
|
|
|
|
writeFileSync(jobFile, `${JSON.stringify(job, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
|
|
|
chmodSync(jobFile, 0o600);
|
|
|
|
|
|
|
|
|
|
const child = spawn('python3', [ORCHESTRATOR_CLI, '--job-file', jobFile], {
|
|
|
|
|
cwd: APP_ROOT,
|
|
|
|
|
env: {
|
|
|
|
|
...process.env,
|
|
|
|
|
...localRuntime,
|
|
|
|
|
...cloudRuntime,
|
|
|
|
|
CASAN_TENANT_ID: actor.tenant || 'default',
|
|
|
|
|
CASAN_GOAL_LOCAL_MODEL: job.local_model,
|
|
|
|
|
CASAN_GOAL_CLOUD_MODEL: job.cloud_model,
|
|
|
|
|
CASAN_GOAL_LOCAL_PROVIDER: job.local_provider,
|
|
|
|
|
CASAN_GOAL_CLOUD_PROVIDER: job.cloud_provider,
|
|
|
|
|
CASAN_GOAL_ACCOUNT_PROVIDER: account || '',
|
|
|
|
|
CASAN_GOAL_CLOUD_FALLBACK_MODEL: String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
|
|
|
|
|
},
|
|
|
|
|
stdio: 'ignore',
|
|
|
|
|
});
|
|
|
|
|
child.on('error', () => {
|
|
|
|
|
const failed = { ...job, status: 'failed' as const, error: 'GOAL_ORCHESTRATOR_START_FAILED', updated_at: new Date().toISOString() };
|
|
|
|
|
writeFileSync(jobFile, `${JSON.stringify(failed, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
|
|
|
});
|
|
|
|
|
child.unref();
|
|
|
|
|
return job;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
return job;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
list(actor: SettingsActor, limit = 20): { count: number; goals: GoalJob[] } {
|
|
|
|
|
this.requireRead(actor);
|
|
|
|
|
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))
|
|
|
|
|
.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))) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private jobPath(tenant: string, id: string): string {
|
|
|
|
|
return join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(tenant), `${id}.json`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private connections(actor: SettingsActor): ModelConnection[] {
|
|
|
|
|
const payload = this.runPython(CONNECTIONS_CLI, ['list'], { CASAN_TENANT_ID: actor.tenant || 'default' });
|
|
|
|
|
const parsed = parseJson<ConnectionList>(payload);
|
|
|
|
|
if (!parsed?.success || !Array.isArray(parsed.connections)) {
|
|
|
|
|
throw new InternalServerErrorException('GOAL_MODEL_CONNECTIONS_UNAVAILABLE');
|
|
|
|
|
}
|
|
|
|
|
return parsed.connections;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private runtime(provider: string, model: string, actor: SettingsActor): Record<string, string> {
|
|
|
|
|
const payload = this.runPython(
|
|
|
|
|
CONNECTIONS_CLI,
|
|
|
|
|
['runtime-env', '--provider', provider, '--model', model],
|
|
|
|
|
{ CASAN_TENANT_ID: actor.tenant || 'default' },
|
|
|
|
|
);
|
|
|
|
|
const parsed = parseJson<{ success?: boolean; env?: Record<string, string> }>(payload);
|
|
|
|
|
if (!parsed?.success || !parsed.env) throw new BadRequestException('GOAL_MODEL_RUNTIME_UNAVAILABLE');
|
|
|
|
|
return parsed.env;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async accountReviewer(): Promise<'claude' | 'codex' | ''> {
|
|
|
|
|
const bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || '').replace(/\/$/, '');
|
|
|
|
|
const bridgeToken = process.env.CASAN_AUTH_BRIDGE_TOKEN || '';
|
|
|
|
|
if (!bridgeUrl || !bridgeToken) return '';
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${bridgeUrl}/v1/auth/providers`, {
|
|
|
|
|
headers: { 'X-CASAN-Bridge-Token': bridgeToken },
|
|
|
|
|
signal: AbortSignal.timeout(10_000),
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) return '';
|
|
|
|
|
const payload = await response.json() as { providers?: AccountProviderStatus[] };
|
|
|
|
|
const available = payload.providers?.filter((provider) => provider.available && provider.loggedIn) ?? [];
|
|
|
|
|
if (available.some((provider) => provider.id === 'claude')) return 'claude';
|
|
|
|
|
if (available.some((provider) => provider.id === 'codex')) return 'codex';
|
|
|
|
|
} catch {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private runPython(script: string, args: string[], environment: NodeJS.ProcessEnv): string {
|
|
|
|
|
try {
|
|
|
|
|
return execFileSync('python3', [script, ...args], {
|
|
|
|
|
cwd: APP_ROOT,
|
|
|
|
|
env: { ...process.env, ...environment },
|
|
|
|
|
encoding: 'utf8',
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
timeout: 20_000,
|
|
|
|
|
}).trim();
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const detail = error as { stderr?: string | Buffer; stdout?: string | Buffer };
|
|
|
|
|
throw new InternalServerErrorException(String(detail.stderr || detail.stdout || 'GOAL_RUNTIME_FAILED').trim());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private requireRead(actor: SettingsActor): void {
|
|
|
|
|
try {
|
|
|
|
|
execFileSync('python3', [RBAC_CLI, 'check', '--role', actor.role, '--resource', 'monitoring', '--action', 'read',
|
|
|
|
|
'--role-project', actor.project, '--target-project', actor.project,
|
|
|
|
|
'--role-tenant', actor.tenant, '--target-tenant', actor.tenant], {
|
|
|
|
|
cwd: APP_ROOT,
|
|
|
|
|
env: process.env,
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
throw new ForbiddenException('GOAL_RBAC_DENIED');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|