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
@@ -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"})