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
+2
View File
@@ -43,6 +43,8 @@ pnpm-debug.log*
.specify/logs/tmp/
.specify/logs/trace/
.specify/logs/trace-events/
.specify/logs/audit/goal-orchestrator.jsonl
.specify/logs/audit/goal-orchestrator-head.txt
.specify/logs/chat/
.specify/logs/idempotency/
.specify/logs/level5/rollback-backups/
@@ -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"})
@@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""Run one governed goal through a local worker and a cloud reviewer.
The job file is tenant-scoped and created by the Control Panel. Credentials are
passed only through the child environment and are never copied into job, trace,
audit, or metric records.
"""
import argparse
import fcntl
import hashlib
import json
import os
import re
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
def project_root() -> str:
current = os.path.abspath(os.path.dirname(__file__))
while current != os.path.dirname(current):
if os.path.isdir(os.path.join(current, ".specify")):
return current
current = os.path.dirname(current)
raise SystemExit("GOAL_ROOT_NOT_FOUND")
ROOT = project_root()
BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
MODEL_ROUTER = os.environ.get("CASAN_GOAL_MODEL_ROUTER") or os.path.join(BIN, "model-router.sh")
SECURITY = os.path.join(BIN, "security-check.sh")
STATE_ROOT = os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
def now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def sha(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def atomic_json(path: str, payload: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".goal-", dir=os.path.dirname(path), text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.chmod(temporary, 0o600)
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def load_json(path: str) -> dict:
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict):
raise ValueError("GOAL_JOB_INVALID")
return payload
def append_jsonl(path: str, payload: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "a", encoding="utf-8") as handle:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def trace_path(goal_id: str) -> str:
directory = os.environ.get("CASAN_TRACE_EVENTS_DIR") or os.path.join(STATE_ROOT, "logs", "trace-events")
return os.path.join(directory, f"{goal_id}.jsonl")
def emit(goal_id: str, gate_id: str, status: str, reason: str, evidence=None) -> None:
append_jsonl(trace_path(goal_id), {
"timestamp": now(),
"trace_id": goal_id,
"gate_id": gate_id,
"status": status,
"reason": reason,
"evidence": evidence or {},
})
def update_job(path: str, **changes) -> dict:
job = load_json(path)
job.update(changes)
job["updated_at"] = now()
atomic_json(path, job)
return job
def stage(path: str, stage_id: str, status: str, detail: str, provider="", model="") -> None:
job = load_json(path)
stages = list(job.get("stages", []))
row = next((item for item in stages if item.get("id") == stage_id), None)
if row is None:
row = {"id": stage_id}
stages.append(row)
row.update({"status": status, "detail": detail, "provider": provider, "model": model, "updated_at": now()})
job["stages"] = stages
job["updated_at"] = now()
atomic_json(path, job)
def scan(text: str, mode: str):
with tempfile.TemporaryDirectory() as directory:
source = os.path.join(directory, "input.txt")
output = os.path.join(directory, "output.txt")
with open(source, "w", encoding="utf-8") as handle:
handle.write(text)
result = subprocess.run(
["bash", SECURITY, source, output, mode],
cwd=ROOT,
capture_output=True,
text=True,
timeout=60,
)
safe = ""
if os.path.isfile(output):
with open(output, encoding="utf-8") as handle:
safe = handle.read().strip()
return result.returncode == 0, safe
def call_model(model: str, prompt: str, cloud: bool):
if not model:
return False, "", {}, "model_unconfigured"
with tempfile.TemporaryDirectory() as directory:
prompt_path = os.path.join(directory, "prompt.txt")
output_path = os.path.join(directory, "output.json")
with open(prompt_path, "w", encoding="utf-8") as handle:
handle.write(prompt)
environment = os.environ.copy()
environment["CASAN_PREFLIGHT"] = "1" if cloud else "0"
try:
result = subprocess.run(
["bash", MODEL_ROUTER, prompt_path, output_path, "--role", "generate", "--model", model],
cwd=ROOT,
capture_output=True,
text=True,
env=environment,
timeout=int(os.environ.get("CASAN_GOAL_MODEL_TIMEOUT", "300")),
)
except subprocess.TimeoutExpired:
return False, "", {}, "model_timeout"
if result.returncode != 0 or not os.path.isfile(output_path):
raw = (result.stderr or result.stdout or "").strip().splitlines()
detail = raw[-1][:180] if raw else "no_detail"
detail = re.sub(r"(?i)(bearer|api[_-]?key|token)[=: ]+\S+", r"\1=[redacted]", detail)
return False, "", {}, f"model_exit_{result.returncode}:{detail}"
try:
output = load_json(output_path)
except (OSError, ValueError, json.JSONDecodeError):
return False, "", {}, "model_output_invalid"
text = str(output.get("text") or "").strip()
if not text:
return False, "", {}, "model_output_empty"
metadata = {
"input_tokens": int(output.get("input_tokens") or 0),
"output_tokens": int(output.get("output_tokens") or 0),
"latency_ms": int(output.get("latency_ms") or 0),
}
return True, text, metadata, "ok"
def call_account_model(provider: str, prompt: str):
base_url = os.environ.get("CASAN_AUTH_BRIDGE_URL", "").rstrip("/")
token = os.environ.get("CASAN_AUTH_BRIDGE_TOKEN", "")
if provider not in {"codex", "claude"} or not base_url or not token:
return False, "", {}, "account_bridge_unavailable"
request = urllib.request.Request(
f"{base_url}/v1/models/{provider}/generate",
data=json.dumps({"prompt": prompt}).encode("utf-8"),
headers={"Content-Type": "application/json", "X-CASAN-Bridge-Token": token},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=310) as response:
payload = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, ValueError):
return False, "", {}, "account_bridge_failed"
text = str(payload.get("text") or "").strip()
if not payload.get("success") or not text:
return False, "", {}, str(payload.get("reason") or "account_model_failed")[:120]
usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
metadata = {
"input_tokens": int(usage.get("input_tokens") or 0),
"output_tokens": int(usage.get("output_tokens") or 0),
"latency_ms": int(payload.get("latency_ms") or 0),
"model": str(payload.get("model") or f"{provider}-account-default"),
}
return True, text, metadata, "ok"
def audit(job: dict, status: str) -> str:
path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator.jsonl")
head_path = os.path.join(STATE_ROOT, "logs", "audit", "goal-orchestrator-head.txt")
os.makedirs(os.path.dirname(path), exist_ok=True)
previous = "0" * 64
try:
with open(head_path, encoding="utf-8") as handle:
previous = handle.read().strip() or previous
except OSError:
pass
core = {
"timestamp": now(),
"harness": "H5-governance",
"goal_id": job["id"],
"tenant": job.get("tenant", "default"),
"actor": job.get("actor", "unknown"),
"goal_hash": sha(str(job.get("goal", ""))),
"status": status,
"local_provider": job.get("local_provider", ""),
"cloud_provider": job.get("cloud_provider", ""),
"prev_hash": previous,
}
record_hash = sha(json.dumps(core, sort_keys=True, ensure_ascii=False))
append_jsonl(path, {**core, "record_hash": record_hash})
with open(head_path, "w", encoding="utf-8") as handle:
handle.write(record_hash + "\n")
return record_hash
def metric(job: dict, status: str, started: float, local_meta: dict, cloud_meta: dict) -> None:
append_jsonl(os.path.join(STATE_ROOT, "logs", "cost", "metrics.jsonl"), {
"timestamp": now(),
"trace_id": job["id"],
"harness": "H6-agentops",
"agent": "goal.orchestrator",
"step": "local-worker-cloud-reviewer",
"status": status,
"exit_code": 0 if status in {"success", "degraded"} else 2,
"latency_ms": int((time.monotonic() - started) * 1000),
"input_tokens": int(local_meta.get("input_tokens", 0)) + int(cloud_meta.get("input_tokens", 0)),
"output_tokens": int(local_meta.get("output_tokens", 0)) + int(cloud_meta.get("output_tokens", 0)),
"total_tokens": sum(int(meta.get(key, 0)) for meta in (local_meta, cloud_meta) for key in ("input_tokens", "output_tokens")),
"cost_estimate": 0.0,
"cost_source": "provider_usage_logs",
"input_hash": sha(str(job.get("goal", ""))),
"output_hash": sha(str(job.get("result", ""))),
})
def run(job_path: str) -> int:
started = time.monotonic()
job = load_json(job_path)
goal_id = str(job["id"])
goal = str(job.get("goal") or "").strip()
local_model = os.environ.get("CASAN_GOAL_LOCAL_MODEL", "")
cloud_model = os.environ.get("CASAN_GOAL_CLOUD_MODEL", "")
account_provider = os.environ.get("CASAN_GOAL_ACCOUNT_PROVIDER", "")
local_meta, cloud_meta = {}, {}
try:
update_job(job_path, status="running", started_at=now())
emit(goal_id, "H1-context", "running", "Validating objective contract")
if len(goal) < 10 or len(goal) > 8000:
raise ValueError("goal_length_invalid")
emit(goal_id, "H1-context", "pass", "Objective accepted", {"goal_hash": sha(goal), "characters": len(goal)})
emit(goal_id, "H4-security", "running", "Scanning objective before model routing")
allowed, safe_goal = scan(goal, "input")
if not allowed:
emit(goal_id, "H4-security", "blocked", "Objective rejected by security boundary")
raise ValueError("goal_security_blocked")
emit(goal_id, "H4-security", "running", "Objective passed; model outputs pending")
stage(job_path, "local-worker", "running", "Local model is developing the primary solution", job.get("local_provider", ""), local_model)
emit(goal_id, "H2-tool", "running", "Local worker is developing a solution", {"provider": job.get("local_provider", ""), "model": local_model})
local_prompt = (
"You are the local CASAN worker. Solve the user's objective concretely. "
"Produce: clarified outcome, assumptions, ordered implementation plan, risks, "
"and verifiable acceptance checks. Respond in the same language as the objective.\n\n"
f"OBJECTIVE:\n{safe_goal}"
)
ok, local_draft, local_meta, reason = call_model(local_model, local_prompt, False)
if not ok:
stage(job_path, "local-worker", "error", reason, job.get("local_provider", ""), local_model)
emit(goal_id, "H2-tool", "error", "Local worker failed", {"reason": reason})
raise RuntimeError("local_worker_failed")
allowed, safe_local = scan(local_draft, "output")
if not allowed:
emit(goal_id, "H4-security", "blocked", "Local worker output rejected")
raise ValueError("local_output_security_blocked")
stage(job_path, "local-worker", "pass", "Primary solution prepared", job.get("local_provider", ""), local_model)
update_job(job_path, local_draft=safe_local, local_usage=local_meta)
emit(goal_id, "H2-tool", "pass", "Local solution prepared", {"provider": job.get("local_provider", ""), "model": local_model, **local_meta})
stage(job_path, "cloud-reviewer", "running", "Cloud model is challenging and improving the local solution", job.get("cloud_provider", ""), cloud_model)
emit(goal_id, "H3-eval", "running", "Cloud reviewer is evaluating the local solution", {"provider": job.get("cloud_provider", ""), "model": cloud_model})
review_prompt = (
"You are the cloud CASAN reviewer. Critically review the local worker's proposal "
"against the objective. Correct gaps, remove unsafe or unverifiable claims, and "
"return one final actionable solution with ordered steps and acceptance checks. "
"Respond in the same language as the objective.\n\n"
f"OBJECTIVE:\n{safe_goal}\n\nLOCAL WORKER PROPOSAL:\n{safe_local[:16000]}"
)
if account_provider:
cloud_ok, cloud_result, cloud_meta, cloud_reason = call_account_model(account_provider, review_prompt)
if not cloud_ok and os.environ.get("CASAN_GOAL_CLOUD_FALLBACK_MODEL"):
cloud_ok, cloud_result, cloud_meta, cloud_reason = call_model(
os.environ["CASAN_GOAL_CLOUD_FALLBACK_MODEL"], review_prompt, True
)
else:
cloud_ok, cloud_result, cloud_meta, cloud_reason = call_model(cloud_model, review_prompt, True)
if cloud_ok:
allowed, safe_result = scan(cloud_result, "output")
if not allowed:
emit(goal_id, "H4-security", "blocked", "Cloud reviewer output rejected")
raise ValueError("cloud_output_security_blocked")
stage(job_path, "cloud-reviewer", "pass", "Cloud review incorporated", job.get("cloud_provider", ""), cloud_model)
emit(goal_id, "H3-eval", "pass", "Cloud review incorporated", {"provider": job.get("cloud_provider", ""), "model": cloud_model, **cloud_meta})
final_status = "completed"
metric_status = "success"
else:
safe_result = safe_local
stage(job_path, "cloud-reviewer", "warning", cloud_reason, job.get("cloud_provider", ""), cloud_model)
emit(goal_id, "H3-eval", "warning", "Cloud reviewer unavailable; local solution retained", {"reason": cloud_reason})
final_status = "degraded"
metric_status = "degraded"
emit(goal_id, "H4-security", "pass", "Objective and all released outputs passed security scans")
job = update_job(job_path, status=final_status, result=safe_result, cloud_usage=cloud_meta, finished_at=now())
emit(goal_id, "H5-governance", "running", "Anchoring orchestration decision")
audit_hash = audit(job, final_status)
emit(goal_id, "H5-governance", "pass", "Orchestration decision anchored", {"audit_hash": audit_hash, "status": final_status})
emit(goal_id, "H6-agentops", "running", "Recording orchestration telemetry")
metric(job, metric_status, started, local_meta, cloud_meta)
emit(goal_id, "H6-agentops", "pass", "Orchestration telemetry recorded", {"latency_ms": int((time.monotonic() - started) * 1000), "status": metric_status})
emit(goal_id, "H7-orchestration", "pass", "Local worker and cloud review workflow completed", {"status": final_status, "cloud_incorporated": cloud_ok})
update_job(job_path, audit_hash=audit_hash)
return 0
except Exception as error:
reason = str(error)[:120] or error.__class__.__name__
update_job(job_path, status="failed", error=reason, finished_at=now())
emit(goal_id, "H5-governance", "running", "Anchoring failed orchestration decision")
job = load_json(job_path)
audit_hash = audit(job, "failed")
emit(goal_id, "H5-governance", "pass", "Failed decision anchored", {"audit_hash": audit_hash})
emit(goal_id, "H6-agentops", "error", "Orchestration failed", {"reason": reason, "latency_ms": int((time.monotonic() - started) * 1000)})
emit(goal_id, "H7-orchestration", "blocked", "Goal workflow stopped", {"reason": reason})
update_job(job_path, audit_hash=audit_hash)
return 2
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--job-file", required=True)
args = parser.parse_args()
path = os.path.abspath(args.job_file)
state = os.path.abspath(STATE_ROOT) + os.sep
if not path.startswith(state):
raise SystemExit("GOAL_JOB_PATH_DENIED")
return run(path)
if __name__ == "__main__":
raise SystemExit(main())