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, - `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 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. local and cloud models. Account-model CLIs remain inside an empty temporary sandbox.
- Goals requesting workspace side effects create a tenant-scoped - Goals requesting workspace side effects make the producer and reviewer return a unified
`goal.workspace.execute` approval proposal and finish as `requires_approval`; this flow patch. CASAN validates its paths and preconditions, stores it as a tenant-scoped artifact,
does not write source files or execute a coding action. 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`, Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`,
`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed. `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)); 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(':id')
get(@Param('id') id: string, @Headers() headers: Record<string, string | string[] | undefined>) { get(@Param('id') id: string, @Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.service.get(id, actorFromHeaders(headers))); 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 }; context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string }; approval?: { id: string; status: string; action: string };
reviewer_attempts?: GoalReviewerAttempt[]; 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 { export interface GoalReviewerAttempt {
@@ -95,6 +97,7 @@ interface AccountProviderStatus {
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash'); const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py'); const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.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 RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json'); 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 account = await this.accountReviewer();
const localModel = local?.defaultModel || local?.models[0] || 'ornith:9b'; const localModel = local?.defaultModel || local?.models[0] || 'ornith:9b';
const cloudModel = cloud?.defaultModel || cloud?.models[0] || gateway?.defaultModel || gateway?.models[0] || ''; 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_CHAT_SELECTED_MODEL: `ollama:${localModel}`,
CASAN_OLLAMA_HOST: process.env.CASAN_OLLAMA_HOST || 'host.docker.internal:11434', CASAN_OLLAMA_HOST: process.env.CASAN_OLLAMA_HOST || 'host.docker.internal:11434',
OLLAMA_HOST: process.env.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; 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[] } { list(actor: SettingsActor, limit = 20): { count: number; goals: GoalJob[] } {
this.requireRead(actor, actor.project); this.requireRead(actor, actor.project);
const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant)); const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant));
@@ -351,6 +379,15 @@ export class GoalsService {
return parsed.env; 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' | ''> { private async accountReviewer(): Promise<'claude' | 'codex' | ''> {
if (process.env.CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED !== '1') return ''; if (process.env.CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED !== '1') return '';
const bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || '').replace(/\/$/, ''); const bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || '').replace(/\/$/, '');
@@ -1,13 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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 { BadRequestException, ForbiddenException } from '@nestjs/common';
import { GoalsService } from '../src/goals/goals.service.js'; 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' }; const admin = { actor: 'goal-admin', role: 'org-admin', project: 'default', tenant: 'goal-test' };
test('goal project selector exposes only active allowlisted registry entries', () => { test('goal project selector exposes only active allowlisted registry entries', () => {
@@ -30,35 +25,3 @@ test('goal project creation is restricted to organization administrators', () =>
ForbiddenException, 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 }; context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string }; approval?: { id: string; status: string; action: string };
reviewer_attempts?: GoalReviewerAttempt[]; 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 { export interface GoalReviewerAttempt {
@@ -500,6 +502,8 @@ export const api = {
post<GoalJob>('goals', { goal, projectId }, actorHeaders(actor)), post<GoalJob>('goals', { goal, projectId }, actorHeaders(actor)),
goal: (actor: SettingsActor, id: string) => goal: (actor: SettingsActor, id: string) =>
getWithHeaders<GoalJob>(`goals/${encodeURIComponent(id)}`, actorHeaders(actor)), 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) => goals: (actor: SettingsActor, limit = 20) =>
getWithHeaders<{ count: number; goals: GoalJob[] }>(`goals?limit=${limit}`, actorHeaders(actor)), getWithHeaders<{ count: number; goals: GoalJob[] }>(`goals?limit=${limit}`, actorHeaders(actor)),
}; };
@@ -49,6 +49,8 @@ export function Goals() {
const [creatingProject, setCreatingProject] = useState(false); const [creatingProject, setCreatingProject] = useState(false);
const [newProjectId, setNewProjectId] = useState(''); const [newProjectId, setNewProjectId] = useState('');
const [newProjectDomain, setNewProjectDomain] = 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 [actor] = useState(DEFAULT_ACTOR);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get('id') ?? ''; const selectedId = searchParams.get('id') ?? '';
@@ -86,6 +88,26 @@ export function Goals() {
void queryClient.invalidateQueries({ queryKey: ['goal-projects'] }); 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 selected = selectedQuery.data;
const localStage = selected?.stages.find((stage) => stage.id === 'local-worker'); const localStage = selected?.stages.find((stage) => stage.id === 'local-worker');
@@ -171,7 +193,9 @@ export function Goals() {
</div> </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.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.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>} {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> </Card>
@@ -11,6 +11,7 @@ import hashlib
import json import json
import os import os
import re import re
import shlex
import subprocess import subprocess
import tempfile import tempfile
import time import time
@@ -247,19 +248,73 @@ def build_context(job_path: str, project_id: str, goal: str):
def requests_side_effect(goal: str) -> bool: def requests_side_effect(goal: str) -> bool:
normalized = " ".join(goal.lower().split()) normalized = " ".join(goal.lower().split())
patterns = [ patterns = [
r"^(hãy\s+)?(làm luôn|sửa|thay đổi|triển khai|chạy|tạo|xóa|cài đặt|commit|push)\b", r"^(hãy\s+)?(làm luôn|sửa|thay đổi|triển khai|thực hiện|hoàn thành|chạy|tạo|xóa|cài đặt|commit|push)\b",
r"^(please\s+)?(implement|fix|change|deploy|run|create|delete|install|commit|push)\b", r"^(please\s+)?(implement|fix|change|deploy|run|create|delete|install|commit|push)\b",
r"\b(thực hiện thao tác|sửa code|ghi file|mở pull request|create a pull request)\b", r"\b(thực hiện thao tác|sửa code|ghi file|mở pull request|create a pull request)\b",
] ]
return any(re.search(pattern, normalized) for pattern in patterns) return any(re.search(pattern, normalized) for pattern in patterns)
def submit_side_effect(job: dict, manifest: dict) -> dict: def extract_patch(text: str) -> str:
match = re.search(r"```(?:diff|patch)\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE)
candidate = match.group(1) if match else text
start = candidate.find("diff --git ")
if start < 0:
raise ValueError("goal_patch_missing")
patch = candidate[start:].strip() + "\n"
if len(patch.encode("utf-8")) > 200_000:
raise ValueError("goal_patch_too_large")
return patch
def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
roots = [str(value).strip("/") for value in job.get("workspace", {}).get("context_roots", [])]
changed = []
header_paths = []
for line in patch.splitlines():
if line.startswith("diff --git "):
fields = shlex.split(line)
if len(fields) != 4:
raise ValueError("goal_patch_header_invalid")
header_paths.extend(fields[2:])
elif line.startswith(("--- ", "+++ ", "rename from ", "rename to ", "copy from ", "copy to ")):
value = line.split(" ", 1)[1].split("\t", 1)[0]
header_paths.append(value)
for raw in header_paths:
path = raw.strip()
if path == "/dev/null":
continue
if path.startswith(("a/", "b/")):
path = path[2:]
if path.startswith("/") or ".." in path.split("/"):
raise ValueError("goal_patch_path_denied")
if not any(path == root or path.startswith(root + "/") for root in roots):
raise ValueError(f"goal_patch_outside_workspace:{path}")
changed.append(path)
if not changed or len(set(changed)) > 20:
raise ValueError("goal_patch_file_count_invalid")
artifact = job_path[:-5] + ".patch"
with open(artifact, "x", encoding="utf-8") as handle:
handle.write(patch)
handle.flush()
os.fsync(handle.fileno())
os.chmod(artifact, 0o600)
check = subprocess.run(["git", "apply", "--check", "--whitespace=error", artifact], cwd=ROOT, capture_output=True, text=True, timeout=30)
if check.returncode != 0:
os.unlink(artifact)
raise ValueError("goal_patch_check_failed:" + (check.stderr or check.stdout).strip()[:160])
return {"path": os.path.relpath(artifact, ROOT), "sha256": sha(patch), "files": sorted(set(changed)), "bytes": len(patch.encode("utf-8")), "status": "awaiting_approval", "preview": patch[:50_000]}
def submit_side_effect(job: dict, manifest: dict, patch_artifact: dict) -> dict:
payload = { payload = {
"goal_id": job["id"], "goal_id": job["id"],
"project_id": job["project"], "project_id": job["project"],
"context_manifest_hash": manifest["bundle_sha256"], "context_manifest_hash": manifest["bundle_sha256"],
"requested_operation": job["goal"], "requested_operation": job["goal"],
"patch_sha256": patch_artifact["sha256"],
"patch_path": patch_artifact["path"],
"changed_files": patch_artifact["files"],
} }
result = subprocess.run([ result = subprocess.run([
"python3", APPROVAL_INBOX, "submit", "python3", APPROVAL_INBOX, "submit",
@@ -561,30 +616,17 @@ def run(job_path: str) -> int:
}) })
emit(goal_id, "H4-security", "running", "Objective and workspace snapshot passed; model outputs pending") emit(goal_id, "H4-security", "running", "Objective and workspace snapshot passed; model outputs pending")
if requests_side_effect(safe_goal): write_intent = requests_side_effect(safe_goal)
proposal = submit_side_effect(job, context_manifest)
approval = {"id": proposal["id"], "status": proposal["status"], "action": proposal["action"]}
stage(job_path, "local-worker", "blocked", "Workspace execution requires approval; no model or tool was allowed to write", job.get("local_provider", ""), local_model)
stage(job_path, "cloud-reviewer", "blocked", "Reviewer is not an execution channel", job.get("cloud_provider", ""), cloud_model)
update_job(job_path, status="requires_approval", approval=approval, result="This objective requests a workspace side effect. CASAN created a governed approval proposal and did not execute or modify files.", finished_at=now())
emit(goal_id, "H2-tool", "blocked", "Side effect withheld pending approval", {"proposal_id": proposal["id"], "action": proposal["action"]})
emit(goal_id, "H3-eval", "blocked", "Cloud reviewer cannot bypass the approval boundary")
emit(goal_id, "H4-security", "pass", "Workspace remained read-only")
gated_job = load_json(job_path)
audit_hash = audit(gated_job, "requires_approval")
emit(goal_id, "H5-governance", "pass", "Approval proposal and decision anchored", {"audit_hash": audit_hash, "proposal_id": proposal["id"]})
metric(gated_job, "degraded", started, {}, {})
emit(goal_id, "H6-agentops", "pass", "Approval routing telemetry recorded")
emit(goal_id, "H7-orchestration", "blocked", "Awaiting governed approval", {"proposal_id": proposal["id"]})
update_job(job_path, audit_hash=audit_hash)
return 0
stage(job_path, "local-worker", "running", "Local model is developing the primary solution", job.get("local_provider", ""), local_model) 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}) emit(goal_id, "H2-tool", "running", "Local worker is developing a solution", {"provider": job.get("local_provider", ""), "model": local_model})
output_contract = (
"Produce ONLY one complete unified git diff inside a ```diff fence. The diff must implement the objective, include every required file, and may only touch paths visible in the workspace snapshot. Do not include commentary outside the diff. "
if write_intent else
"Produce: clarified outcome, assumptions, ordered implementation plan, risks, and verifiable acceptance checks. Respond in the same language as the objective. "
)
local_prompt = ( local_prompt = (
"You are the local CASAN worker. Solve the user's objective concretely. " "You are the local CASAN worker. Solve the user's objective concretely. " + output_contract +
"Produce: clarified outcome, assumptions, ordered implementation plan, risks, "
"and verifiable acceptance checks. Respond in the same language as the objective. "
"Use only the bounded, redacted workspace snapshot below as repository evidence. " "Use only the bounded, redacted workspace snapshot below as repository evidence. "
"Do not claim to inspect any filesystem outside this snapshot and do not perform side effects.\n\n" "Do not claim to inspect any filesystem outside this snapshot and do not perform side effects.\n\n"
f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}" f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}"
@@ -604,11 +646,15 @@ def run(job_path: str) -> int:
stage(job_path, "cloud-reviewer", "running", "Independent reviewer is challenging and improving the local solution", job.get("cloud_provider", ""), cloud_model) stage(job_path, "cloud-reviewer", "running", "Independent reviewer 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}) emit(goal_id, "H3-eval", "running", "Cloud reviewer is evaluating the local solution", {"provider": job.get("cloud_provider", ""), "model": cloud_model})
reviewer_contract = (
"Return ONLY the corrected, complete unified git diff inside a ```diff fence. Preserve valid implementation details, fix defects, and include no prose outside the diff. "
if write_intent else
"Return one final actionable solution with ordered steps and acceptance checks. Respond in the same language as the objective. "
)
review_prompt = ( review_prompt = (
"You are the cloud CASAN reviewer. Critically review the local worker's proposal " "You are the cloud CASAN reviewer. Critically review the local worker's proposal "
"against the objective. Correct gaps, remove unsafe or unverifiable claims, and " "against the objective. Correct gaps, remove unsafe or unverifiable claims, and "
"return one final actionable solution with ordered steps and acceptance checks. " + reviewer_contract + "Use only the exact bounded, redacted "
"Respond in the same language as the objective. Use only the exact bounded, redacted "
"workspace snapshot provided below; your execution directory is intentionally empty. " "workspace snapshot provided below; your execution directory is intentionally empty. "
"Do not inspect or infer from any other filesystem and do not perform side effects.\n\n" "Do not inspect or infer from any other filesystem and do not perform side effects.\n\n"
f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}\n\n" f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}\n\n"
@@ -636,14 +682,27 @@ def run(job_path: str) -> int:
metric_status = "degraded" metric_status = "degraded"
emit(goal_id, "H4-security", "pass", "Objective and all released outputs passed security scans") 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()) if write_intent:
patch = extract_patch(safe_result)
patch_artifact = validate_and_store_patch(job_path, load_json(job_path), patch)
proposal = submit_side_effect(load_json(job_path), context_manifest, patch_artifact)
approval = {"id": proposal["id"], "status": proposal["status"], "action": proposal["action"]}
patch_artifact["approval_id"] = proposal["id"]
final_status = "requires_approval"
metric_status = "degraded"
safe_result = "Implementation patch generated and independently reviewed. Approval is required before applying it to the workspace."
emit(goal_id, "H7-orchestration", "blocked", "Reviewed patch awaits approval", {"proposal_id": proposal["id"], "patch_sha256": patch_artifact["sha256"]})
job = update_job(job_path, status=final_status, result=safe_result, patch_artifact=patch_artifact, approval=approval, cloud_usage=cloud_meta, finished_at=now())
else:
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") emit(goal_id, "H5-governance", "running", "Anchoring orchestration decision")
audit_hash = audit(job, final_status) 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, "H5-governance", "pass", "Orchestration decision anchored", {"audit_hash": audit_hash, "status": final_status})
emit(goal_id, "H6-agentops", "running", "Recording orchestration telemetry") emit(goal_id, "H6-agentops", "running", "Recording orchestration telemetry")
metric(job, metric_status, started, local_meta, cloud_meta) 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, "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}) if not write_intent:
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) update_job(job_path, audit_hash=audit_hash)
return 0 return 0
except Exception as error: except Exception as error:
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Apply an approved Goal patch with bounded paths and rollback-on-failure."""
import argparse
import hashlib
import json
import os
import subprocess
import tempfile
from datetime import datetime, timezone
def 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_APPLY_ROOT_NOT_FOUND")
ROOT = root()
INBOX = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash", "approval-inbox.py")
def now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def load(path: str) -> dict:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
def save(path: str, payload: dict) -> None:
directory = os.path.dirname(path)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=directory, delete=False) as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False)
handle.write("\n")
temporary = handle.name
os.chmod(temporary, 0o600)
os.replace(temporary, path)
def run(command: list[str], timeout: int = 180) -> subprocess.CompletedProcess[str]:
return subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=timeout)
def verify_approval(job: dict) -> dict:
result = run(["python3", INBOX, "list", "--status", "all"], 30)
if result.returncode != 0:
raise RuntimeError("GOAL_APPLY_APPROVAL_STORE_UNAVAILABLE")
proposal = next((row for row in json.loads(result.stdout).get("proposals", []) if row.get("id") == job.get("approval", {}).get("id")), None)
artifact = job.get("patch_artifact", {})
if not proposal or proposal.get("status") != "approved":
raise PermissionError("GOAL_APPLY_APPROVAL_REQUIRED")
if proposal.get("action") != "goal.workspace.execute" or proposal.get("payload", {}).get("goal_id") != job.get("id"):
raise PermissionError("GOAL_APPLY_APPROVAL_SCOPE_MISMATCH")
if proposal.get("payload", {}).get("patch_sha256") != artifact.get("sha256"):
raise PermissionError("GOAL_APPLY_PATCH_HASH_MISMATCH")
if sorted(proposal.get("payload", {}).get("changed_files", [])) != sorted(artifact.get("files", [])):
raise PermissionError("GOAL_APPLY_FILE_SCOPE_MISMATCH")
return proposal
def verification_commands(files: list[str]) -> list[list[str]]:
commands = [["git", "diff", "--check", "--", *files]]
if any(path.startswith("apps/okr/frontend/") for path in files):
commands.append(["npm", "run", "build", "-w", "@ainative-okr/frontend"])
commands.append(["npm", "test", "-w", "@ainative-okr/frontend"])
if any(path.startswith("apps/okr/backend/") for path in files):
commands.append(["npm", "run", "build", "-w", "@ainative-okr/backend"])
commands.append(["npm", "test", "-w", "@ainative-okr/backend"])
return commands
def execute(job_path: str, actor: str) -> dict:
job = load(job_path)
if job.get("status") != "requires_approval" or not job.get("patch_artifact"):
raise RuntimeError("GOAL_APPLY_JOB_NOT_READY")
proposal = verify_approval(job)
if proposal.get("approver") != actor:
raise PermissionError("GOAL_APPLY_APPROVER_IDENTITY_MISMATCH")
artifact = job["patch_artifact"]
patch_path = os.path.realpath(os.path.join(ROOT, artifact["path"]))
state_root = os.path.realpath(os.path.join(ROOT, ".specify", "state", "goals"))
if not patch_path.startswith(state_root + os.sep) or not os.path.isfile(patch_path):
raise PermissionError("GOAL_APPLY_ARTIFACT_PATH_DENIED")
with open(patch_path, "rb") as handle:
actual_hash = hashlib.sha256(handle.read()).hexdigest()
if actual_hash != artifact["sha256"]:
raise PermissionError("GOAL_APPLY_ARTIFACT_TAMPERED")
check = run(["git", "apply", "--check", "--whitespace=error", patch_path], 30)
if check.returncode != 0:
raise RuntimeError("GOAL_APPLY_PRECONDITION_FAILED:" + (check.stderr or check.stdout).strip()[:200])
applied = run(["git", "apply", "--whitespace=error", patch_path], 30)
if applied.returncode != 0:
raise RuntimeError("GOAL_APPLY_FAILED:" + (applied.stderr or applied.stdout).strip()[:200])
checks = []
try:
for command in verification_commands(artifact.get("files", [])):
result = run(command, 300)
checks.append({"command": " ".join(command), "exit_code": result.returncode, "output": (result.stdout + result.stderr)[-4000:]})
if result.returncode != 0:
raise RuntimeError("GOAL_APPLY_VERIFICATION_FAILED")
except Exception:
rollback = run(["git", "apply", "--reverse", patch_path], 30)
if rollback.returncode != 0:
raise RuntimeError("GOAL_APPLY_ROLLBACK_FAILED")
job.update(status="failed", error="GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK", verification=checks, finished_at=now(), updated_at=now())
save(job_path, job)
raise
artifact["status"] = "applied"
artifact["applied_at"] = now()
artifact["applied_by"] = actor
job.update(status="completed", result="Approved implementation patch applied and verified successfully.", patch_artifact=artifact, verification=checks, finished_at=now(), updated_at=now())
save(job_path, job)
return job
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--job-file", required=True)
parser.add_argument("--actor", required=True)
args = parser.parse_args()
try:
print(json.dumps(execute(os.path.realpath(args.job_file), args.actor), ensure_ascii=False))
return 0
except PermissionError as error:
print(str(error), file=os.sys.stderr)
return 3
except Exception as error:
print(str(error), file=os.sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import importlib.util
import json
import os
import tempfile
import unittest
from unittest.mock import patch
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
def load_module(name, relative):
spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, relative))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
ORCHESTRATOR = load_module("goal_orchestrator_patch", "packages/casan-harness/scripts/bash/goal-orchestrator.py")
EXECUTOR = load_module("goal_patch_executor", "packages/casan-harness/scripts/bash/goal-patch-executor.py")
class Result:
def __init__(self, code=0, stdout="", stderr=""):
self.returncode = code
self.stdout = stdout
self.stderr = stderr
class GoalPatchWorkflowTests(unittest.TestCase):
def test_vietnamese_completion_goal_is_write_intent(self):
self.assertTrue(ORCHESTRATOR.requests_side_effect("Hoàn thành component KeyResultDetail với form update progress đầy đủ"))
def test_extract_patch_requires_unified_diff(self):
with self.assertRaisesRegex(ValueError, "goal_patch_missing"):
ORCHESTRATOR.extract_patch("implementation plan only")
value = ORCHESTRATOR.extract_patch("```diff\ndiff --git a/apps/okr/frontend/a.ts b/apps/okr/frontend/a.ts\n--- a/apps/okr/frontend/a.ts\n+++ b/apps/okr/frontend/a.ts\n@@ -1 +1 @@\n-a\n+b\n```")
self.assertTrue(value.startswith("diff --git"))
def test_patch_outside_workspace_is_denied(self):
job = {"workspace": {"context_roots": ["apps/okr/frontend"]}}
content = "diff --git a/package.json b/package.json\n--- a/package.json\n+++ b/package.json\n@@ -1 +1 @@\n-a\n+b\n"
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "job.json")
with self.assertRaisesRegex(ValueError, "goal_patch_outside_workspace"):
ORCHESTRATOR.validate_and_store_patch(path, job, content)
def test_patch_rename_source_outside_workspace_is_denied(self):
job = {"workspace": {"context_roots": ["apps/okr/frontend"]}}
content = "diff --git a/package.json b/apps/okr/frontend/package.json\nsimilarity index 100%\nrename from package.json\nrename to apps/okr/frontend/package.json\n"
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(ValueError, "goal_patch_outside_workspace"):
ORCHESTRATOR.validate_and_store_patch(os.path.join(directory, "job.json"), job, content)
def test_executor_requires_approved_matching_proposal(self):
job = {"id": "goal-1", "approval": {"id": "AP-1"}, "patch_artifact": {"sha256": "abc"}}
pending = {"proposals": [{"id": "AP-1", "status": "pending", "action": "goal.workspace.execute", "payload": {"goal_id": "goal-1", "patch_sha256": "abc"}}]}
with patch.object(EXECUTOR, "run", return_value=Result(stdout=json.dumps(pending))):
with self.assertRaisesRegex(PermissionError, "GOAL_APPLY_APPROVAL_REQUIRED"):
EXECUTOR.verify_approval(job)
mismatched = {"proposals": [{"id": "AP-1", "status": "approved", "action": "goal.workspace.execute", "payload": {"goal_id": "goal-1", "patch_sha256": "different"}}]}
with patch.object(EXECUTOR, "run", return_value=Result(stdout=json.dumps(mismatched))):
with self.assertRaisesRegex(PermissionError, "GOAL_APPLY_PATCH_HASH_MISMATCH"):
EXECUTOR.verify_approval(job)
def test_frontend_patch_runs_build_and_tests(self):
commands = EXECUTOR.verification_commands(["apps/okr/frontend/src/pages/KeyResultDetail.tsx"])
self.assertIn(["npm", "run", "build", "-w", "@ainative-okr/frontend"], commands)
self.assertIn(["npm", "test", "-w", "@ainative-okr/frontend"], commands)
if __name__ == "__main__":
unittest.main()