feat: updade workspace
This commit is contained in:
@@ -38,6 +38,17 @@ Settings management:
|
||||
permission; calls `rbac-check.py` before `control-plane-settings.py set`.
|
||||
- `POST /api/v1/settings/rollback` — governed rollback through the same core CLI.
|
||||
|
||||
Goal workspace context:
|
||||
|
||||
- `GET /api/v1/goals/projects` — lists active project IDs and context roots from the
|
||||
harness-owned project registry after RBAC filtering; browser-supplied paths are never accepted.
|
||||
- `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.
|
||||
|
||||
Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`,
|
||||
`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed.
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ function stableJson(value: unknown): string {
|
||||
export class ApprovalsService {
|
||||
list(actor: SettingsActor, status = 'pending') {
|
||||
this.requireRbac(actor, 'monitoring', 'read');
|
||||
const res = runFile('python3', [INBOX_CLI, 'list', '--status', status]);
|
||||
return { ...parseJson<Record<string, any>>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit() };
|
||||
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) };
|
||||
}
|
||||
|
||||
submit(input: ApprovalSubmit, actor: SettingsActor) {
|
||||
@@ -109,8 +109,8 @@ export class ApprovalsService {
|
||||
JSON.stringify(input.payload ?? {}),
|
||||
];
|
||||
if (input.sensitive) args.push('--sensitive');
|
||||
const res = runFile('python3', args);
|
||||
return { proposal: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
|
||||
const res = runFile('python3', args, this.tenantEnv(actor));
|
||||
return { proposal: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit(actor) };
|
||||
}
|
||||
|
||||
decide(input: ApprovalDecision, actor: SettingsActor) {
|
||||
@@ -119,7 +119,7 @@ export class ApprovalsService {
|
||||
}
|
||||
this.requireRbac(actor, 'approval', 'grant');
|
||||
try {
|
||||
const pending = this.findProposal(input.id);
|
||||
const pending = this.findProposal(input.id, actor);
|
||||
this.verifyApprovalIdentity(input, actor, pending);
|
||||
const res = runFile('python3', [
|
||||
INBOX_CLI,
|
||||
@@ -132,10 +132,10 @@ export class ApprovalsService {
|
||||
actor.actor,
|
||||
'--reason',
|
||||
input.reason,
|
||||
]);
|
||||
], this.tenantEnv(actor));
|
||||
const proposal = parseJson<Record<string, any>>(res.stdout, {});
|
||||
const applied = input.decision === 'approve' ? this.applyApprovedProposal(proposal, actor) : null;
|
||||
return { proposal, applied, audit_verify: this.verifyAudit() };
|
||||
return { proposal, applied, audit_verify: this.verifyAudit(actor) };
|
||||
} catch (err: any) {
|
||||
if (err instanceof ForbiddenException) throw err;
|
||||
if (Number(err.status) === 3 || Number(err.status) === 1) {
|
||||
@@ -145,8 +145,8 @@ export class ApprovalsService {
|
||||
}
|
||||
}
|
||||
|
||||
private findProposal(id: string) {
|
||||
const res = runFile('python3', [INBOX_CLI, 'list', '--status', 'all']);
|
||||
private findProposal(id: string, actor: SettingsActor) {
|
||||
const res = runFile('python3', [INBOX_CLI, 'list', '--status', 'all'], this.tenantEnv(actor));
|
||||
const store = parseJson<Record<string, any>>(res.stdout, { proposals: [] });
|
||||
const proposal = (store.proposals ?? []).find((p: Record<string, any>) => p.id === id);
|
||||
if (!proposal) throw new ForbiddenException(`APPROVAL_DECIDE_DENY unknown_id ${id}`);
|
||||
@@ -199,7 +199,7 @@ export class ApprovalsService {
|
||||
`approved:${proposal.id}:${proposal.decision_reason ?? ''}`,
|
||||
'--approval',
|
||||
`inbox:${proposal.id}:${actor.actor}`,
|
||||
]);
|
||||
], this.tenantEnv(actor));
|
||||
return parseJson<Record<string, any>>(res.stdout, {});
|
||||
} catch (err: any) {
|
||||
if (Number(err.status) === 2 || Number(err.status) === 3) {
|
||||
@@ -234,9 +234,13 @@ export class ApprovalsService {
|
||||
}
|
||||
}
|
||||
|
||||
private verifyAudit() {
|
||||
private tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv {
|
||||
return { CASAN_TENANT_ID: actor.tenant || 'default' };
|
||||
}
|
||||
|
||||
private verifyAudit(actor: SettingsActor) {
|
||||
try {
|
||||
const res = runFile('python3', [INBOX_CLI, 'verify-audit']);
|
||||
const res = runFile('python3', [INBOX_CLI, 'verify-audit'], this.tenantEnv(actor));
|
||||
return { ok: true, output: res.stdout };
|
||||
} catch (err: any) {
|
||||
return { ok: false, output: err.stderr || err.stdout || err.message };
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GoalsController {
|
||||
return ok(await this.service.start(body, actorFromHeaders(headers)));
|
||||
}
|
||||
|
||||
@Get('projects')
|
||||
projects(@Headers() headers: Record<string, string | string[] | undefined>) {
|
||||
return ok(this.service.projects(actorFromHeaders(headers)));
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Headers() headers: Record<string, string | string[] | undefined>, @Query('limit') limit?: string) {
|
||||
return ok(this.service.list(actorFromHeaders(headers), Number(limit) || 20));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
@@ -8,6 +8,14 @@ import type { SettingsActor } from '../settings/settings.service.js';
|
||||
|
||||
export interface GoalStartInput {
|
||||
goal: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface GoalProject {
|
||||
project_id: string;
|
||||
domain: string;
|
||||
domain_root: string;
|
||||
context_roots: string[];
|
||||
}
|
||||
|
||||
export interface GoalStage {
|
||||
@@ -23,7 +31,7 @@ export interface GoalJob {
|
||||
id: string;
|
||||
trace_id: string;
|
||||
goal: string;
|
||||
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed';
|
||||
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed' | 'requires_approval';
|
||||
actor: string;
|
||||
tenant: string;
|
||||
project: string;
|
||||
@@ -42,6 +50,9 @@ export interface GoalJob {
|
||||
audit_hash?: string;
|
||||
local_usage?: Record<string, number>;
|
||||
cloud_usage?: Record<string, number>;
|
||||
workspace?: GoalProject;
|
||||
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
|
||||
approval?: { id: string; status: string; action: string };
|
||||
}
|
||||
|
||||
interface ModelConnection {
|
||||
@@ -67,6 +78,7 @@ 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 RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
|
||||
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json');
|
||||
|
||||
function parseJson<T>(value: string): T | null {
|
||||
try {
|
||||
@@ -86,7 +98,8 @@ export class GoalsService {
|
||||
private readonly startWindows = new Map<string, number[]>();
|
||||
|
||||
async start(input: GoalStartInput, actor: SettingsActor): Promise<GoalJob> {
|
||||
this.requireRead(actor);
|
||||
const workspace = this.resolveProject(String(input.projectId ?? ''));
|
||||
this.requireRead(actor, workspace.project_id);
|
||||
const goal = String(input.goal ?? '').trim();
|
||||
if (goal.length < 10 || goal.length > 8000) {
|
||||
throw new BadRequestException('GOAL_LENGTH_INVALID');
|
||||
@@ -115,7 +128,8 @@ export class GoalsService {
|
||||
status: 'queued',
|
||||
actor: actor.actor,
|
||||
tenant: actor.tenant,
|
||||
project: actor.project,
|
||||
project: workspace.project_id,
|
||||
workspace,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
local_provider: local?.id || 'local-policy',
|
||||
@@ -156,24 +170,39 @@ export class GoalsService {
|
||||
return job;
|
||||
}
|
||||
|
||||
projects(actor: SettingsActor): { count: number; projects: GoalProject[] } {
|
||||
const projects = this.registeredProjects().filter((project) => {
|
||||
try {
|
||||
this.requireRead(actor, project.project_id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return { count: projects.length, projects };
|
||||
}
|
||||
|
||||
get(id: string, actor: SettingsActor): GoalJob {
|
||||
this.requireRead(actor);
|
||||
if (!/^[a-f0-9-]{36}$/.test(id)) throw new NotFoundException('GOAL_NOT_FOUND');
|
||||
const path = this.jobPath(actor.tenant, id);
|
||||
if (!existsSync(path)) throw new NotFoundException('GOAL_NOT_FOUND');
|
||||
const job = parseJson<GoalJob>(readFileSync(path, 'utf8'));
|
||||
if (!job || job.tenant !== actor.tenant) throw new NotFoundException('GOAL_NOT_FOUND');
|
||||
this.requireRead(actor, job.project);
|
||||
return job;
|
||||
}
|
||||
|
||||
list(actor: SettingsActor, limit = 20): { count: number; goals: GoalJob[] } {
|
||||
this.requireRead(actor);
|
||||
this.requireRead(actor, actor.project);
|
||||
const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant));
|
||||
if (!existsSync(directory)) return { count: 0, goals: [] };
|
||||
const goals = readdirSync(directory)
|
||||
.filter((name) => /^[a-f0-9-]{36}\.json$/.test(name))
|
||||
.map((name) => parseJson<GoalJob>(readFileSync(join(directory, name), 'utf8')))
|
||||
.filter((job): job is GoalJob => Boolean(job && job.tenant === actor.tenant))
|
||||
.filter((job) => {
|
||||
try { this.requireRead(actor, job.project); return true; } catch { return false; }
|
||||
})
|
||||
.sort((left, right) => right.created_at.localeCompare(left.created_at));
|
||||
return { count: goals.length, goals: goals.slice(0, Math.max(1, Math.min(limit, 100))) };
|
||||
}
|
||||
@@ -182,6 +211,34 @@ export class GoalsService {
|
||||
return join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(tenant), `${id}.json`);
|
||||
}
|
||||
|
||||
private registeredProjects(): GoalProject[] {
|
||||
const parsed = parseJson<{ projects?: Array<Record<string, unknown>> }>(readFileSync(PROJECT_REGISTRY, 'utf8'));
|
||||
const root = realpathSync(APP_ROOT);
|
||||
return (parsed?.projects ?? []).filter((entry) => entry.status === 'active').map((entry) => {
|
||||
const projectId = String(entry.project_id ?? '');
|
||||
const domainRoot = String(entry.domain_root ?? '');
|
||||
const rawRoots = Array.isArray(entry.context_roots) ? entry.context_roots.map(String) : [domainRoot];
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(projectId) || !domainRoot || rawRoots.length === 0) {
|
||||
throw new InternalServerErrorException('GOAL_PROJECT_REGISTRY_INVALID');
|
||||
}
|
||||
const contextRoots = rawRoots.map((relative) => {
|
||||
const absolute = realpathSync(join(APP_ROOT, relative));
|
||||
if (!(absolute === root || absolute.startsWith(`${root}/`)) || !statSync(absolute).isDirectory() && !statSync(absolute).isFile()) {
|
||||
throw new InternalServerErrorException('GOAL_PROJECT_CONTEXT_ROOT_DENIED');
|
||||
}
|
||||
return relative;
|
||||
});
|
||||
return { project_id: projectId, domain: String(entry.domain ?? projectId), domain_root: domainRoot, context_roots: contextRoots };
|
||||
});
|
||||
}
|
||||
|
||||
private resolveProject(projectId: string): GoalProject {
|
||||
if (!projectId) throw new BadRequestException('GOAL_PROJECT_REQUIRED');
|
||||
const project = this.registeredProjects().find((entry) => entry.project_id === projectId);
|
||||
if (!project) throw new BadRequestException('GOAL_PROJECT_NOT_ALLOWED');
|
||||
return project;
|
||||
}
|
||||
|
||||
private connections(actor: SettingsActor): ModelConnection[] {
|
||||
const payload = this.runPython(CONNECTIONS_CLI, ['list'], { CASAN_TENANT_ID: actor.tenant || 'default' });
|
||||
const parsed = parseJson<ConnectionList>(payload);
|
||||
@@ -259,10 +316,10 @@ export class GoalsService {
|
||||
}
|
||||
}
|
||||
|
||||
private requireRead(actor: SettingsActor): void {
|
||||
private requireRead(actor: SettingsActor, targetProject: string): void {
|
||||
try {
|
||||
execFileSync('python3', [RBAC_CLI, 'check', '--role', actor.role, '--resource', 'monitoring', '--action', 'read',
|
||||
'--role-project', actor.project, '--target-project', actor.project,
|
||||
'--role-project', actor.project, '--target-project', targetProject,
|
||||
'--role-tenant', actor.tenant, '--target-tenant', actor.tenant], {
|
||||
cwd: APP_ROOT,
|
||||
env: process.env,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { APP_ROOT } from '../common/app-root.js';
|
||||
@@ -60,10 +60,11 @@ function parseJson<T>(raw: string, fallback: T): T {
|
||||
export class SettingsService {
|
||||
list(actor: SettingsActor) {
|
||||
this.requireRbac(actor, 'read', false);
|
||||
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy']).stdout, {});
|
||||
const settings = parseJson<Record<string, any>>(runPython(CP_CLI, ['get-all']).stdout, {});
|
||||
const audit = parseJson<any[]>(runPython(CP_CLI, ['get-audit']).stdout, []);
|
||||
const auditVerify = this.verifyAudit();
|
||||
const tenantEnv = this.tenantEnv(actor);
|
||||
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy'], tenantEnv).stdout, {});
|
||||
const settings = parseJson<Record<string, any>>(runPython(CP_CLI, ['get-all'], tenantEnv).stdout, {});
|
||||
const audit = parseJson<any[]>(runPython(CP_CLI, ['get-audit'], tenantEnv).stdout, []);
|
||||
const auditVerify = this.verifyAudit(actor);
|
||||
|
||||
return {
|
||||
actor,
|
||||
@@ -83,7 +84,7 @@ export class SettingsService {
|
||||
if (!input.key || input.value === undefined || !input.reason) {
|
||||
throw new ForbiddenException('SETTINGS_DENY key/value/reason required');
|
||||
}
|
||||
const sensitive = this.isSensitive(input.key);
|
||||
const sensitive = this.isSensitive(input.key, actor);
|
||||
this.requireRbac(actor, 'write', sensitive);
|
||||
try {
|
||||
const res = runPython(CP_CLI, [
|
||||
@@ -96,11 +97,12 @@ export class SettingsService {
|
||||
input.reason,
|
||||
'--approval',
|
||||
input.approval ?? '',
|
||||
]);
|
||||
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
|
||||
], this.tenantEnv(actor));
|
||||
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit(actor) };
|
||||
} catch (err: any) {
|
||||
if (Number(err.status) === 3) throw new ForbiddenException(err.stderr || 'APPROVAL_REQUIRED');
|
||||
if (Number(err.status) === 2) throw new ForbiddenException(err.stderr || 'SETTING_NOT_ALLOWED');
|
||||
if (Number(err.status) === 5) throw new BadRequestException(err.stderr || 'SETTING_VALIDATION_ERROR');
|
||||
throw new InternalServerErrorException(err.stderr || err.message);
|
||||
}
|
||||
}
|
||||
@@ -109,19 +111,19 @@ export class SettingsService {
|
||||
if (!input.key || !input.reason) {
|
||||
throw new ForbiddenException('SETTINGS_DENY key/reason required');
|
||||
}
|
||||
const sensitive = this.isSensitive(input.key);
|
||||
const sensitive = this.isSensitive(input.key, actor);
|
||||
this.requireRbac(actor, 'write', sensitive);
|
||||
try {
|
||||
const res = runPython(CP_CLI, ['rollback', input.key, '--actor', actor.actor, '--reason', input.reason]);
|
||||
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
|
||||
const res = runPython(CP_CLI, ['rollback', input.key, '--actor', actor.actor, '--reason', input.reason], this.tenantEnv(actor));
|
||||
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit(actor) };
|
||||
} catch (err: any) {
|
||||
if (Number(err.status) === 4) throw new ForbiddenException(err.stderr || 'NO_PRIOR_VERSION');
|
||||
throw new InternalServerErrorException(err.stderr || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
private isSensitive(key: string): boolean {
|
||||
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy']).stdout, {});
|
||||
private isSensitive(key: string, actor: SettingsActor): boolean {
|
||||
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy'], this.tenantEnv(actor)).stdout, {});
|
||||
return Boolean(policy[key]?.securitySensitive);
|
||||
}
|
||||
|
||||
@@ -164,9 +166,13 @@ export class SettingsService {
|
||||
return runPython(RBAC_CLI, args);
|
||||
}
|
||||
|
||||
private verifyAudit() {
|
||||
private tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv {
|
||||
return { CASAN_TENANT_ID: actor.tenant };
|
||||
}
|
||||
|
||||
private verifyAudit(actor: SettingsActor) {
|
||||
try {
|
||||
const res = runPython(CP_CLI, ['verify-audit']);
|
||||
const res = runPython(CP_CLI, ['verify-audit'], this.tenantEnv(actor));
|
||||
return { ok: true, output: res.stdout };
|
||||
} catch (err: any) {
|
||||
return { ok: false, output: err.stderr || err.stdout || err.message };
|
||||
|
||||
@@ -16,8 +16,10 @@ function withTempGovernance(fn: (paths: { inbox: string; store: string }) => voi
|
||||
const prevStore = process.env.CASAN_CP_STORE_FILE;
|
||||
const prevKeyDir = process.env.CASAN_CP_KEY_DIR;
|
||||
const prevPub = process.env.CASAN_CP_PUB;
|
||||
const prevTenantRoot = process.env.CASAN_TENANT_STATE_ROOT;
|
||||
const work = mkdtempSync(join(tmpdir(), 'cp-approval-'));
|
||||
process.env.CASAN_APPROVAL_INBOX_FILE = join(work, 'approval-inbox.json');
|
||||
process.env.CASAN_TENANT_STATE_ROOT = work;
|
||||
process.env.CASAN_APPROVAL_INBOX_FILE = join(work, 'default', 'approvals', 'approval-inbox.json');
|
||||
process.env.CASAN_CP_STORE_FILE = join(work, 'settings.json');
|
||||
process.env.CASAN_CP_KEY_DIR = join(work, 'keys');
|
||||
process.env.CASAN_CP_PUB = join(work, 'cp.pub');
|
||||
@@ -34,6 +36,8 @@ function withTempGovernance(fn: (paths: { inbox: string; store: string }) => voi
|
||||
else process.env.CASAN_CP_KEY_DIR = prevKeyDir;
|
||||
if (prevPub === undefined) delete process.env.CASAN_CP_PUB;
|
||||
else process.env.CASAN_CP_PUB = prevPub;
|
||||
if (prevTenantRoot === undefined) delete process.env.CASAN_TENANT_STATE_ROOT;
|
||||
else process.env.CASAN_TENANT_STATE_ROOT = prevTenantRoot;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 } 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', () => {
|
||||
const result = new GoalsService().projects(admin);
|
||||
assert.ok(result.projects.some((project) => project.project_id === 'AINative_OKR_CASAN4'));
|
||||
assert.ok(result.projects.every((project) => project.context_roots.every((contextRoot) => !contextRoot.startsWith('/'))));
|
||||
assert.ok(result.projects.every((project) => project.project_id !== 'CASAN_DEMO_PROJECT_A'));
|
||||
});
|
||||
|
||||
test('goal start rejects project ids outside the server registry before model routing', async () => {
|
||||
await assert.rejects(
|
||||
new GoalsService().start({ goal: 'Review the current application architecture safely', projectId: '../../tmp' }, admin),
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { SettingsService } from '../src/settings/settings.service.js';
|
||||
|
||||
const viewer = { actor: 'viewer-1', role: 'viewer', project: 'default', tenant: 'default' };
|
||||
@@ -41,6 +41,17 @@ test('viewer cannot write settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid setting value is rejected before it reaches the store', () => {
|
||||
withTempStore((storeFile) => {
|
||||
const svc = new SettingsService();
|
||||
assert.throws(
|
||||
() => svc.set({ key: 'loop.max_steps', value: 0, reason: 'invalid lower bound' }, admin),
|
||||
BadRequestException,
|
||||
);
|
||||
assert.equal(existsSync(storeFile), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('org-admin writes and rolls back through governed store with audit', () => {
|
||||
withTempStore((storeFile) => {
|
||||
const svc = new SettingsService();
|
||||
|
||||
@@ -273,7 +273,7 @@ export interface GoalJob {
|
||||
id: string;
|
||||
trace_id: string;
|
||||
goal: string;
|
||||
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed';
|
||||
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed' | 'requires_approval';
|
||||
actor: string;
|
||||
tenant: string;
|
||||
project: string;
|
||||
@@ -290,6 +290,16 @@ export interface GoalJob {
|
||||
result?: string;
|
||||
error?: string;
|
||||
audit_hash?: string;
|
||||
workspace?: GoalProject;
|
||||
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
|
||||
approval?: { id: string; status: string; action: string };
|
||||
}
|
||||
|
||||
export interface GoalProject {
|
||||
project_id: string;
|
||||
domain: string;
|
||||
domain_root: string;
|
||||
context_roots: string[];
|
||||
}
|
||||
|
||||
export interface ChatReplay {
|
||||
@@ -336,9 +346,28 @@ export interface SettingsState {
|
||||
can_write_sensitive: boolean;
|
||||
can_rollback: boolean;
|
||||
};
|
||||
policy: Record<string, { securitySensitive: boolean; description: string }>;
|
||||
policy: Record<string, {
|
||||
securitySensitive: boolean;
|
||||
description: string;
|
||||
type: 'boolean' | 'string' | 'number' | 'integer' | 'enum';
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}>;
|
||||
settings: Record<string, { value: unknown; version: number; updatedAt: string; actor: string; reason: string }>;
|
||||
audit: any[];
|
||||
audit: Array<{
|
||||
seq: number;
|
||||
key: string;
|
||||
action: 'set' | 'rollback';
|
||||
value: unknown;
|
||||
prevValue: unknown;
|
||||
actor: string;
|
||||
reason: string;
|
||||
at: string;
|
||||
hash: string;
|
||||
}>;
|
||||
audit_verify: { ok: boolean; output: string };
|
||||
}
|
||||
|
||||
@@ -450,8 +479,10 @@ export const api = {
|
||||
getWithHeaders<{ success: boolean; providers: ProviderAuthStatus[] }>('provider-auth', actorHeaders(actor)),
|
||||
startProviderLogin: (actor: SettingsActor, provider: ProviderAuthStatus['id']) =>
|
||||
post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)),
|
||||
startGoal: (actor: SettingsActor, goal: string) =>
|
||||
post<GoalJob>('goals', { goal }, actorHeaders(actor)),
|
||||
goalProjects: (actor: SettingsActor) =>
|
||||
getWithHeaders<{ count: number; projects: GoalProject[] }>('goals/projects', actorHeaders(actor)),
|
||||
startGoal: (actor: SettingsActor, goal: string, projectId: string) =>
|
||||
post<GoalJob>('goals', { goal, projectId }, actorHeaders(actor)),
|
||||
goal: (actor: SettingsActor, id: string) =>
|
||||
getWithHeaders<GoalJob>(`goals/${encodeURIComponent(id)}`, actorHeaders(actor)),
|
||||
goals: (actor: SettingsActor, limit = 20) =>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api, type GoalJob, type SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
import { TraceExplorer } from '../components/trace/TraceExplorer';
|
||||
import { MarkdownText } from '../components/ui/MarkdownText';
|
||||
import { ModelInteractionDiagram } from '../components/goals/ModelInteractionDiagram';
|
||||
|
||||
const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'project-admin', project: 'default', tenant: 'default' };
|
||||
const TERMINAL = new Set<GoalJob['status']>(['completed', 'degraded', 'failed']);
|
||||
const DEFAULT_ACTOR: SettingsActor = { actor: 'local-operator', role: 'org-admin', project: 'default', tenant: 'default' };
|
||||
const TERMINAL = new Set<GoalJob['status']>(['completed', 'degraded', 'failed', 'requires_approval']);
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
@@ -45,10 +45,14 @@ function WorkerCard({ title, subtitle, status, detail, provider, model }: {
|
||||
|
||||
export function Goals() {
|
||||
const [goal, setGoal] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [actor] = useState(DEFAULT_ACTOR);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedId = searchParams.get('id') ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
const projectsQuery = useQuery({ queryKey: ['goal-projects', actor], queryFn: () => api.goalProjects(actor) });
|
||||
const projects = projectsQuery.data?.projects ?? [];
|
||||
const effectiveProject = projectId || projects[0]?.project_id || '';
|
||||
|
||||
const listQuery = useQuery({ queryKey: ['goals', actor], queryFn: () => api.goals(actor, 20) });
|
||||
const selectedQuery = useQuery({
|
||||
@@ -61,7 +65,7 @@ export function Goals() {
|
||||
},
|
||||
});
|
||||
const start = useMutation({
|
||||
mutationFn: () => api.startGoal(actor, goal.trim()),
|
||||
mutationFn: () => api.startGoal({ ...actor, project: effectiveProject }, goal.trim(), effectiveProject),
|
||||
onSuccess: (job) => {
|
||||
setSearchParams({ id: job.id });
|
||||
setGoal('');
|
||||
@@ -85,6 +89,15 @@ export function Goals() {
|
||||
</section>
|
||||
|
||||
<Card title="Give CASAN an objective">
|
||||
<label className="mb-4 block">
|
||||
<span className="text-sm font-semibold text-slate-800">Project workspace</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">Only server-registered, allowlisted roots are available. Models receive a bounded redacted snapshot—not filesystem access.</span>
|
||||
<select value={effectiveProject} onChange={(event) => setProjectId(event.target.value)} disabled={projectsQuery.isLoading || projects.length === 0} className="mt-2 w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-4 focus:ring-indigo-100 disabled:bg-slate-100">
|
||||
{projects.map((project) => <option key={project.project_id} value={project.project_id}>{project.domain} · {project.project_id}</option>)}
|
||||
</select>
|
||||
{projectsQuery.isError && <span role="alert" className="mt-2 block text-xs font-medium text-rose-700">Could not load the allowlisted project registry.</span>}
|
||||
{effectiveProject && <span className="mt-2 block font-mono text-[11px] text-slate-400">Context roots: {projects.find((project) => project.project_id === effectiveProject)?.context_roots.join(', ')}</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={goal}
|
||||
onChange={(event) => setGoal(event.target.value)}
|
||||
@@ -93,10 +106,10 @@ export function Goals() {
|
||||
maxLength={8000}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-500">CASAN selects the connected local and cloud models automatically. No IDE or CLI is required.</p>
|
||||
<p className="text-xs text-slate-500">Read-only goals receive the same evidence snapshot in both models. Side-effect requests become approval proposals and never write automatically.</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={goal.trim().length < 10 || start.isPending}
|
||||
disabled={goal.trim().length < 10 || !effectiveProject || start.isPending}
|
||||
onClick={() => start.mutate()}
|
||||
className="rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
@@ -116,6 +129,7 @@ export function Goals() {
|
||||
<ModelInteractionDiagram goal={selected} localStage={localStage} cloudStage={cloudStage} />
|
||||
|
||||
<Card title="Governed outcome" right={<StatusBadge value={selected.status} />}>
|
||||
{selected.workspace && <div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-indigo-200 bg-indigo-50 p-3 text-xs text-indigo-900"><strong>{selected.workspace.domain}</strong><span>·</span><span className="font-mono">{selected.workspace.project_id}</span>{selected.context_manifest && <><span>·</span><span>{selected.context_manifest.files} files / {selected.context_manifest.characters} chars</span>{selected.context_manifest.truncated && <StatusBadge value="bounded snapshot" />}</>}</div>}
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Objective</div>
|
||||
<div className="mt-2"><MarkdownText text={selected.goal} /></div>
|
||||
@@ -127,6 +141,7 @@ export function Goals() {
|
||||
</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.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>}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,181 +1,209 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
import axios from 'axios';
|
||||
import { api, SettingsActor, SettingsState } from '../lib/api';
|
||||
import { StatusBadge } from '../components/ui/Card';
|
||||
|
||||
const ROLES = ['viewer', 'operator', 'project-admin', 'org-admin', 'auditor'];
|
||||
type Category = 'all' | 'runtime' | 'models' | 'cost' | 'security';
|
||||
type Notice = { tone: 'success' | 'error'; text: string } | null;
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
if (raw.trim() === '') return '';
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
const LOCAL_ACTOR: SettingsActor = { actor: 'local-operator', role: 'org-admin', project: 'default', tenant: 'default' };
|
||||
const CATEGORY_META: Array<{ id: Category; label: string; description: string }> = [
|
||||
{ id: 'all', label: 'All settings', description: 'Every governed control' },
|
||||
{ id: 'runtime', label: 'Runtime & loops', description: 'Execution limits and compression' },
|
||||
{ id: 'models', label: 'Models', description: 'Primary model routing' },
|
||||
{ id: 'cost', label: 'Cost controls', description: 'Per-call spend boundaries' },
|
||||
{ id: 'security', label: 'Security', description: 'Fail-closed and emergency controls' },
|
||||
];
|
||||
|
||||
const DISPLAY: Record<string, { name: string; impact: string }> = {
|
||||
'compression.enabled': { name: 'Context compression', impact: 'Changes token usage and the context passed to every new model call.' },
|
||||
'compression.mode': { name: 'Compression strategy', impact: 'Changes how CASAN reduces context before model execution.' },
|
||||
'cost.absolute_cap_usd': { name: 'Absolute cost cap', impact: 'Blocks a model call when its estimated cost exceeds this USD limit.' },
|
||||
'model.primary': { name: 'Primary model', impact: 'Routes new eligible model calls to this provider and model.' },
|
||||
'security.strict': { name: 'Strict security mode', impact: 'Controls whether H4 security checks fail closed. Disabling it reduces protection.' },
|
||||
'kill_switch.global': { name: 'Global kill switch', impact: 'Stops governed runtime actions across every project in this control plane.' },
|
||||
'loop.max_steps': { name: 'Maximum loop steps', impact: 'Caps autonomous steps in each run.' },
|
||||
'loop.max_tokens': { name: 'Maximum loop tokens', impact: 'Caps total model tokens consumed by a run.' },
|
||||
'loop.max_wall_clock_sec': { name: 'Maximum run time', impact: 'Stops a run after this many seconds.' },
|
||||
'loop.max_cost_usd': { name: 'Maximum loop cost', impact: 'Stops a run when cumulative model cost reaches this USD limit.' },
|
||||
'loop.max_corrections_per_step': { name: 'Corrections per step', impact: 'Limits retry and self-correction attempts for one step.' },
|
||||
'loop.oscillation_repeat': { name: 'Oscillation threshold', impact: 'Marks a run as oscillating after this many repeated actions.' },
|
||||
'loop.no_progress_window': { name: 'No-progress window', impact: 'Marks a run as stalled after this many steps without progress.' },
|
||||
};
|
||||
|
||||
function categoryFor(key: string): Exclude<Category, 'all'> {
|
||||
if (key.startsWith('compression.') || key.startsWith('loop.')) return 'runtime';
|
||||
if (key.startsWith('model.')) return 'models';
|
||||
if (key.startsWith('cost.')) return 'cost';
|
||||
return 'security';
|
||||
}
|
||||
|
||||
function valuePreview(value: unknown): string {
|
||||
function valueText(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function apiError(error: unknown): string {
|
||||
if (axios.isAxiosError<{ message?: string }>(error)) return error.response?.data?.message || error.message;
|
||||
return error instanceof Error ? error.message : 'The settings request failed.';
|
||||
}
|
||||
|
||||
function validate(raw: string, policy: SettingsState['policy'][string]): string | null {
|
||||
if (!raw.trim()) return 'Enter a value before saving.';
|
||||
if (policy.type === 'boolean' && raw !== 'true' && raw !== 'false') return 'Choose true or false.';
|
||||
if (policy.type === 'enum' && !policy.options?.includes(raw)) return `Choose one of: ${policy.options?.join(', ')}.`;
|
||||
if (policy.type === 'number' || policy.type === 'integer') {
|
||||
const number = Number(raw);
|
||||
if (!Number.isFinite(number)) return 'Enter a valid number.';
|
||||
if (policy.type === 'integer' && !Number.isInteger(number)) return 'Enter a whole number.';
|
||||
if (policy.min !== undefined && number < policy.min) return `Value must be at least ${policy.min}.`;
|
||||
if (policy.max !== undefined && number > policy.max) return `Value must be at most ${policy.max}.`;
|
||||
}
|
||||
if (policy.type === 'string' && policy.minLength !== undefined && raw.length < policy.minLength) return `Enter at least ${policy.minLength} characters.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsedValue(raw: string, type: SettingsState['policy'][string]['type']): unknown {
|
||||
if (type === 'boolean') return raw === 'true';
|
||||
if (type === 'number' || type === 'integer') return Number(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [actor, setActor] = useState<SettingsActor>({
|
||||
actor: 'local-operator',
|
||||
role: 'viewer',
|
||||
project: 'default',
|
||||
tenant: 'default',
|
||||
});
|
||||
const [category, setCategory] = useState<Category>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedKey, setSelectedKey] = useState('');
|
||||
const [rawValue, setRawValue] = useState('true');
|
||||
const [reason, setReason] = useState('operator change from Ops Console');
|
||||
const [rawValue, setRawValue] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [approval, setApproval] = useState('');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<'save' | 'rollback' | null>(null);
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['settings', actor],
|
||||
queryFn: () => api.settings(actor),
|
||||
retry: false,
|
||||
});
|
||||
const settingsQuery = useQuery({ queryKey: ['settings'], queryFn: () => api.settings(LOCAL_ACTOR), retry: false });
|
||||
const data = settingsQuery.data;
|
||||
const keys = useMemo(() => Object.keys(data?.policy ?? {}).sort(), [data]);
|
||||
const filteredKeys = useMemo(() => keys.filter((key) => {
|
||||
const label = DISPLAY[key]?.name ?? key;
|
||||
const matchesCategory = category === 'all' || categoryFor(key) === category;
|
||||
const query = search.trim().toLowerCase();
|
||||
return matchesCategory && (!query || `${label} ${key} ${data?.policy[key].description}`.toLowerCase().includes(query));
|
||||
}), [category, data, keys, search]);
|
||||
|
||||
const keys = useMemo(() => Object.keys(settingsQuery.data?.policy ?? {}).sort(), [settingsQuery.data]);
|
||||
const effectiveKey = selectedKey || keys[0] || '';
|
||||
const policy = effectiveKey ? settingsQuery.data?.policy[effectiveKey] : undefined;
|
||||
useEffect(() => {
|
||||
if (!selectedKey && keys[0]) {
|
||||
setSelectedKey(keys[0]);
|
||||
setRawValue(valueText(data?.settings[keys[0]]?.value));
|
||||
}
|
||||
}, [data, keys, selectedKey]);
|
||||
|
||||
const setMutation = useMutation({
|
||||
mutationFn: () => api.setSetting(actor, {
|
||||
key: effectiveKey,
|
||||
value: parseValue(rawValue),
|
||||
reason,
|
||||
approval: approval || undefined,
|
||||
const policy = selectedKey ? data?.policy[selectedKey] : undefined;
|
||||
const current = selectedKey ? data?.settings[selectedKey] : undefined;
|
||||
const initialValue = valueText(current?.value);
|
||||
const dirty = Boolean(selectedKey) && rawValue !== initialValue;
|
||||
const validationError = policy && dirty ? validate(rawValue, policy) : null;
|
||||
const canWrite = Boolean(policy && (policy.securitySensitive ? data?.capabilities.can_write_sensitive : data?.capabilities.can_write_standard));
|
||||
const audit = data?.audit.filter((entry) => entry.key === selectedKey) ?? [];
|
||||
|
||||
function selectSetting(key: string) {
|
||||
if (dirty && !window.confirm('Discard your unsaved change and open another setting?')) return;
|
||||
setSelectedKey(key);
|
||||
setRawValue(valueText(data?.settings[key]?.value));
|
||||
setReason('');
|
||||
setApproval('');
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
function discard() {
|
||||
setRawValue(initialValue);
|
||||
setReason('');
|
||||
setApproval('');
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => api.setSetting(LOCAL_ACTOR, {
|
||||
key: selectedKey,
|
||||
value: parsedValue(rawValue, policy!.type),
|
||||
reason: reason.trim(),
|
||||
approval: approval.trim() || undefined,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setMessage(`SET ${res.key} v${res.setting.version} audit=${res.audit_verify.ok ? 'ok' : 'failed'}`);
|
||||
onSuccess: (result) => {
|
||||
setNotice({ tone: 'success', text: `${DISPLAY[result.key]?.name ?? result.key} saved as version ${result.setting.version}. Audit chain verified.` });
|
||||
setReason(''); setApproval(''); setConfirmAction(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'SET failed'),
|
||||
onError: (error) => { setNotice({ tone: 'error', text: apiError(error) }); setConfirmAction(null); },
|
||||
});
|
||||
|
||||
const rollbackMutation = useMutation({
|
||||
mutationFn: () => api.rollbackSetting(actor, { key: effectiveKey, reason }),
|
||||
onSuccess: (res) => {
|
||||
setMessage(`ROLLBACK ${res.key} v${res.setting.version} audit=${res.audit_verify.ok ? 'ok' : 'failed'}`);
|
||||
mutationFn: () => api.rollbackSetting(LOCAL_ACTOR, { key: selectedKey, reason: reason.trim() }),
|
||||
onSuccess: (result) => {
|
||||
setNotice({ tone: 'success', text: `${DISPLAY[result.key]?.name ?? result.key} rolled back as version ${result.setting.version}. Audit chain verified.` });
|
||||
setReason(''); setConfirmAction(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'ROLLBACK failed'),
|
||||
onError: (error) => { setNotice({ tone: 'error', text: apiError(error) }); setConfirmAction(null); },
|
||||
});
|
||||
|
||||
if (settingsQuery.isLoading) return <div className="text-gray-500">Loading…</div>;
|
||||
if (settingsQuery.isError || !settingsQuery.data) return <div className="text-red-600">Cannot reach settings API.</div>;
|
||||
if (settingsQuery.isLoading) return <div role="status" className="rounded-2xl border border-slate-200 bg-white p-8 text-sm text-slate-600">Loading governed settings…</div>;
|
||||
if (settingsQuery.isError || !data) return <div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-800"><strong>Settings are unavailable.</strong><div className="mt-1">{apiError(settingsQuery.error)}</div><button className="mt-4 rounded-lg border border-rose-300 px-3 py-2 font-medium hover:bg-rose-100 focus:outline-none focus:ring-2 focus:ring-rose-500" onClick={() => void settingsQuery.refetch()}>Try again</button></div>;
|
||||
|
||||
const data = settingsQuery.data;
|
||||
const current = effectiveKey ? data.settings[effectiveKey] : undefined;
|
||||
const canWrite = policy?.securitySensitive ? data.capabilities.can_write_sensitive : data.capabilities.can_write_standard;
|
||||
const accessLabel = canWrite ? (policy?.securitySensitive ? 'Admin + approval' : 'Editable') : 'Read only';
|
||||
const saveDisabled = !dirty || Boolean(validationError) || !reason.trim() || !canWrite || saveMutation.isPending;
|
||||
const rollbackDisabled = !data.capabilities.can_rollback || !current || audit.length < 2 || !reason.trim() || rollbackMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="Management identity" right={<StatusBadge value={data.audit_verify.ok ? 'audit ok' : 'audit fail'} />}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 text-sm">
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Actor</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
|
||||
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Role</span>
|
||||
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
|
||||
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Project</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
|
||||
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-gray-500">Tenant</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
|
||||
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
|
||||
</label>
|
||||
<div className="space-y-5 pb-44 lg:pb-28">
|
||||
<section className="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div><p className="text-xs font-semibold uppercase tracking-[0.14em] text-indigo-600">Runtime configuration</p><h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-950">Settings you can understand before you change</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">Changes apply to new governed runs in tenant <strong>{data.actor.tenant}</strong> and project <strong>{data.actor.project}</strong>. Every save and rollback is RBAC-checked and audit anchored.</p></div>
|
||||
<div className="flex flex-wrap items-center gap-2"><StatusBadge value={data.audit_verify.ok ? 'audit verified' : 'audit failed'} /><span className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-700">{data.actor.actor} · {data.actor.role}</span></div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card title="Governed settings">
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
||||
<tr><th className="py-2 pr-4">Key</th><th className="py-2 pr-4">Current</th><th className="py-2 pr-4">Version</th><th className="py-2">Policy</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((key) => {
|
||||
const row = data.settings[key];
|
||||
const p = data.policy[key];
|
||||
return (
|
||||
<tr key={key} className={`border-b border-gray-100 cursor-pointer ${effectiveKey === key ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => { setSelectedKey(key); setRawValue(valuePreview(row?.value ?? '')); }}>
|
||||
<td className="py-3 pr-4 font-medium text-gray-800">{key}</td>
|
||||
<td className="py-3 pr-4 text-gray-600"><code>{row ? valuePreview(row.value) : 'unset'}</code></td>
|
||||
<td className="py-3 pr-4 text-gray-600">{row?.version ?? '—'}</td>
|
||||
<td className="py-3">{p.securitySensitive ? <StatusBadge value="sensitive" /> : <StatusBadge value="standard" />}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="grid gap-5 xl:grid-cols-[240px_minmax(0,1fr)_360px]">
|
||||
<aside className="space-y-4 xl:sticky xl:top-24 xl:self-start">
|
||||
<label className="block"><span className="sr-only">Search settings</span><input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search settings…" className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm shadow-sm outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" /></label>
|
||||
<nav aria-label="Settings categories" className="rounded-2xl border border-slate-200 bg-white p-2 shadow-sm">
|
||||
{CATEGORY_META.map((item) => <button key={item.id} type="button" onClick={() => setCategory(item.id)} aria-current={category === item.id ? 'page' : undefined} className={`w-full rounded-xl px-3 py-3 text-left transition focus:outline-none focus:ring-2 focus:ring-indigo-500 ${category === item.id ? 'bg-indigo-50 text-indigo-950' : 'text-slate-700 hover:bg-slate-50'}`}><span className="block text-sm font-semibold">{item.label}</span><span className="mt-0.5 block text-xs leading-5 text-slate-500">{item.description}</span></button>)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Card title="Apply or rollback">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-gray-500">Selected key</div>
|
||||
<div className="font-medium text-gray-800">{effectiveKey || 'none'}</div>
|
||||
{policy && <div className="text-gray-500 mt-1">{policy.description}</div>}
|
||||
{current && <div className="text-gray-500 mt-1">current v{current.version} by {current.actor}</div>}
|
||||
</div>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-gray-500">Value (JSON or string)</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={rawValue} onChange={(e) => setRawValue(e.target.value)} />
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-gray-500">Reason</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-gray-500">Approval token</span>
|
||||
<input className="w-full rounded border border-gray-300 px-3 py-2" value={approval} onChange={(e) => setApproval(e.target.value)} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
|
||||
disabled={!effectiveKey || !canWrite || setMutation.isPending}
|
||||
onClick={() => setMutation.mutate()}>
|
||||
Apply
|
||||
</button>
|
||||
<button className="rounded border border-gray-300 px-4 py-2 text-gray-700 disabled:text-gray-300"
|
||||
disabled={!effectiveKey || !data.capabilities.can_rollback || rollbackMutation.isPending}
|
||||
onClick={() => rollbackMutation.mutate()}>
|
||||
Rollback
|
||||
</button>
|
||||
</div>
|
||||
{message && <div className="rounded border border-gray-200 bg-gray-50 p-3 text-gray-700">{message}</div>}
|
||||
<section aria-labelledby="settings-list-title" className="min-w-0 rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-200 px-5 py-4"><div className="flex items-center justify-between gap-3"><h2 id="settings-list-title" className="font-semibold text-slate-950">{CATEGORY_META.find((item) => item.id === category)?.label}</h2><span className="text-xs font-medium text-slate-500">{filteredKeys.length} settings</span></div></div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{filteredKeys.map((key) => {
|
||||
const entry = data.settings[key]; const itemPolicy = data.policy[key]; const display = DISPLAY[key] ?? { name: key, impact: itemPolicy.description };
|
||||
return <button type="button" key={key} onClick={() => selectSetting(key)} aria-pressed={selectedKey === key} className={`group w-full px-5 py-4 text-left transition focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 ${selectedKey === key ? 'bg-indigo-50/70' : 'hover:bg-slate-50'}`}><div className="flex items-start justify-between gap-4"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-semibold text-slate-900">{display.name}</span>{itemPolicy.securitySensitive && <span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800">Security-sensitive</span>}</div><p className="mt-1 text-sm leading-5 text-slate-600">{itemPolicy.description}</p><p className="mt-2 font-mono text-[11px] text-slate-400">{key}</p></div><div className="shrink-0 text-right"><div className="max-w-32 truncate font-mono text-sm font-semibold text-slate-800">{entry ? valueText(entry.value) : 'Not set'}</div><div className="mt-1 text-xs text-slate-500">{entry ? `v${entry.version}` : 'Uses runtime default'}</div></div></div></button>;
|
||||
})}
|
||||
{filteredKeys.length === 0 && <div className="px-6 py-12 text-center"><div className="font-medium text-slate-800">No settings found</div><p className="mt-1 text-sm text-slate-500">Try another category or search phrase.</p></div>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase text-gray-500">Recent settings audit</div>
|
||||
<div className="space-y-2 max-h-80 overflow-auto">
|
||||
{data.audit.map((a) => (
|
||||
<div key={`${a.seq}-${a.hash}`} className="rounded border border-gray-200 p-3">
|
||||
<div className="font-medium text-gray-800">{a.action} {a.key}</div>
|
||||
<div className="text-xs text-gray-500">{a.actor} · {a.at} · vhash {String(a.hash).slice(0, 12)}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{a.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
{data.audit.length === 0 && <div className="text-gray-500">No settings audit records yet.</div>}
|
||||
</section>
|
||||
|
||||
<aside aria-label="Setting editor" className="xl:sticky xl:top-24 xl:self-start">
|
||||
{policy ? <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="border-b border-slate-200 px-5 py-4"><div className="flex items-start justify-between gap-3"><div><p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">Current selection</p><h2 className="mt-1 text-lg font-semibold text-slate-950">{DISPLAY[selectedKey]?.name ?? selectedKey}</h2></div><StatusBadge value={accessLabel} /></div></div>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4"><div className="text-xs font-semibold uppercase tracking-[0.1em] text-blue-700">Impact</div><p className="mt-1 text-sm leading-6 text-blue-950">{DISPLAY[selectedKey]?.impact ?? policy.description}</p></div>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm"><div><dt className="text-xs text-slate-500">Current value</dt><dd className="mt-1 break-words font-mono font-semibold text-slate-900">{current ? valueText(current.value) : 'Runtime default'}</dd></div><div><dt className="text-xs text-slate-500">Last changed</dt><dd className="mt-1 text-slate-800">{current ? new Date(current.updatedAt).toLocaleString() : 'Never'}</dd></div></dl>
|
||||
{!canWrite && <div role="note" className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm leading-5 text-slate-700">Your <strong>{data.actor.role}</strong> role can view this setting but cannot change it. No editable-looking controls are shown.</div>}
|
||||
{canWrite && <>
|
||||
<label className="block"><span className="text-sm font-semibold text-slate-800">New value</span><span className="mt-0.5 block text-xs text-slate-500">{policy.type === 'enum' ? `Allowed: ${policy.options?.join(', ')}` : policy.type === 'boolean' ? 'Choose true or false.' : `Expected type: ${policy.type}${policy.min !== undefined ? ` · ${policy.min}–${policy.max}` : ''}`}</span>{policy.type === 'boolean' || policy.type === 'enum' ? <select value={rawValue} onChange={(event) => { setRawValue(event.target.value); setNotice(null); }} className="mt-2 w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"><option value="">Select a value</option>{(policy.type === 'boolean' ? ['true', 'false'] : policy.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}</select> : <input value={rawValue} onChange={(event) => { setRawValue(event.target.value); setNotice(null); }} inputMode={policy.type === 'number' || policy.type === 'integer' ? 'decimal' : 'text'} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" />}{validationError && <span role="alert" className="mt-1.5 block text-xs font-medium text-rose-700">{validationError}</span>}</label>
|
||||
<label className="block"><span className="text-sm font-semibold text-slate-800">Reason for change</span><span className="mt-0.5 block text-xs text-slate-500">Required and recorded in the audit log.</span><textarea rows={3} value={reason} onChange={(event) => setReason(event.target.value)} className="mt-2 w-full resize-y rounded-xl border border-slate-300 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" placeholder="What operational need does this change address?" /></label>
|
||||
{policy.securitySensitive && <label className="block"><span className="text-sm font-semibold text-slate-800">Approval token</span><span className="mt-0.5 block text-xs text-slate-500">Required by the harness approval gate.</span><input type="password" autoComplete="off" value={approval} onChange={(event) => setApproval(event.target.value)} className="mt-2 w-full rounded-xl border border-amber-300 bg-amber-50/50 px-3 py-2.5 text-sm outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-200" /></label>}
|
||||
</>}
|
||||
{notice && <div role={notice.tone === 'error' ? 'alert' : 'status'} className={`rounded-xl border p-3 text-sm leading-5 ${notice.tone === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-rose-200 bg-rose-50 text-rose-800'}`}>{notice.text}</div>}
|
||||
<div><div className="flex items-center justify-between"><h3 className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">Change history</h3><span className="text-xs text-slate-400">{audit.length} events</span></div><div className="mt-2 max-h-52 space-y-2 overflow-auto">{audit.map((entry) => <div key={`${entry.seq}-${entry.hash}`} className="rounded-xl border border-slate-200 p-3"><div className="flex justify-between gap-2 text-xs"><span className="font-semibold capitalize text-slate-800">{entry.action} · v{entry.seq}</span><time className="text-slate-500">{new Date(entry.at).toLocaleString()}</time></div><p className="mt-1 text-xs text-slate-600">{entry.reason}</p><p className="mt-1 text-[11px] text-slate-400">{entry.actor} · {entry.hash.slice(0, 10)}</p></div>)}{audit.length === 0 && <p className="rounded-xl bg-slate-50 p-3 text-xs text-slate-500">No changes have been recorded for this setting.</p>}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
</div> : <div className="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-500">Select a setting to inspect it.</div>}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{canWrite && <div className="fixed inset-x-0 bottom-20 z-20 border-t border-slate-200 bg-white/95 px-4 py-3 shadow-[0_-12px_30px_rgba(15,23,42,0.08)] backdrop-blur lg:bottom-0 lg:left-[252px]"><div className="mx-auto flex max-w-7xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><div className={`text-sm font-semibold ${dirty ? 'text-amber-800' : 'text-slate-700'}`}>{dirty ? 'Unsaved changes' : 'No unsaved changes'}</div><div className="text-xs text-slate-500">{dirty ? `New value: ${rawValue || 'empty'}` : 'Select a setting and change its value to enable Save.'}</div></div><div className="flex flex-wrap gap-2"><button type="button" onClick={discard} disabled={!dirty} className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40">Discard</button><button type="button" onClick={() => setConfirmAction('rollback')} disabled={rollbackDisabled} title={audit.length < 2 ? 'Rollback requires a prior version.' : undefined} className="rounded-lg border border-amber-300 px-4 py-2 text-sm font-semibold text-amber-800 transition hover:bg-amber-50 focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:cursor-not-allowed disabled:opacity-40">Rollback</button><button type="button" onClick={() => policy?.securitySensitive ? setConfirmAction('save') : saveMutation.mutate()} disabled={saveDisabled} className="rounded-lg bg-indigo-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-slate-300">{saveMutation.isPending ? 'Saving…' : 'Save changes'}</button></div></div></div>}
|
||||
|
||||
{confirmAction && <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setConfirmAction(null); }}><div role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-description" className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl"><div className="inline-flex rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-800">High-impact action</div><h2 id="confirm-title" className="mt-4 text-xl font-semibold text-slate-950">{confirmAction === 'rollback' ? 'Rollback this setting?' : 'Apply this security-sensitive change?'}</h2><p id="confirm-description" className="mt-2 text-sm leading-6 text-slate-600">{confirmAction === 'rollback' ? 'CASAN will restore the immediately previous value and record a new audited version. This does not erase history.' : DISPLAY[selectedKey]?.impact}</p><div className="mt-4 rounded-xl bg-slate-50 p-3 text-sm"><span className="text-slate-500">Setting</span><div className="mt-1 font-semibold text-slate-900">{DISPLAY[selectedKey]?.name}</div></div><div className="mt-6 flex justify-end gap-2"><button autoFocus type="button" onClick={() => setConfirmAction(null)} className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">Cancel</button><button type="button" onClick={() => confirmAction === 'rollback' ? rollbackMutation.mutate() : saveMutation.mutate()} className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500">Confirm {confirmAction}</button></div></div></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,8 +63,8 @@ steps_with_tracking:
|
||||
- step: step-11-review-code
|
||||
reference_docs:
|
||||
- docs/output/specs/
|
||||
- backend/
|
||||
- frontend/
|
||||
- apps/okr/backend/
|
||||
- apps/okr/frontend/
|
||||
signals:
|
||||
- false_security_clearance
|
||||
- invented_test_result
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
"project_id": "AINative_OKR_CASAN4",
|
||||
"domain": "SDD OKR",
|
||||
"domain_root": "apps/okr/domain",
|
||||
"context_roots": [
|
||||
"apps/okr/domain",
|
||||
"apps/okr/frontend",
|
||||
"apps/okr/backend",
|
||||
"docs/technical_architecture.md"
|
||||
],
|
||||
"harness_package": "fpt-casan-sdd-harness",
|
||||
"harness_version": "1.0.0",
|
||||
"status": "active"
|
||||
@@ -35,6 +41,9 @@
|
||||
"project_id": "CASAN_SERVICE_DESK",
|
||||
"domain": "IT Service Desk",
|
||||
"domain_root": "apps/service-desk/domain",
|
||||
"context_roots": [
|
||||
"apps/service-desk"
|
||||
],
|
||||
"harness_package": "fpt-casan-sdd-harness",
|
||||
"harness_version": "1.0.0",
|
||||
"status": "active"
|
||||
|
||||
@@ -4,7 +4,7 @@ Rules:
|
||||
- Output markdown only.
|
||||
- Preserve SCR-00 through SCR-04 exactly.
|
||||
- Include Screen Layout and API Boundary sections.
|
||||
- Keep frontend/backend boundaries concrete.
|
||||
- Keep apps/okr/frontend/backend boundaries concrete.
|
||||
|
||||
Feature: {{featureId}}
|
||||
Module: {{moduleId}}
|
||||
|
||||
@@ -4,7 +4,7 @@ Rules:
|
||||
- Output markdown only.
|
||||
- Preserve TC-01 through TC-06 exactly.
|
||||
- Cover login, employee isolation, manager visibility, invalid objective payload, progress update, and golden drift.
|
||||
- Keep tests executable by the existing backend/frontend test stack.
|
||||
- Keep tests executable by the existing apps/okr/backend/frontend test stack.
|
||||
|
||||
Feature: {{featureId}}
|
||||
Module: {{moduleId}}
|
||||
|
||||
@@ -29,22 +29,22 @@ except ImportError: # non-POSIX (e.g. Windows): best-effort, no OS lock
|
||||
|
||||
|
||||
SETTINGS_POLICY = {
|
||||
"compression.enabled": {"securitySensitive": False, "description": "Toggle context/token compression"},
|
||||
"compression.mode": {"securitySensitive": False, "description": "extractive | structural | semantic-dedup | abstractive"},
|
||||
"cost.absolute_cap_usd": {"securitySensitive": False, "description": "Absolute per-call cost cap"},
|
||||
"model.primary": {"securitySensitive": False, "description": "Primary model spec (e.g. ollama:ornith:9b)"},
|
||||
"security.strict": {"securitySensitive": True, "description": "H4 fail-closed strict mode"},
|
||||
"kill_switch.global": {"securitySensitive": True, "description": "Global kill-switch engage/disengage"},
|
||||
"compression.enabled": {"securitySensitive": False, "description": "Toggle context/token compression", "type": "boolean"},
|
||||
"compression.mode": {"securitySensitive": False, "description": "extractive | structural | semantic-dedup | abstractive", "type": "enum", "options": ["extractive", "structural", "semantic-dedup", "abstractive"]},
|
||||
"cost.absolute_cap_usd": {"securitySensitive": False, "description": "Absolute per-call cost cap", "type": "number", "min": 0, "max": 1000},
|
||||
"model.primary": {"securitySensitive": False, "description": "Primary model spec (e.g. ollama:ornith:9b)", "type": "string", "minLength": 3, "maxLength": 200},
|
||||
"security.strict": {"securitySensitive": True, "description": "H4 fail-closed strict mode", "type": "boolean"},
|
||||
"kill_switch.global": {"securitySensitive": True, "description": "Global kill-switch engage/disengage", "type": "boolean"},
|
||||
# Plan-17 loop governance overrides (meta-loop, T5). Loosening a loop budget /
|
||||
# widening a convergence window is security-sensitive: it grants the agent more
|
||||
# autonomy, so it needs a real approval + SoD and is clamped to org_ceiling.
|
||||
"loop.max_steps": {"securitySensitive": True, "description": "Loop Governor: max steps per run"},
|
||||
"loop.max_tokens": {"securitySensitive": True, "description": "Loop Governor: max tokens per run"},
|
||||
"loop.max_wall_clock_sec": {"securitySensitive": True, "description": "Loop Governor: max wall-clock seconds per run"},
|
||||
"loop.max_cost_usd": {"securitySensitive": True, "description": "Loop Governor: max cost USD per run"},
|
||||
"loop.max_corrections_per_step": {"securitySensitive": True, "description": "Loop Governor: max corrections per step"},
|
||||
"loop.oscillation_repeat": {"securitySensitive": True, "description": "Convergence: identical-action repeats before OSCILLATING"},
|
||||
"loop.no_progress_window": {"securitySensitive": True, "description": "Convergence: no-progress window before STALLED"},
|
||||
"loop.max_steps": {"securitySensitive": True, "description": "Loop Governor: max steps per run", "type": "integer", "min": 1, "max": 100},
|
||||
"loop.max_tokens": {"securitySensitive": True, "description": "Loop Governor: max tokens per run", "type": "integer", "min": 128, "max": 1000000},
|
||||
"loop.max_wall_clock_sec": {"securitySensitive": True, "description": "Loop Governor: max wall-clock seconds per run", "type": "integer", "min": 1, "max": 86400},
|
||||
"loop.max_cost_usd": {"securitySensitive": True, "description": "Loop Governor: max cost USD per run", "type": "number", "min": 0, "max": 1000},
|
||||
"loop.max_corrections_per_step": {"securitySensitive": True, "description": "Loop Governor: max corrections per step", "type": "integer", "min": 0, "max": 20},
|
||||
"loop.oscillation_repeat": {"securitySensitive": True, "description": "Convergence: identical-action repeats before OSCILLATING", "type": "integer", "min": 2, "max": 20},
|
||||
"loop.no_progress_window": {"securitySensitive": True, "description": "Convergence: no-progress window before STALLED", "type": "integer", "min": 1, "max": 50},
|
||||
}
|
||||
|
||||
GENESIS_HASH = "0" * 64
|
||||
@@ -263,6 +263,10 @@ def do_set(key, value, actor, reason, approval):
|
||||
if policy is None:
|
||||
print(f"SETTING_NOT_ALLOWED {key}", file=sys.stderr)
|
||||
return 2
|
||||
validation_error = validate_value(policy, value)
|
||||
if validation_error:
|
||||
print(f"SETTING_VALIDATION_ERROR {key}: {validation_error}", file=sys.stderr)
|
||||
return 5
|
||||
if policy["securitySensitive"]:
|
||||
ok, reason_ = check_approval(key, actor, approval)
|
||||
if not ok:
|
||||
@@ -292,6 +296,31 @@ def do_set(key, value, actor, reason, approval):
|
||||
return 0
|
||||
|
||||
|
||||
def validate_value(policy, value):
|
||||
expected = policy.get("type")
|
||||
if expected == "boolean" and not isinstance(value, bool):
|
||||
return "must be true or false"
|
||||
if expected == "string":
|
||||
if not isinstance(value, str):
|
||||
return "must be a string"
|
||||
if len(value) < policy.get("minLength", 0):
|
||||
return f"must contain at least {policy['minLength']} characters"
|
||||
if len(value) > policy.get("maxLength", sys.maxsize):
|
||||
return f"must contain at most {policy['maxLength']} characters"
|
||||
if expected == "enum" and value not in policy.get("options", []):
|
||||
return "must be one of: " + ", ".join(policy.get("options", []))
|
||||
if expected in ("number", "integer"):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return "must be a number"
|
||||
if expected == "integer" and not isinstance(value, int):
|
||||
return "must be an integer"
|
||||
if value < policy.get("min", value):
|
||||
return f"must be at least {policy['min']}"
|
||||
if value > policy.get("max", value):
|
||||
return f"must be at most {policy['max']}"
|
||||
return None
|
||||
|
||||
|
||||
def do_rollback(key, actor, reason):
|
||||
with store_lock(): # SEC-19: atomic read-modify-write
|
||||
store = load_store()
|
||||
|
||||
@@ -33,6 +33,11 @@ BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
|
||||
MODEL_ROUTER = os.environ.get("CASAN_GOAL_MODEL_ROUTER") or os.path.join(BIN, "model-router.sh")
|
||||
SECURITY = os.path.join(BIN, "security-check.sh")
|
||||
STATE_ROOT = os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||
PROJECT_REGISTRY = os.path.join(ROOT, "packages", "casan-harness", "level5", "project-registry.json")
|
||||
APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
|
||||
CONTEXT_EXTENSIONS = {".md", ".txt", ".json", ".yaml", ".yml", ".ts", ".tsx", ".js", ".mjs", ".py", ".sh", ".prisma", ".css", ".html"}
|
||||
CONTEXT_IGNORED = {"node_modules", ".git", "dist", "build", "coverage", ".vite", "tmp", "logs", "__pycache__"}
|
||||
SENSITIVE_NAMES = {".env", ".env.local", "credentials", "credentials.json", "secrets.json", "id_rsa", "id_ed25519"}
|
||||
|
||||
|
||||
def now() -> str:
|
||||
@@ -111,6 +116,167 @@ def stage(path: str, stage_id: str, status: str, detail: str, provider="", model
|
||||
atomic_json(path, job)
|
||||
|
||||
|
||||
def registered_project(project_id: str) -> dict:
|
||||
registry = load_json(PROJECT_REGISTRY)
|
||||
entry = next((item for item in registry.get("projects", [])
|
||||
if item.get("project_id") == project_id and item.get("status") == "active"), None)
|
||||
if not entry:
|
||||
raise ValueError("goal_project_not_allowed")
|
||||
raw_roots = entry.get("context_roots") or [entry.get("domain_root")]
|
||||
root_real = os.path.realpath(ROOT)
|
||||
resolved = []
|
||||
for relative in raw_roots:
|
||||
if not isinstance(relative, str) or not relative:
|
||||
raise ValueError("goal_context_root_invalid")
|
||||
absolute = os.path.realpath(os.path.join(ROOT, relative))
|
||||
if absolute != root_real and not absolute.startswith(root_real + os.sep):
|
||||
raise ValueError("goal_context_root_denied")
|
||||
if not os.path.exists(absolute):
|
||||
raise ValueError("goal_context_root_missing")
|
||||
resolved.append((relative, absolute))
|
||||
return {"project_id": project_id, "domain": entry.get("domain", project_id), "domain_root": entry.get("domain_root", ""), "roots": resolved}
|
||||
|
||||
|
||||
def redact_context(text: str) -> str:
|
||||
patterns = [
|
||||
(r"(?im)^[^\n]*(?:api[_-]?key|secret|password|token)[^:=\n]*[:=]\s*[^\s\n]+", "[REDACTED SECRET]"),
|
||||
(r"(?i)bearer\s+[A-Za-z0-9._~+/-]{12,}", "Bearer [REDACTED]"),
|
||||
(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", "[REDACTED PRIVATE KEY]"),
|
||||
]
|
||||
redacted = text
|
||||
for pattern, replacement in patterns:
|
||||
redacted = re.sub(pattern, replacement, redacted)
|
||||
return redacted
|
||||
|
||||
|
||||
def context_excerpt_is_sensitive(text: str) -> bool:
|
||||
"""Exclude source excerpts that would turn the shared snapshot into unsafe input."""
|
||||
normalized = " ".join(text.lower().split())
|
||||
blocked_phrases = (
|
||||
"ignore previous instruction",
|
||||
"ignore prior instruction",
|
||||
"drop table",
|
||||
"shutdown system",
|
||||
"export secrets",
|
||||
"dump database",
|
||||
# The security gate normalizes identifiers, so Prisma's `onDelete`
|
||||
# otherwise looks like an imperative delete request.
|
||||
"ondelete",
|
||||
)
|
||||
if any(phrase in normalized for phrase in blocked_phrases):
|
||||
return True
|
||||
return bool(re.search(
|
||||
r"(?i)(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|jwt[_-]?secret|secret)"
|
||||
r"\s*[:=]\s*(?!\[REDACTED\])\S+",
|
||||
text,
|
||||
))
|
||||
|
||||
|
||||
def context_candidates(project: dict, goal: str):
|
||||
terms = {term.lower() for term in re.findall(r"[A-Za-z0-9_-]{3,}", goal)}
|
||||
candidates = []
|
||||
seen = set()
|
||||
for relative_root, absolute_root in project["roots"]:
|
||||
if os.path.isfile(absolute_root):
|
||||
paths = [absolute_root]
|
||||
else:
|
||||
paths = []
|
||||
for base, directories, files in os.walk(absolute_root):
|
||||
directories[:] = [item for item in directories if item not in CONTEXT_IGNORED and not item.startswith('.')]
|
||||
paths.extend(os.path.join(base, name) for name in files)
|
||||
for path in paths:
|
||||
relative = os.path.relpath(path, ROOT)
|
||||
if relative in seen or os.path.basename(path).lower() in SENSITIVE_NAMES:
|
||||
continue
|
||||
seen.add(relative)
|
||||
extension = os.path.splitext(path)[1].lower()
|
||||
if extension not in CONTEXT_EXTENSIONS or os.path.getsize(path) > 256_000:
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
raw = handle.read(16_000)
|
||||
except OSError:
|
||||
continue
|
||||
haystack = (relative + "\n" + raw[:4000]).lower()
|
||||
score = sum(4 if term in relative.lower() else 1 for term in terms if term in haystack)
|
||||
if relative.endswith(("README.md", "architecture.md", "technical_architecture.md", "package.json")):
|
||||
score += 3
|
||||
candidates.append((score, relative, raw))
|
||||
return sorted(candidates, key=lambda item: (-item[0], item[1]))
|
||||
|
||||
|
||||
def build_context(job_path: str, project_id: str, goal: str):
|
||||
project = registered_project(project_id)
|
||||
candidates = context_candidates(project, goal)
|
||||
excerpts, manifest_files, characters = [], [], 0
|
||||
max_files, max_characters, per_file = 16, 7_000, 1_200
|
||||
for score, relative, raw in candidates:
|
||||
if len(excerpts) >= max_files or characters >= max_characters:
|
||||
break
|
||||
excerpt = redact_context(raw[:min(per_file, max_characters - characters)]).strip()
|
||||
if not excerpt or context_excerpt_is_sensitive(excerpt):
|
||||
continue
|
||||
excerpts.append(f"### FILE: {relative}\n{excerpt}")
|
||||
characters += len(excerpt)
|
||||
manifest_files.append({"path": relative, "sha256": sha(raw), "characters": len(excerpt), "relevance": score})
|
||||
bundle = "\n\n".join(excerpts)
|
||||
allowed, safe_bundle = scan(bundle, "input")
|
||||
if not allowed:
|
||||
raise ValueError("goal_context_security_blocked")
|
||||
bundle = safe_bundle
|
||||
base = os.path.splitext(job_path)[0]
|
||||
bundle_path, manifest_path = base + ".context.txt", base + ".context.json"
|
||||
with open(bundle_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(bundle + "\n")
|
||||
os.chmod(bundle_path, 0o600)
|
||||
manifest = {
|
||||
"project_id": project_id,
|
||||
"domain": project["domain"],
|
||||
"domain_root": project["domain_root"],
|
||||
"generated_at": now(),
|
||||
"files": manifest_files,
|
||||
"file_count": len(manifest_files),
|
||||
"characters": characters,
|
||||
"truncated": len(manifest_files) < len(candidates),
|
||||
"bundle_sha256": sha(bundle),
|
||||
}
|
||||
atomic_json(manifest_path, manifest)
|
||||
return bundle, manifest, os.path.relpath(manifest_path, ROOT)
|
||||
|
||||
|
||||
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"^(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:
|
||||
payload = {
|
||||
"goal_id": job["id"],
|
||||
"project_id": job["project"],
|
||||
"context_manifest_hash": manifest["bundle_sha256"],
|
||||
"requested_operation": job["goal"],
|
||||
}
|
||||
result = subprocess.run([
|
||||
"python3", APPROVAL_INBOX, "submit",
|
||||
"--project", job["project"],
|
||||
"--action", "goal.workspace.execute",
|
||||
"--target", job["project"],
|
||||
"--risk", "high",
|
||||
"--sensitive",
|
||||
"--proposer", job["actor"],
|
||||
"--reason", "Goal requests a workspace side effect; execution remains disabled until approval",
|
||||
"--payload", json.dumps(payload, ensure_ascii=False),
|
||||
], cwd=ROOT, capture_output=True, text=True, env=os.environ.copy(), timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout or "goal_approval_submit_failed").strip())
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def scan(text: str, mode: str):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = os.path.join(directory, "input.txt")
|
||||
@@ -282,22 +448,54 @@ def run(job_path: str) -> int:
|
||||
emit(goal_id, "H1-context", "running", "Validating objective contract")
|
||||
if len(goal) < 10 or len(goal) > 8000:
|
||||
raise ValueError("goal_length_invalid")
|
||||
emit(goal_id, "H1-context", "pass", "Objective accepted", {"goal_hash": sha(goal), "characters": len(goal)})
|
||||
project_id = str(job.get("project") or "")
|
||||
|
||||
emit(goal_id, "H4-security", "running", "Scanning objective before model routing")
|
||||
allowed, safe_goal = scan(goal, "input")
|
||||
if not allowed:
|
||||
emit(goal_id, "H4-security", "blocked", "Objective rejected by security boundary")
|
||||
raise ValueError("goal_security_blocked")
|
||||
emit(goal_id, "H4-security", "running", "Objective passed; model outputs pending")
|
||||
context_bundle, context_manifest, context_manifest_path = build_context(job_path, project_id, safe_goal)
|
||||
context_summary = {
|
||||
"files": context_manifest["file_count"],
|
||||
"characters": context_manifest["characters"],
|
||||
"truncated": context_manifest["truncated"],
|
||||
"path": context_manifest_path,
|
||||
}
|
||||
update_job(job_path, context_manifest=context_summary)
|
||||
emit(goal_id, "H1-context", "pass", "Allowlisted workspace snapshot prepared", {
|
||||
"goal_hash": sha(goal), "project_id": project_id, **context_summary,
|
||||
"bundle_sha256": context_manifest["bundle_sha256"],
|
||||
})
|
||||
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
|
||||
|
||||
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})
|
||||
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.\n\n"
|
||||
f"OBJECTIVE:\n{safe_goal}"
|
||||
"and verifiable acceptance checks. Respond in the same language as the objective. "
|
||||
"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}"
|
||||
)
|
||||
ok, local_draft, local_meta, reason = call_model(local_model, local_prompt, False)
|
||||
if not ok:
|
||||
@@ -318,8 +516,11 @@ def run(job_path: str) -> int:
|
||||
"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.\n\n"
|
||||
f"OBJECTIVE:\n{safe_goal}\n\nLOCAL WORKER PROPOSAL:\n{safe_local[:16000]}"
|
||||
"Respond in the same language as the objective. 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"
|
||||
f"LOCAL WORKER PROPOSAL:\n{safe_local[:5000]}"
|
||||
)
|
||||
if account_provider:
|
||||
cloud_ok, cloud_result, cloud_meta, cloud_reason = call_account_model(account_provider, review_prompt)
|
||||
|
||||
@@ -239,7 +239,7 @@ get_project_structure() {
|
||||
local project_type="$1"
|
||||
|
||||
if [[ "$project_type" == *"web"* ]]; then
|
||||
echo "backend/\\nfrontend/\\ntests/"
|
||||
echo "apps/okr/backend/\\napps/okr/frontend/\\ntests/"
|
||||
else
|
||||
echo "src/\\ntests/"
|
||||
fi
|
||||
|
||||
@@ -177,7 +177,7 @@ function Get-ProjectStructure {
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ProjectType
|
||||
)
|
||||
if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" }
|
||||
if ($ProjectType -match 'web') { return "apps/okr/backend/`napps/okr/frontend/`ntests/" } else { return "src/`ntests/" }
|
||||
}
|
||||
|
||||
function Get-CommandsForLanguage {
|
||||
|
||||
@@ -73,14 +73,14 @@ tests/
|
||||
└── unit/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||
backend/
|
||||
apps/okr/backend/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── api/
|
||||
└── tests/
|
||||
|
||||
frontend/
|
||||
apps/okr/frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
|
||||
@@ -21,7 +21,7 @@ description: "Task list template for feature implementation"
|
||||
## Path Conventions
|
||||
|
||||
- **Single project**: `src/`, `tests/` at repository root
|
||||
- **Web app**: `backend/src/`, `frontend/src/`
|
||||
- **Web app**: `apps/okr/backend/src/`, `apps/okr/frontend/src/`
|
||||
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||
- Paths shown below assume single project - adjust based on plan.md structure
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ SYMBROKEN="$WORK/map-badsym.json"
|
||||
python3 - "$MAP" "$SYMBROKEN" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
d["FR-01"]["code"] = [{"file": "backend/src/auth/auth.service.ts", "symbols": ["NoSuchSymbolXYZ"]}]
|
||||
d["FR-01"]["code"] = [{"file": "apps/okr/backend/src/auth/auth.service.ts", "symbols": ["NoSuchSymbolXYZ"]}]
|
||||
json.dump(d, open(sys.argv[2], "w"), indent=2)
|
||||
PY
|
||||
set +e
|
||||
@@ -79,7 +79,7 @@ LINEBROKEN="$WORK/map-badline.json"
|
||||
python3 - "$MAP" "$LINEBROKEN" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
d["FR-01"]["code"] = [{"file": "backend/src/auth/auth.service.ts", "lines": [999999]}]
|
||||
d["FR-01"]["code"] = [{"file": "apps/okr/backend/src/auth/auth.service.ts", "lines": [999999]}]
|
||||
json.dump(d, open(sys.argv[2], "w"), indent=2)
|
||||
PY
|
||||
set +e
|
||||
|
||||
@@ -58,7 +58,7 @@ case "$step" in
|
||||
text="# Model Generated Tasks\n\n- [X] Backend auth module with JWT and bcrypt.\n- [X] Frontend dashboard and detail pages.\n- [X] Backend and frontend tests.\n- [X] Golden regression evidence.\n"
|
||||
;;
|
||||
10-implement)
|
||||
text="# Model Generated Implementation Draft\n\n## Backend Source\n- backend/src/auth/auth.service.ts\n- backend/src/objectives/objectives.service.ts\n\n## Frontend Source\n- frontend/src/lib/api.ts\n- frontend/src/pages/DashboardPage.tsx\n\n## Acceptance Gate\nThis draft is not accepted until STEP12 test PASS.\n"
|
||||
text="# Model Generated Implementation Draft\n\n## Backend Source\n- apps/okr/backend/src/auth/auth.service.ts\n- apps/okr/backend/src/objectives/objectives.service.ts\n\n## Frontend Source\n- apps/okr/frontend/src/lib/api.ts\n- apps/okr/frontend/src/pages/DashboardPage.tsx\n\n## Acceptance Gate\nThis draft is not accepted until STEP12 test PASS.\n"
|
||||
;;
|
||||
*)
|
||||
text="# Model Generated SRS\n\n## Functional Requirements\n- FR-01 Login\n- FR-02 Create Objective\n- FR-03 Create Key Result\n- FR-04 Update Progress\n- FR-05 Dashboard\n\n## Non Functional Requirements\nAuthentication required.\n"
|
||||
|
||||
@@ -28,7 +28,7 @@ expect_rc() {
|
||||
}
|
||||
|
||||
echo "===== C1: tool authorization / action gating (V17) ====="
|
||||
expect_rc 2 "C1 blocks overwrite of .env" bash "$S/action-gate.sh" --write "backend/.env"
|
||||
expect_rc 2 "C1 blocks overwrite of .env" bash "$S/action-gate.sh" --write "apps/okr/backend/.env"
|
||||
expect_rc 2 "C1 blocks write of a private key" bash "$S/action-gate.sh" --write "deploy/id_rsa"
|
||||
expect_rc 2 "C1 blocks write of a CI workflow" bash "$S/action-gate.sh" --write ".github/workflows/deploy.yml"
|
||||
expect_rc 2 "C1 blocks rm -rf /" bash "$S/action-gate.sh" --command "rm -rf /"
|
||||
|
||||
Reference in New Issue
Block a user