feat: orchestrate goals with local and cloud models
This commit is contained in:
@@ -6,9 +6,10 @@ import { KillSwitchModule } from './kill-switch/kill-switch.module.js';
|
||||
import { ApprovalsModule } from './approvals/approvals.module.js';
|
||||
import { ChatModule } from './chat/chat.module.js';
|
||||
import { ProviderAuthModule } from './provider-auth/provider-auth.module.js';
|
||||
import { GoalsModule } from './goals/goals.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule],
|
||||
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule, GoalsModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { FinOps } from './pages/FinOps';
|
||||
import { Approvals } from './pages/Approvals';
|
||||
import { CommandCenter } from './pages/CommandCenter';
|
||||
import { Chat } from './pages/Chat';
|
||||
import { Goals } from './pages/Goals';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -27,6 +28,7 @@ export default function App() {
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/command" element={<CommandCenter />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/goals" element={<Goals />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
type IconName = 'grid' | 'command' | 'chat' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'coins' | 'approval' | 'settings';
|
||||
type IconName = 'grid' | 'command' | 'chat' | 'goal' | 'runs' | 'shield' | 'governance' | 'incident' | 'trace' | 'coins' | 'approval' | 'settings';
|
||||
|
||||
interface NavItem { to: string; label: string; icon: IconName; }
|
||||
|
||||
@@ -10,6 +10,7 @@ const NAVIGATION: Array<{ label: string; items: NavItem[] }> = [
|
||||
{ to: '/', label: 'Overview', icon: 'grid' },
|
||||
{ to: '/command', label: 'Command center', icon: 'command' },
|
||||
{ to: '/chat', label: 'Ask CASAN', icon: 'chat' },
|
||||
{ to: '/goals', label: 'Goal orchestrator', icon: 'goal' },
|
||||
{ to: '/runs', label: 'Run observability', icon: 'runs' },
|
||||
] },
|
||||
{ label: 'Assure', items: [
|
||||
@@ -30,6 +31,7 @@ function Icon({ name }: { name: IconName }) {
|
||||
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
command: <><path d="M5 12h14M12 5l7 7-7 7" /><path d="M5 5v14" /></>,
|
||||
chat: <><path d="M20 11.5a7.5 7.5 0 0 1-8 7.5 8.4 8.4 0 0 1-3.7-.9L4 19l1.2-3.5A7.5 7.5 0 1 1 20 11.5Z" /><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01" /></>,
|
||||
goal: <><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="4" /><path d="m12 12 7-7M16 5h3v3" /></>,
|
||||
runs: <><path d="M4 19V9M10 19V5M16 19v-7M22 19H2" /><path d="M3 9h2M9 5h2M15 12h2" /></>,
|
||||
shield: <path d="M12 3 20 6v5c0 5.2-3.4 8.9-8 10-4.6-1.1-8-4.8-8-10V6l8-3Z" />,
|
||||
governance: <><path d="M4 20h16M6 17V9M10 17V5M14 17V9M18 17V5" /><path d="M3 5h18l-9-3-9 3Z" /></>,
|
||||
|
||||
@@ -260,6 +260,38 @@ export interface HarnessTraceGraph {
|
||||
events: HarnessTraceEvent[];
|
||||
}
|
||||
|
||||
export interface GoalStage {
|
||||
id: 'local-worker' | 'cloud-reviewer' | 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;
|
||||
}
|
||||
|
||||
export interface ChatReplay {
|
||||
ok: boolean;
|
||||
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
|
||||
@@ -418,6 +450,12 @@ export const api = {
|
||||
getWithHeaders<{ success: boolean; providers: ProviderAuthStatus[] }>('provider-auth', actorHeaders(actor)),
|
||||
startProviderLogin: (actor: SettingsActor, provider: ProviderAuthStatus['id']) =>
|
||||
post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)),
|
||||
startGoal: (actor: SettingsActor, goal: string) =>
|
||||
post<GoalJob>('goals', { goal }, actorHeaders(actor)),
|
||||
goal: (actor: SettingsActor, id: string) =>
|
||||
getWithHeaders<GoalJob>(`goals/${encodeURIComponent(id)}`, actorHeaders(actor)),
|
||||
goals: (actor: SettingsActor, limit = 20) =>
|
||||
getWithHeaders<{ count: number; goals: GoalJob[] }>(`goals?limit=${limit}`, actorHeaders(actor)),
|
||||
};
|
||||
|
||||
// Health is raw (not enveloped) + carries HTTP status.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api, type GoalJob, type SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
import { TraceExplorer } from '../components/trace/TraceExplorer';
|
||||
|
||||
const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'project-admin', project: 'default', tenant: 'default' };
|
||||
const TERMINAL = new Set<GoalJob['status']>(['completed', 'degraded', 'failed']);
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const candidate = error as { message?: string; response?: { data?: { message?: string } } };
|
||||
return candidate.response?.data?.message || candidate.message || 'Goal orchestration could not start.';
|
||||
}
|
||||
return 'Goal orchestration could not start.';
|
||||
}
|
||||
|
||||
function WorkerCard({ title, subtitle, status, detail, provider, model }: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}) {
|
||||
const busy = status === 'running';
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
{busy && <div className="absolute inset-x-0 top-0 h-0.5 overflow-hidden bg-blue-100"><div className="h-full w-1/3 animate-pulse rounded-full bg-blue-500" /></div>}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><h3 className="font-semibold text-slate-900">{title}</h3><p className="mt-1 text-xs text-slate-500">{subtitle}</p></div>
|
||||
<StatusBadge value={status} />
|
||||
</div>
|
||||
<p className="mt-4 min-h-10 text-sm leading-5 text-slate-600">{detail}</p>
|
||||
<div className="mt-4 border-t border-slate-100 pt-3 text-[11px] text-slate-400">
|
||||
<div className="font-medium text-slate-500">{provider || 'not selected'}</div>
|
||||
<div className="mt-1 break-all font-mono">{model || 'model unavailable'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Goals() {
|
||||
const [goal, setGoal] = useState('');
|
||||
const [actor] = useState(DEFAULT_ACTOR);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get('id') ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const listQuery = useQuery({ queryKey: ['goals', actor], queryFn: () => api.goals(actor, 20) });
|
||||
const selectedQuery = useQuery({
|
||||
queryKey: ['goal', actor, selectedId],
|
||||
queryFn: () => api.goal(actor, selectedId),
|
||||
enabled: Boolean(selectedId),
|
||||
refetchInterval: (query) => {
|
||||
const current = query.state.data as GoalJob | undefined;
|
||||
return current && TERMINAL.has(current.status) ? false : 1500;
|
||||
},
|
||||
});
|
||||
const start = useMutation({
|
||||
mutationFn: () => api.startGoal(actor, goal.trim()),
|
||||
onSuccess: (job) => {
|
||||
setSearchParams({ id: job.id });
|
||||
setGoal('');
|
||||
queryClient.setQueryData(['goal', actor, job.id], job);
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
},
|
||||
});
|
||||
|
||||
const selected = selectedQuery.data;
|
||||
const localStage = selected?.stages.find((stage) => stage.id === 'local-worker');
|
||||
const cloudStage = selected?.stages.find((stage) => stage.id === 'cloud-reviewer');
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="overflow-hidden rounded-2xl border border-indigo-200/70 bg-gradient-to-br from-slate-950 via-indigo-950 to-indigo-800 p-6 text-white shadow-[0_20px_45px_rgba(30,41,89,0.2)]">
|
||||
<div className="max-w-3xl">
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.16em] text-indigo-200">Goal orchestrator</div>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">One objective. Two models. One governed outcome.</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-indigo-100/80">A local worker develops the primary solution. A cloud reviewer challenges it, closes gaps, and returns the final answer through the same H1–H7 controls.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card title="Give CASAN an objective">
|
||||
<textarea
|
||||
value={goal}
|
||||
onChange={(event) => setGoal(event.target.value)}
|
||||
placeholder="Ví dụ: Thiết kế kế hoạch đưa ứng dụng OKR hiện tại lên production, có rollback và tiêu chí nghiệm thu rõ ràng."
|
||||
className="min-h-32 w-full resize-y rounded-xl border border-slate-300 px-4 py-3 text-sm leading-6 text-slate-800 outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100"
|
||||
maxLength={8000}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500">CASAN selects the connected local and cloud models automatically. No IDE or CLI is required.</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={goal.trim().length < 10 || start.isPending}
|
||||
onClick={() => start.mutate()}
|
||||
className="rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{start.isPending ? 'Starting…' : 'Solve objective'}
|
||||
</button>
|
||||
</div>
|
||||
{start.isError && <div role="alert" className="mt-3 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{errorMessage(start.error)}</div>}
|
||||
</Card>
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<WorkerCard title="Local worker" subtitle="Private first-pass solution" status={localStage?.status ?? 'queued'} detail={localStage?.detail ?? 'Waiting'} provider={localStage?.provider ?? selected.local_provider} model={localStage?.model ?? selected.local_model} />
|
||||
<WorkerCard title="Cloud reviewer" subtitle="Independent critique and refinement" status={cloudStage?.status ?? 'queued'} detail={cloudStage?.detail ?? 'Waiting'} provider={cloudStage?.provider ?? selected.cloud_provider} model={cloudStage?.model ?? selected.cloud_model} />
|
||||
</div>
|
||||
|
||||
<Card title="Governed outcome" right={<StatusBadge value={selected.status} />}>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Objective</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">{selected.goal}</p>
|
||||
</div>
|
||||
{selected.result ? <div className="mt-5 whitespace-pre-wrap text-sm leading-7 text-slate-800">{selected.result}</div> : (
|
||||
<div className="mt-5 flex items-center gap-3 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-blue-500" />
|
||||
Models are working. This page refreshes automatically.
|
||||
</div>
|
||||
)}
|
||||
{selected.error && <div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{selected.error}</div>}
|
||||
{selected.local_draft && <details className="mt-5 rounded-xl border border-slate-200 p-4"><summary className="cursor-pointer text-sm font-semibold text-slate-700">Inspect local worker draft</summary><div className="mt-4 whitespace-pre-wrap text-sm leading-6 text-slate-600">{selected.local_draft}</div></details>}
|
||||
</Card>
|
||||
|
||||
<TraceExplorer traceId={selected.trace_id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card title={`Recent objectives (${listQuery.data?.count ?? 0})`}>
|
||||
<div className="space-y-2">
|
||||
{(listQuery.data?.goals ?? []).map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => setSearchParams({ id: item.id })} className={`flex w-full items-center justify-between gap-4 rounded-xl border p-3 text-left transition hover:bg-slate-50 ${selectedId === item.id ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200'}`}>
|
||||
<div className="min-w-0"><div className="truncate text-sm font-medium text-slate-800">{item.goal}</div><div className="mt-1 text-xs text-slate-400">{item.created_at.replace('T', ' ').replace('Z', '')}</div></div>
|
||||
<StatusBadge value={item.status} />
|
||||
</button>
|
||||
))}
|
||||
{!listQuery.isLoading && (listQuery.data?.count ?? 0) === 0 && <p className="py-6 text-center text-sm text-slate-500">No objective has been orchestrated yet.</p>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,9 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -33,6 +35,7 @@ PROVIDERS = {
|
||||
}
|
||||
RUNNING: Dict[str, subprocess.Popen] = {}
|
||||
LOCK = threading.Lock()
|
||||
MODEL_LOCKS = {provider: threading.Lock() for provider in PROVIDERS}
|
||||
|
||||
|
||||
def command_status(provider: str) -> dict:
|
||||
@@ -109,6 +112,66 @@ def start_login(provider: str) -> dict:
|
||||
return {"success": True, "reason": "browser_login_started", "provider": command_status(provider)}
|
||||
|
||||
|
||||
def generate_with_account(provider: str, prompt: str) -> dict:
|
||||
status = command_status(provider)
|
||||
if not status["available"] or not status["loggedIn"]:
|
||||
return {"success": False, "reason": "provider_not_logged_in"}
|
||||
if not prompt or len(prompt) > 24000:
|
||||
return {"success": False, "reason": "prompt_length_invalid"}
|
||||
started = time.monotonic()
|
||||
with MODEL_LOCKS[provider], tempfile.TemporaryDirectory(prefix="casan-account-model-") as directory:
|
||||
try:
|
||||
if provider == "codex":
|
||||
output_path = os.path.join(directory, "last-message.txt")
|
||||
command = [
|
||||
"codex", "exec", "--ephemeral", "--ignore-user-config", "--ignore-rules",
|
||||
"--skip-git-repo-check", "--sandbox", "read-only", "--color", "never",
|
||||
"--cd", directory, "--output-last-message", output_path, "-",
|
||||
]
|
||||
result = subprocess.run(
|
||||
command, input=prompt, capture_output=True, text=True, timeout=300,
|
||||
env={**os.environ, "NO_COLOR": "1"},
|
||||
)
|
||||
text = ""
|
||||
if result.returncode == 0 and os.path.isfile(output_path):
|
||||
with open(output_path, encoding="utf-8") as handle:
|
||||
text = handle.read().strip()
|
||||
usage = {}
|
||||
model = "codex-account-default"
|
||||
else:
|
||||
command = [
|
||||
"claude", "--print", "--output-format", "json", "--permission-mode", "plan",
|
||||
"--tools", "", "--safe-mode", "--no-session-persistence",
|
||||
]
|
||||
result = subprocess.run(
|
||||
command, input=prompt, capture_output=True, text=True, timeout=300, cwd=directory,
|
||||
env={**os.environ, "NO_COLOR": "1"},
|
||||
)
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
except ValueError:
|
||||
payload = {}
|
||||
text = str(payload.get("result") or "").strip()
|
||||
raw_usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
||||
usage = {
|
||||
"input_tokens": int(raw_usage.get("input_tokens") or 0),
|
||||
"output_tokens": int(raw_usage.get("output_tokens") or 0),
|
||||
}
|
||||
model = str(payload.get("model") or "claude-account-default")
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return {"success": False, "reason": "account_model_unreachable"}
|
||||
if result.returncode != 0 or not text:
|
||||
return {"success": False, "reason": "account_model_failed"}
|
||||
return {
|
||||
"success": True,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"text": text,
|
||||
"usage": usage,
|
||||
"latency_ms": int((time.monotonic() - started) * 1000),
|
||||
}
|
||||
|
||||
|
||||
class BridgeHandler(BaseHTTPRequestHandler):
|
||||
server_version = "CASANAuthBridge/1.0"
|
||||
|
||||
@@ -130,6 +193,19 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
supplied = self.headers.get("X-CASAN-Bridge-Token", "")
|
||||
return bool(expected) and hmac.compare_digest(expected, supplied)
|
||||
|
||||
def read_json(self) -> dict:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
return {}
|
||||
if length < 1 or length > 100000:
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/healthz":
|
||||
self.send_json(200, {"status": "ok"})
|
||||
@@ -151,6 +227,11 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
result = start_login(parts[2])
|
||||
self.send_json(202 if result["success"] else 503, result)
|
||||
return
|
||||
if len(parts) == 4 and parts[:2] == ["v1", "models"] and parts[3] == "generate" and parts[2] in PROVIDERS:
|
||||
body = self.read_json()
|
||||
result = generate_with_account(parts[2], str(body.get("prompt") or ""))
|
||||
self.send_json(200 if result["success"] else 503, result)
|
||||
return
|
||||
self.send_json(404, {"success": False, "reason": "not_found"})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user