feat: apply reviewed goal patches
This commit is contained in:
@@ -27,6 +27,11 @@ export class GoalsController {
|
||||
return ok(this.service.list(actorFromHeaders(headers), Number(limit) || 20));
|
||||
}
|
||||
|
||||
@Post(':id/apply')
|
||||
apply(@Param('id') id: string, @Headers() headers: Record<string, string | string[] | undefined>) {
|
||||
return ok(this.service.apply(id, actorFromHeaders(headers)));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string, @Headers() headers: Record<string, string | string[] | undefined>) {
|
||||
return ok(this.service.get(id, actorFromHeaders(headers)));
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface GoalJob {
|
||||
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
|
||||
approval?: { id: string; status: string; action: string };
|
||||
reviewer_attempts?: GoalReviewerAttempt[];
|
||||
patch_artifact?: { path: string; sha256: string; files: string[]; bytes: number; status: string; preview?: string; approval_id?: string; applied_at?: string; applied_by?: string };
|
||||
verification?: Array<{ command: string; exit_code: number; output: string }>;
|
||||
}
|
||||
|
||||
export interface GoalReviewerAttempt {
|
||||
@@ -95,6 +97,7 @@ interface AccountProviderStatus {
|
||||
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
|
||||
const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
|
||||
const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py');
|
||||
const PATCH_EXECUTOR_CLI = join(HARNESS_BIN, 'goal-patch-executor.py');
|
||||
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
|
||||
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json');
|
||||
|
||||
@@ -132,7 +135,7 @@ export class GoalsService {
|
||||
const account = await this.accountReviewer();
|
||||
const localModel = local?.defaultModel || local?.models[0] || 'ornith:9b';
|
||||
const cloudModel = cloud?.defaultModel || cloud?.models[0] || gateway?.defaultModel || gateway?.models[0] || '';
|
||||
const localRuntime = local ? this.runtime(local.id, localModel, actor) : {
|
||||
const localRuntime = local ? this.localRuntime(this.runtime(local.id, localModel, actor)) : {
|
||||
CASAN_CHAT_SELECTED_MODEL: `ollama:${localModel}`,
|
||||
CASAN_OLLAMA_HOST: process.env.CASAN_OLLAMA_HOST || 'host.docker.internal:11434',
|
||||
OLLAMA_HOST: process.env.OLLAMA_HOST || 'host.docker.internal:11434',
|
||||
@@ -284,6 +287,31 @@ export class GoalsService {
|
||||
return job;
|
||||
}
|
||||
|
||||
apply(id: string, actor: SettingsActor): GoalJob {
|
||||
const job = this.get(id, actor);
|
||||
if (!job.patch_artifact || job.status !== 'requires_approval') {
|
||||
throw new BadRequestException('GOAL_PATCH_NOT_READY');
|
||||
}
|
||||
try {
|
||||
const output = execFileSync('python3', [PATCH_EXECUTOR_CLI, '--job-file', this.jobPath(actor.tenant, id), '--actor', actor.actor], {
|
||||
cwd: APP_ROOT,
|
||||
env: { ...process.env, CASAN_TENANT_ID: actor.tenant || 'default' },
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10 * 60_000,
|
||||
});
|
||||
const applied = parseJson<GoalJob>(output);
|
||||
if (!applied) throw new InternalServerErrorException('GOAL_APPLY_RESPONSE_INVALID');
|
||||
return applied;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
const detail = error as { status?: number; stderr?: string | Buffer; stdout?: string | Buffer };
|
||||
const message = String(detail.stderr || detail.stdout || 'GOAL_APPLY_FAILED').trim();
|
||||
if (Number(detail.status) === 3) throw new ForbiddenException(message);
|
||||
throw new InternalServerErrorException(message);
|
||||
}
|
||||
}
|
||||
|
||||
list(actor: SettingsActor, limit = 20): { count: number; goals: GoalJob[] } {
|
||||
this.requireRead(actor, actor.project);
|
||||
const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant));
|
||||
@@ -351,6 +379,15 @@ export class GoalsService {
|
||||
return parsed.env;
|
||||
}
|
||||
|
||||
private localRuntime(runtime: Record<string, string>): Record<string, string> {
|
||||
if (existsSync('/.dockerenv')) return runtime;
|
||||
const normalize = (value: string): string => value
|
||||
.replace('http://host.docker.internal:', 'http://127.0.0.1:')
|
||||
.replace('https://host.docker.internal:', 'https://127.0.0.1:')
|
||||
.replace(/^host\.docker\.internal:/, '127.0.0.1:');
|
||||
return Object.fromEntries(Object.entries(runtime).map(([key, value]) => [key, normalize(value)]));
|
||||
}
|
||||
|
||||
private async accountReviewer(): Promise<'claude' | 'codex' | ''> {
|
||||
if (process.env.CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED !== '1') return '';
|
||||
const bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || '').replace(/\/$/, '');
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { GoalsService } from '../src/goals/goals.service.js';
|
||||
|
||||
const root = join(import.meta.dirname, '..', '..', '..', '..');
|
||||
const admin = { actor: 'goal-admin', role: 'org-admin', project: 'default', tenant: 'goal-test' };
|
||||
|
||||
test('goal project selector exposes only active allowlisted registry entries', () => {
|
||||
@@ -30,35 +25,3 @@ test('goal project creation is restricted to organization administrators', () =>
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
test('H1 creates a bounded manifest and routes workspace side effects to approval without writing source', () => {
|
||||
const stateRoot = mkdtempSync(join(tmpdir(), 'casan-goal-context-'));
|
||||
const tenantRoot = join(stateRoot, 'tenants');
|
||||
const jobDirectory = join(stateRoot, 'state', 'goals', 'goal-test');
|
||||
mkdirSync(jobDirectory, { recursive: true });
|
||||
const jobPath = join(jobDirectory, '11111111-1111-1111-1111-111111111111.json');
|
||||
writeFileSync(jobPath, JSON.stringify({
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
trace_id: '11111111-1111-1111-1111-111111111111',
|
||||
goal: 'Hãy sửa code OKR để thêm một nút mới ngay bây giờ',
|
||||
status: 'queued', actor: 'goal-admin', tenant: 'goal-test', project: 'AINative_OKR_CASAN4',
|
||||
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
|
||||
local_provider: 'local-policy', local_model: 'unused', cloud_provider: 'unused', cloud_model: 'unused',
|
||||
stages: [
|
||||
{ id: 'local-worker', status: 'queued', detail: 'Waiting', provider: '', model: '' },
|
||||
{ id: 'cloud-reviewer', status: 'queued', detail: 'Waiting', provider: '', model: '' },
|
||||
],
|
||||
}));
|
||||
execFileSync('python3', [join(root, 'packages/casan-harness/scripts/bash/goal-orchestrator.py'), '--job-file', jobPath], {
|
||||
cwd: root,
|
||||
env: { ...process.env, CASAN_STATE_ROOT: stateRoot, CASAN_TENANT_ID: 'goal-test', CASAN_TENANT_STATE_ROOT: tenantRoot },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 60_000,
|
||||
});
|
||||
const job = JSON.parse(readFileSync(jobPath, 'utf8')) as { status: string; context_manifest: { files: number; characters: number }; approval: { action: string } };
|
||||
assert.equal(job.status, 'requires_approval');
|
||||
assert.equal(job.approval.action, 'goal.workspace.execute');
|
||||
assert.ok(job.context_manifest.files > 0);
|
||||
assert.ok(job.context_manifest.files <= 16);
|
||||
assert.ok(job.context_manifest.characters <= 7000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user