feat: appove and go

This commit is contained in:
thanhnv
2026-07-19 09:37:16 +07:00
parent 13fae3e6c3
commit 709b6cccd6
24 changed files with 1245 additions and 70 deletions
@@ -83,7 +83,11 @@ export class ApprovalsService {
list(actor: SettingsActor, status = 'pending') {
this.requireRbac(actor, 'monitoring', 'read');
const res = runFile('python3', [INBOX_CLI, 'list', '--status', status], this.tenantEnv(actor));
return { ...parseJson<Record<string, any>>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit(actor) };
const inbox = parseJson<{ proposals?: Array<Record<string, any>>; oversight?: Array<Record<string, any>> }>(res.stdout, { proposals: [], oversight: [] });
const proposals = (inbox.proposals ?? []).filter((proposal) => this.canAccessProject(actor, String(proposal.project ?? actor.project)));
const visibleIds = new Set(proposals.map((proposal) => String(proposal.id ?? '')));
const oversight = (inbox.oversight ?? []).filter((record) => visibleIds.has(String(record.proposal_id ?? '')));
return { ...inbox, count: proposals.length, proposals, oversight, audit_verify: this.verifyAudit(actor) };
}
submit(input: ApprovalSubmit, actor: SettingsActor) {
@@ -122,9 +126,9 @@ export class ApprovalsService {
if (!input.id || !input.decision || !input.reason) {
throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required');
}
this.requireRbac(actor, 'approval', 'grant');
try {
const pending = this.findProposal(input.id, actor);
this.requireRbac(actor, 'approval', 'grant', String(pending.project ?? actor.project));
await this.verifyApprovalIdentity(input, actor, pending);
const res = runFile('python3', [
INBOX_CLI,
@@ -281,7 +285,16 @@ export class ApprovalsService {
}
}
private requireRbac(actor: SettingsActor, resource: string, action: string) {
private canAccessProject(actor: SettingsActor, targetProject: string): boolean {
try {
this.requireRbac(actor, 'monitoring', 'read', targetProject);
return true;
} catch {
return false;
}
}
private requireRbac(actor: SettingsActor, resource: string, action: string, targetProject = actor.project) {
try {
runFile('python3', [
RBAC_CLI,
@@ -295,7 +308,7 @@ export class ApprovalsService {
'--role-project',
actor.project,
'--target-project',
actor.project,
targetProject,
'--role-tenant',
actor.tenant,
'--target-tenant',
@@ -33,6 +33,14 @@ function roleFromClaim(raw: string | undefined): string {
return 'viewer';
}
function projectFromClaims(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const prefix = 'casan-project:';
const projectClaim = raw.split(/[\s,]+/).find((claim) => claim.startsWith(prefix));
const project = projectClaim?.slice(prefix.length);
return project && /^[A-Za-z0-9._-]+$/.test(project) ? project : undefined;
}
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
const actor = firstHeader(headers['x-casan-actor'])
|| firstHeader(headers['x-auth-request-user'])
@@ -45,7 +53,7 @@ export function actorFromHeaders(headers: Record<string, string | string[] | und
return {
actor,
role: roleFromClaim(roleClaim),
project: firstHeader(headers['x-casan-project']) || 'default',
project: firstHeader(headers['x-casan-project']) || projectFromClaims(roleClaim) || 'default',
tenant: firstHeader(headers['x-casan-tenant']) || 'default',
};
}
@@ -48,6 +48,8 @@ export interface GoalJob {
finished_at?: string;
local_provider: string;
local_model: string;
effective_local_provider?: string;
effective_local_model?: string;
cloud_provider: string;
cloud_model: string;
stages: GoalStage[];
@@ -182,9 +184,20 @@ export class GoalsService {
// must not silently change provider/model after a direct OpenAI repair
// fails. Gateways can have their own transport and model-specific output
// contracts; an operator can explicitly opt in after validating one.
const gatewayPatchRepairModels = process.env.CASAN_GOAL_ENABLE_GATEWAY_PATCH_REPAIR === '1' ? gatewayModels : [];
const gatewayPatchRepairModels = process.env.CASAN_GOAL_ENABLE_GATEWAY_PATCH_REPAIR === '1' ? gatewayModels.slice(0, 1) : [];
const preferredCloudModel = cloudCandidates[0]?.model || '';
const preferredCloudProvider = preferredCloudModel.startsWith('openai:') ? 'openai' : preferredCloudModel.startsWith('anthropic:') ? 'anthropic' : (cloud?.id || 'unavailable');
// Coding-worker order is based on observed capability, not advertised
// discovery: direct cloud credentials first, then a logged-in account
// bridge, and only then the small local model. The account remains H3 when
// direct cloud is H2, preserving a distinct reviewer channel.
const cloudWorker = cloudCandidates[0];
const accountWorker = cloudWorker ? '' : account;
const accountReviewer = cloudWorker ? account : '';
const workerModel = cloudWorker?.model || (accountWorker ? `account:${accountWorker}` : String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`));
const workerProvider = cloudWorker
? (workerModel.startsWith('openai:') ? 'openai' : workerModel.startsWith('anthropic:') ? 'anthropic' : 'cloud')
: (accountWorker ? `${accountWorker}-account` : (local?.id || 'local-policy'));
const cloudModel = preferredCloudModel || gateway?.defaultModel || gateway?.models[0] || '';
const id = randomUUID();
const timestamp = new Date().toISOString();
@@ -199,13 +212,13 @@ export class GoalsService {
workspace,
created_at: timestamp,
updated_at: timestamp,
local_provider: local?.id || 'local-policy',
local_model: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
cloud_provider: account ? `${account}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'unavailable')),
cloud_model: account ? `${account}-account-default` : preferredCloudModel || String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
local_provider: workerProvider,
local_model: workerModel,
cloud_provider: accountReviewer ? `${accountReviewer}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'unavailable')),
cloud_model: accountReviewer ? `${accountReviewer}-account-default` : preferredCloudModel || String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
stages: [
{ id: 'local-worker', status: 'queued', detail: 'Waiting for local worker', provider: local?.id || 'local-policy', model: localModel },
{ id: 'cloud-reviewer', status: 'queued', detail: account || preferredCloudModel || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: account ? `${account}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'local-policy')), model: account ? `${account}-account-default` : cloudModel || localModel },
{ id: 'local-worker', status: 'queued', detail: 'Waiting for primary coding worker', provider: workerProvider, model: workerModel },
{ id: 'cloud-reviewer', status: 'queued', detail: accountReviewer || preferredCloudModel || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: accountReviewer ? `${accountReviewer}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'local-policy')), model: accountReviewer ? `${accountReviewer}-account-default` : cloudModel || localModel },
],
};
const jobFile = this.jobPath(actor.tenant, id);
@@ -226,7 +239,7 @@ export class GoalsService {
CASAN_GOAL_CLOUD_MODEL: job.cloud_model,
CASAN_GOAL_LOCAL_PROVIDER: job.local_provider,
CASAN_GOAL_CLOUD_PROVIDER: job.cloud_provider,
CASAN_GOAL_ACCOUNT_PROVIDER: account || '',
CASAN_GOAL_ACCOUNT_PROVIDER: accountReviewer || '',
CASAN_GOAL_CLOUD_FALLBACK_MODEL: String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
CASAN_GOAL_CLOUD_MODELS: cloudCandidates.map(({ model }) => model).join(','),
CASAN_GOAL_OMNIROUTE_MODELS: gatewayModels.join(','),
@@ -234,6 +247,8 @@ export class GoalsService {
// explicit opt-in because it must not disguise a Codex patch failure.
CASAN_GOAL_PATCH_REPAIR_MODELS: [...cloudCandidates.map(({ model }) => model), ...gatewayPatchRepairModels, String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`)].filter((model, index, rows) => rows.indexOf(model) === index).join(','),
CASAN_GOAL_LOCAL_REVIEWER_MODEL: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
CASAN_GOAL_LOCAL_REVIEWER_PROVIDER: local?.id || 'local-policy',
CASAN_GOAL_ENABLE_LOCAL_REVIEWER: process.env.CASAN_GOAL_ENABLE_LOCAL_REVIEWER || (account || preferredCloudModel ? '0' : '1'),
CASAN_GOAL_REVIEWER_MAX_ATTEMPTS: process.env.CASAN_GOAL_REVIEWER_MAX_ATTEMPTS || '8',
CASAN_GOAL_REVIEWER_DEADLINE_SEC: process.env.CASAN_GOAL_REVIEWER_DEADLINE_SEC || '600',
},
@@ -393,7 +408,8 @@ export class GoalsService {
apply(id: string, actor: SettingsActor): GoalJob {
const job = this.get(id, actor);
if (!job.patch_artifact || job.status !== 'requires_approval') {
const retryableRollback = job.status === 'failed' && job.error === 'GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK';
if (!job.patch_artifact || (job.status !== 'requires_approval' && !retryableRollback)) {
throw new BadRequestException('GOAL_PATCH_NOT_READY');
}
try {