feat: orchestrate goals with local and cloud models

This commit is contained in:
thanhnv
2026-07-10 23:35:30 +09:00
parent 1345d4c930
commit 48b1186439
11 changed files with 920 additions and 2 deletions
@@ -0,0 +1,24 @@
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';
@Controller('api/v1/goals')
export class GoalsController {
constructor(@Inject(GoalsService) private readonly service: GoalsService) {}
@Post()
async start(@Body() body: GoalStartInput, @Headers() headers: Record<string, string | string[] | undefined>) {
return ok(await this.service.start(body, 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));
}
@Get(':id')
get(@Param('id') id: string, @Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.service.get(id, actorFromHeaders(headers)));
}
}
@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { GoalsController } from './goals.controller.js';
import { GoalsService } from './goals.service.js';
@Module({ controllers: [GoalsController], providers: [GoalsService] })
export class GoalsModule {}
@@ -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');
}
}
}