feat: apply reviewed goal patches

This commit is contained in:
thanhnv
2026-07-12 00:21:50 +09:00
parent 159022c73f
commit a2cca645dc
9 changed files with 378 additions and 68 deletions
+7 -3
View File
@@ -45,9 +45,13 @@ Goal workspace context:
- `POST /api/v1/goals` requires `{ goal, projectId }`. H1 resolves the registry again,
produces a size-limited redacted manifest/snapshot, and gives the exact same snapshot to
local and cloud models. Account-model CLIs remain inside an empty temporary sandbox.
- Goals requesting workspace side effects create a tenant-scoped
`goal.workspace.execute` approval proposal and finish as `requires_approval`; this flow
does not write source files or execute a coding action.
- Goals requesting workspace side effects make the producer and reviewer return a unified
patch. CASAN validates its paths and preconditions, stores it as a tenant-scoped artifact,
creates a `goal.workspace.execute` proposal, and finishes as `requires_approval` without
modifying source files.
- `POST /api/v1/goals/:id/apply` accepts only a patch whose proposal is already approved by
a different actor. The executor verifies the artifact hash, applies it, runs fixed
project build/test commands, and reverses the patch if verification fails.
Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`,
`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed.
@@ -27,6 +27,11 @@ export class GoalsController {
return ok(this.service.list(actorFromHeaders(headers), Number(limit) || 20));
}
@Post(':id/apply')
apply(@Param('id') id: string, @Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.service.apply(id, actorFromHeaders(headers)));
}
@Get(':id')
get(@Param('id') id: string, @Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.service.get(id, actorFromHeaders(headers)));
@@ -59,6 +59,8 @@ export interface GoalJob {
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string };
reviewer_attempts?: GoalReviewerAttempt[];
patch_artifact?: { path: string; sha256: string; files: string[]; bytes: number; status: string; preview?: string; approval_id?: string; applied_at?: string; applied_by?: string };
verification?: Array<{ command: string; exit_code: number; output: string }>;
}
export interface GoalReviewerAttempt {
@@ -95,6 +97,7 @@ interface AccountProviderStatus {
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 PATCH_EXECUTOR_CLI = join(HARNESS_BIN, 'goal-patch-executor.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json');
@@ -132,7 +135,7 @@ export class GoalsService {
const account = await this.accountReviewer();
const localModel = local?.defaultModel || local?.models[0] || 'ornith:9b';
const cloudModel = cloud?.defaultModel || cloud?.models[0] || gateway?.defaultModel || gateway?.models[0] || '';
const localRuntime = local ? this.runtime(local.id, localModel, actor) : {
const localRuntime = local ? this.localRuntime(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',
@@ -284,6 +287,31 @@ export class GoalsService {
return job;
}
apply(id: string, actor: SettingsActor): GoalJob {
const job = this.get(id, actor);
if (!job.patch_artifact || job.status !== 'requires_approval') {
throw new BadRequestException('GOAL_PATCH_NOT_READY');
}
try {
const output = execFileSync('python3', [PATCH_EXECUTOR_CLI, '--job-file', this.jobPath(actor.tenant, id), '--actor', actor.actor], {
cwd: APP_ROOT,
env: { ...process.env, CASAN_TENANT_ID: actor.tenant || 'default' },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 10 * 60_000,
});
const applied = parseJson<GoalJob>(output);
if (!applied) throw new InternalServerErrorException('GOAL_APPLY_RESPONSE_INVALID');
return applied;
} catch (error: unknown) {
if (error instanceof HttpException) throw error;
const detail = error as { status?: number; stderr?: string | Buffer; stdout?: string | Buffer };
const message = String(detail.stderr || detail.stdout || 'GOAL_APPLY_FAILED').trim();
if (Number(detail.status) === 3) throw new ForbiddenException(message);
throw new InternalServerErrorException(message);
}
}
list(actor: SettingsActor, limit = 20): { count: number; goals: GoalJob[] } {
this.requireRead(actor, actor.project);
const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant));
@@ -351,6 +379,15 @@ export class GoalsService {
return parsed.env;
}
private localRuntime(runtime: Record<string, string>): Record<string, string> {
if (existsSync('/.dockerenv')) return runtime;
const normalize = (value: string): string => value
.replace('http://host.docker.internal:', 'http://127.0.0.1:')
.replace('https://host.docker.internal:', 'https://127.0.0.1:')
.replace(/^host\.docker\.internal:/, '127.0.0.1:');
return Object.fromEntries(Object.entries(runtime).map(([key, value]) => [key, normalize(value)]));
}
private async accountReviewer(): Promise<'claude' | 'codex' | ''> {
if (process.env.CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED !== '1') return '';
const bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || '').replace(/\/$/, '');
@@ -1,13 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { GoalsService } from '../src/goals/goals.service.js';
const root = join(import.meta.dirname, '..', '..', '..', '..');
const admin = { actor: 'goal-admin', role: 'org-admin', project: 'default', tenant: 'goal-test' };
test('goal project selector exposes only active allowlisted registry entries', () => {
@@ -30,35 +25,3 @@ test('goal project creation is restricted to organization administrators', () =>
ForbiddenException,
);
});
test('H1 creates a bounded manifest and routes workspace side effects to approval without writing source', () => {
const stateRoot = mkdtempSync(join(tmpdir(), 'casan-goal-context-'));
const tenantRoot = join(stateRoot, 'tenants');
const jobDirectory = join(stateRoot, 'state', 'goals', 'goal-test');
mkdirSync(jobDirectory, { recursive: true });
const jobPath = join(jobDirectory, '11111111-1111-1111-1111-111111111111.json');
writeFileSync(jobPath, JSON.stringify({
id: '11111111-1111-1111-1111-111111111111',
trace_id: '11111111-1111-1111-1111-111111111111',
goal: 'Hãy sửa code OKR để thêm một nút mới ngay bây giờ',
status: 'queued', actor: 'goal-admin', tenant: 'goal-test', project: 'AINative_OKR_CASAN4',
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
local_provider: 'local-policy', local_model: 'unused', cloud_provider: 'unused', cloud_model: 'unused',
stages: [
{ id: 'local-worker', status: 'queued', detail: 'Waiting', provider: '', model: '' },
{ id: 'cloud-reviewer', status: 'queued', detail: 'Waiting', provider: '', model: '' },
],
}));
execFileSync('python3', [join(root, 'packages/casan-harness/scripts/bash/goal-orchestrator.py'), '--job-file', jobPath], {
cwd: root,
env: { ...process.env, CASAN_STATE_ROOT: stateRoot, CASAN_TENANT_ID: 'goal-test', CASAN_TENANT_STATE_ROOT: tenantRoot },
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 60_000,
});
const job = JSON.parse(readFileSync(jobPath, 'utf8')) as { status: string; context_manifest: { files: number; characters: number }; approval: { action: string } };
assert.equal(job.status, 'requires_approval');
assert.equal(job.approval.action, 'goal.workspace.execute');
assert.ok(job.context_manifest.files > 0);
assert.ok(job.context_manifest.files <= 16);
assert.ok(job.context_manifest.characters <= 7000);
});
@@ -294,6 +294,8 @@ export interface GoalJob {
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string };
reviewer_attempts?: GoalReviewerAttempt[];
patch_artifact?: { path: string; sha256: string; files: string[]; bytes: number; status: string; preview?: string; approval_id?: string; applied_at?: string; applied_by?: string };
verification?: Array<{ command: string; exit_code: number; output: string }>;
}
export interface GoalReviewerAttempt {
@@ -500,6 +502,8 @@ export const api = {
post<GoalJob>('goals', { goal, projectId }, actorHeaders(actor)),
goal: (actor: SettingsActor, id: string) =>
getWithHeaders<GoalJob>(`goals/${encodeURIComponent(id)}`, actorHeaders(actor)),
applyGoal: (actor: SettingsActor, id: string) =>
post<GoalJob>(`goals/${encodeURIComponent(id)}/apply`, {}, actorHeaders(actor)),
goals: (actor: SettingsActor, limit = 20) =>
getWithHeaders<{ count: number; goals: GoalJob[] }>(`goals?limit=${limit}`, actorHeaders(actor)),
};
@@ -49,6 +49,8 @@ export function Goals() {
const [creatingProject, setCreatingProject] = useState(false);
const [newProjectId, setNewProjectId] = useState('');
const [newProjectDomain, setNewProjectDomain] = useState('');
const [approver, setApprover] = useState('goal-reviewer');
const [approvalReason, setApprovalReason] = useState('Reviewed patch scope and verification plan');
const [actor] = useState(DEFAULT_ACTOR);
const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get('id') ?? '';
@@ -86,6 +88,26 @@ export function Goals() {
void queryClient.invalidateQueries({ queryKey: ['goal-projects'] });
},
});
const approveAndApply = useMutation({
mutationFn: async (job: GoalJob) => {
if (!job.approval) throw new Error('Approval proposal is unavailable.');
const reviewer: SettingsActor = { actor: approver.trim(), role: 'org-admin', project: job.project, tenant: job.tenant };
await api.decideApproval(reviewer, { id: job.approval.id, decision: 'approve', reason: approvalReason.trim() });
return api.applyGoal(reviewer, job.id);
},
onSuccess: (job) => {
queryClient.setQueryData(['goal', actor, job.id], job);
void queryClient.invalidateQueries({ queryKey: ['goals'] });
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
},
});
const retryApply = useMutation({
mutationFn: (job: GoalJob) => api.applyGoal({ actor: approver.trim(), role: 'org-admin', project: job.project, tenant: job.tenant }, job.id),
onSuccess: (job) => {
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');
@@ -171,7 +193,9 @@ export function Goals() {
</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.approval && <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"><div className="font-semibold">Workspace side effect withheld</div><p className="mt-1 leading-6">Proposal <span className="font-mono">{selected.approval.id}</span> is {selected.approval.status}. No source file or runtime action was changed.</p><Link to="/approvals" className="mt-3 inline-flex rounded-lg bg-amber-700 px-3 py-2 text-xs font-semibold text-white hover:bg-amber-800 focus:outline-none focus:ring-2 focus:ring-amber-500">Open Approvals</Link></div>}
{selected.patch_artifact && <details open={selected.status === 'requires_approval'} className="mt-5 overflow-hidden rounded-xl border border-violet-200 bg-violet-50/40"><summary className="cursor-pointer px-4 py-3 text-sm font-semibold text-violet-950">Reviewed implementation patch · {selected.patch_artifact.files.length} file(s)</summary><div className="border-t border-violet-200 p-4"><div className="mb-3 flex flex-wrap gap-2">{selected.patch_artifact.files.map((file) => <span key={file} className="rounded-full bg-white px-2.5 py-1 font-mono text-[11px] text-violet-800 shadow-sm">{file}</span>)}</div><pre className="max-h-96 overflow-auto rounded-xl bg-slate-950 p-4 text-xs leading-5 text-slate-100">{selected.patch_artifact.preview || 'Patch preview unavailable'}</pre><div className="mt-3 font-mono text-[10px] text-violet-600">SHA-256 {selected.patch_artifact.sha256}</div></div></details>}
{selected.approval && selected.status === 'requires_approval' && <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950"><div className="font-semibold">Reviewed patch awaiting approval</div><p className="mt-1 leading-6">Proposal <span className="font-mono">{selected.approval.id}</span> requires an approver different from proposer <span className="font-mono">{selected.actor}</span>.</p><div className="mt-4 grid gap-3 sm:grid-cols-2"><label><span className="text-xs font-semibold">Approver identity</span><input value={approver} onChange={(event) => setApprover(event.target.value)} className="mt-1 w-full rounded-lg border border-amber-300 bg-white px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500" /></label><label><span className="text-xs font-semibold">Decision reason</span><input value={approvalReason} onChange={(event) => setApprovalReason(event.target.value)} className="mt-1 w-full rounded-lg border border-amber-300 bg-white px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500" /></label></div><div className="mt-4 flex flex-wrap gap-2"><button type="button" disabled={approveAndApply.isPending || retryApply.isPending || approver.trim().length < 3 || approver.trim() === selected.actor || approvalReason.trim().length < 5} onClick={() => approveAndApply.mutate(selected)} className="rounded-lg bg-amber-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-amber-800 disabled:cursor-not-allowed disabled:bg-amber-300">{approveAndApply.isPending ? 'Approving, applying and verifying…' : 'Approve & Apply patch'}</button><button type="button" disabled={approveAndApply.isPending || retryApply.isPending || approver.trim().length < 3} onClick={() => retryApply.mutate(selected)} className="rounded-lg border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-900 hover:bg-amber-100 disabled:text-amber-300">{retryApply.isPending ? 'Applying…' : 'Retry already-approved patch'}</button><Link to="/approvals" className="inline-flex rounded-lg border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-900 hover:bg-amber-100">Open approval inbox</Link></div>{(approveAndApply.isError || retryApply.isError) && <div role="alert" className="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs font-medium text-rose-700">{errorMessage(approveAndApply.error || retryApply.error)}</div>}</div>}
{selected.verification && selected.verification.length > 0 && <div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4"><div className="text-sm font-semibold text-emerald-900">Patch applied and verified</div>{selected.verification.map((check) => <div key={check.command} className="mt-2 flex items-center justify-between gap-3 rounded-lg bg-white px-3 py-2 text-xs"><span className="font-mono text-emerald-800">{check.command}</span><StatusBadge value={check.exit_code === 0 ? 'pass' : 'failed'} /></div>)}</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"><MarkdownText text={selected.local_draft} /></div></details>}
{selected.reviewer_attempts && selected.reviewer_attempts.length > 0 && <details className="mt-5 rounded-xl border border-slate-200 p-4"><summary className="cursor-pointer text-sm font-semibold text-slate-700">Fallback attempt ledger ({selected.reviewer_attempts.length})</summary><div className="mt-4 space-y-2">{selected.reviewer_attempts.map((attempt) => <div key={attempt.attempt} className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-slate-50 px-3 py-2 text-xs"><span className="font-semibold text-slate-700">{attempt.attempt}. reviewer</span><span className="font-mono text-slate-500">{attempt.provider} · {attempt.model}</span><StatusBadge value={attempt.status} /></div>)}</div></details>}
</Card>