feat: orchestrate goals with local and cloud models
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user