feat: apply reviewed goal patches
This commit is contained in:
@@ -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,6 +682,18 @@ 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")
|
||||||
|
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())
|
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)
|
||||||
@@ -643,6 +701,7 @@ def run(job_path: str) -> int:
|
|||||||
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})
|
||||||
|
if not write_intent:
|
||||||
emit(goal_id, "H7-orchestration", "pass", "Local worker and cloud review workflow completed", {"status": final_status, "cloud_incorporated": cloud_ok})
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user