From 48b118643980c8fed1ded6e513cabcd981ab083d Mon Sep 17 00:00:00 2001 From: thanhnv Date: Fri, 10 Jul 2026 23:35:30 +0900 Subject: [PATCH] feat: orchestrate goals with local and cloud models --- .gitignore | 2 + .../backend/src/app.module.ts | 3 +- .../backend/src/goals/goals.controller.ts | 24 ++ .../backend/src/goals/goals.module.ts | 6 + .../backend/src/goals/goals.service.ts | 250 ++++++++++++ .../casan-control-panel/frontend/src/App.tsx | 2 + .../src/components/layout/Sidebar.tsx | 4 +- .../frontend/src/lib/api.ts | 38 ++ .../frontend/src/pages/Goals.tsx | 146 +++++++ .../scripts/provider-auth-bridge.py | 81 ++++ .../scripts/bash/goal-orchestrator.py | 366 ++++++++++++++++++ 11 files changed, 920 insertions(+), 2 deletions(-) create mode 100644 packages/casan-control-panel/backend/src/goals/goals.controller.ts create mode 100644 packages/casan-control-panel/backend/src/goals/goals.module.ts create mode 100644 packages/casan-control-panel/backend/src/goals/goals.service.ts create mode 100644 packages/casan-control-panel/frontend/src/pages/Goals.tsx create mode 100644 packages/casan-harness/scripts/bash/goal-orchestrator.py diff --git a/.gitignore b/.gitignore index 95b9ec8..85200f5 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/packages/casan-control-panel/backend/src/app.module.ts b/packages/casan-control-panel/backend/src/app.module.ts index 60ea6ec..1763cbd 100644 --- a/packages/casan-control-panel/backend/src/app.module.ts +++ b/packages/casan-control-panel/backend/src/app.module.ts @@ -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 {} diff --git a/packages/casan-control-panel/backend/src/goals/goals.controller.ts b/packages/casan-control-panel/backend/src/goals/goals.controller.ts new file mode 100644 index 0000000..10e5e1f --- /dev/null +++ b/packages/casan-control-panel/backend/src/goals/goals.controller.ts @@ -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) { + return ok(await this.service.start(body, actorFromHeaders(headers))); + } + + @Get() + list(@Headers() headers: Record, @Query('limit') limit?: string) { + return ok(this.service.list(actorFromHeaders(headers), Number(limit) || 20)); + } + + @Get(':id') + get(@Param('id') id: string, @Headers() headers: Record) { + return ok(this.service.get(id, actorFromHeaders(headers))); + } +} diff --git a/packages/casan-control-panel/backend/src/goals/goals.module.ts b/packages/casan-control-panel/backend/src/goals/goals.module.ts new file mode 100644 index 0000000..5a55329 --- /dev/null +++ b/packages/casan-control-panel/backend/src/goals/goals.module.ts @@ -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 {} diff --git a/packages/casan-control-panel/backend/src/goals/goals.service.ts b/packages/casan-control-panel/backend/src/goals/goals.service.ts new file mode 100644 index 0000000..b0f4898 --- /dev/null +++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts @@ -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; + cloud_usage?: Record; +} + +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(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 { + 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(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(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(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 { + 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 }>(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'); + } + } +} diff --git a/packages/casan-control-panel/frontend/src/App.tsx b/packages/casan-control-panel/frontend/src/App.tsx index 0aa8b0b..4a1fd9a 100644 --- a/packages/casan-control-panel/frontend/src/App.tsx +++ b/packages/casan-control-panel/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/packages/casan-control-panel/frontend/src/components/layout/Sidebar.tsx b/packages/casan-control-panel/frontend/src/components/layout/Sidebar.tsx index 5e8ba50..72c21c9 100644 --- a/packages/casan-control-panel/frontend/src/components/layout/Sidebar.tsx +++ b/packages/casan-control-panel/frontend/src/components/layout/Sidebar.tsx @@ -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: <>, command: <>, chat: <>, + goal: <>, runs: <>, shield: , governance: <>, diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts index 60b6f9d..30379b5 100644 --- a/packages/casan-control-panel/frontend/src/lib/api.ts +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -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('goals', { goal }, actorHeaders(actor)), + goal: (actor: SettingsActor, id: string) => + getWithHeaders(`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. diff --git a/packages/casan-control-panel/frontend/src/pages/Goals.tsx b/packages/casan-control-panel/frontend/src/pages/Goals.tsx new file mode 100644 index 0000000..8d3fab6 --- /dev/null +++ b/packages/casan-control-panel/frontend/src/pages/Goals.tsx @@ -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(['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 ( +
+ {busy &&
} +
+

{title}

{subtitle}

+ +
+

{detail}

+
+
{provider || 'not selected'}
+
{model || 'model unavailable'}
+
+
+ ); +} + +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 ( +
+
+
+
Goal orchestrator
+

One objective. Two models. One governed outcome.

+

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.

+
+
+ + +