From a2cca645dc83ef4e785965ffc0e86c2bbf398a81 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Sun, 12 Jul 2026 00:21:50 +0900 Subject: [PATCH] feat: apply reviewed goal patches --- packages/casan-control-panel/README.md | 10 +- .../backend/src/goals/goals.controller.ts | 5 + .../backend/src/goals/goals.service.ts | 39 ++++- .../backend/test/goals.test.ts | 37 ----- .../frontend/src/lib/api.ts | 4 + .../frontend/src/pages/Goals.tsx | 26 +++- .../scripts/bash/goal-orchestrator.py | 111 ++++++++++---- .../scripts/bash/goal-patch-executor.py | 140 ++++++++++++++++++ .../tests/goal-patch-workflow-tests.py | 74 +++++++++ 9 files changed, 378 insertions(+), 68 deletions(-) create mode 100644 packages/casan-harness/scripts/bash/goal-patch-executor.py create mode 100644 packages/casan-harness/tests/goal-patch-workflow-tests.py diff --git a/packages/casan-control-panel/README.md b/packages/casan-control-panel/README.md index bb029f1..d294dd1 100644 --- a/packages/casan-control-panel/README.md +++ b/packages/casan-control-panel/README.md @@ -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. diff --git a/packages/casan-control-panel/backend/src/goals/goals.controller.ts b/packages/casan-control-panel/backend/src/goals/goals.controller.ts index b7834f2..bbaa2da 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.controller.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.controller.ts @@ -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) { + return ok(this.service.apply(id, actorFromHeaders(headers))); + } + @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.service.ts b/packages/casan-control-panel/backend/src/goals/goals.service.ts index abaebd6..ad33b63 100644 --- a/packages/casan-control-panel/backend/src/goals/goals.service.ts +++ b/packages/casan-control-panel/backend/src/goals/goals.service.ts @@ -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(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): Record { + 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(/\/$/, ''); diff --git a/packages/casan-control-panel/backend/test/goals.test.ts b/packages/casan-control-panel/backend/test/goals.test.ts index c9d616c..6cf73e4 100644 --- a/packages/casan-control-panel/backend/test/goals.test.ts +++ b/packages/casan-control-panel/backend/test/goals.test.ts @@ -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); -}); diff --git a/packages/casan-control-panel/frontend/src/lib/api.ts b/packages/casan-control-panel/frontend/src/lib/api.ts index f26e569..71bfe28 100644 --- a/packages/casan-control-panel/frontend/src/lib/api.ts +++ b/packages/casan-control-panel/frontend/src/lib/api.ts @@ -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('goals', { goal, projectId }, actorHeaders(actor)), goal: (actor: SettingsActor, id: string) => getWithHeaders(`goals/${encodeURIComponent(id)}`, actorHeaders(actor)), + applyGoal: (actor: SettingsActor, id: string) => + post(`goals/${encodeURIComponent(id)}/apply`, {}, actorHeaders(actor)), goals: (actor: SettingsActor, limit = 20) => getWithHeaders<{ count: number; goals: GoalJob[] }>(`goals?limit=${limit}`, actorHeaders(actor)), }; diff --git a/packages/casan-control-panel/frontend/src/pages/Goals.tsx b/packages/casan-control-panel/frontend/src/pages/Goals.tsx index 3d2f104..a4f9646 100644 --- a/packages/casan-control-panel/frontend/src/pages/Goals.tsx +++ b/packages/casan-control-panel/frontend/src/pages/Goals.tsx @@ -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() { )} {selected.error &&
{selected.error}
} - {selected.approval &&
Workspace side effect withheld

Proposal {selected.approval.id} is {selected.approval.status}. No source file or runtime action was changed.

Open Approvals
} + {selected.patch_artifact &&
Reviewed implementation patch · {selected.patch_artifact.files.length} file(s)
{selected.patch_artifact.files.map((file) => {file})}
{selected.patch_artifact.preview || 'Patch preview unavailable'}
SHA-256 {selected.patch_artifact.sha256}
} + {selected.approval && selected.status === 'requires_approval' &&
Reviewed patch awaiting approval

Proposal {selected.approval.id} requires an approver different from proposer {selected.actor}.

Open approval inbox
{(approveAndApply.isError || retryApply.isError) &&
{errorMessage(approveAndApply.error || retryApply.error)}
}
} + {selected.verification && selected.verification.length > 0 &&
Patch applied and verified
{selected.verification.map((check) =>
{check.command}
)}
} {selected.local_draft &&
Inspect local worker draft
} {selected.reviewer_attempts && selected.reviewer_attempts.length > 0 &&
Fallback attempt ledger ({selected.reviewer_attempts.length})
{selected.reviewer_attempts.map((attempt) =>
{attempt.attempt}. reviewer{attempt.provider} · {attempt.model}
)}
} diff --git a/packages/casan-harness/scripts/bash/goal-orchestrator.py b/packages/casan-harness/scripts/bash/goal-orchestrator.py index c2f0751..9409104 100644 --- a/packages/casan-harness/scripts/bash/goal-orchestrator.py +++ b/packages/casan-harness/scripts/bash/goal-orchestrator.py @@ -11,6 +11,7 @@ import hashlib import json import os import re +import shlex import subprocess import tempfile import time @@ -247,19 +248,73 @@ def build_context(job_path: str, project_id: str, goal: str): def requests_side_effect(goal: str) -> bool: normalized = " ".join(goal.lower().split()) 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"\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) -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 = { "goal_id": job["id"], "project_id": job["project"], "context_manifest_hash": manifest["bundle_sha256"], "requested_operation": job["goal"], + "patch_sha256": patch_artifact["sha256"], + "patch_path": patch_artifact["path"], + "changed_files": patch_artifact["files"], } result = subprocess.run([ "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") - if 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 + write_intent = requests_side_effect(safe_goal) 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}) + 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 = ( - "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. " + "You are the local CASAN worker. Solve the user's objective concretely. " + output_contract + "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" 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) 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 = ( "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. Use only the exact bounded, redacted " + + reviewer_contract + "Use only the exact bounded, redacted " "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" 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" 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") 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}) + 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) return 0 except Exception as error: diff --git a/packages/casan-harness/scripts/bash/goal-patch-executor.py b/packages/casan-harness/scripts/bash/goal-patch-executor.py new file mode 100644 index 0000000..f025c20 --- /dev/null +++ b/packages/casan-harness/scripts/bash/goal-patch-executor.py @@ -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()) diff --git a/packages/casan-harness/tests/goal-patch-workflow-tests.py b/packages/casan-harness/tests/goal-patch-workflow-tests.py new file mode 100644 index 0000000..ea02bca --- /dev/null +++ b/packages/casan-harness/tests/goal-patch-workflow-tests.py @@ -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()