fix template, remove okr, use casan.*
This commit is contained in:
@@ -16,6 +16,8 @@ export interface GoalProject {
|
||||
domain: string;
|
||||
domain_root: string;
|
||||
context_roots: string[];
|
||||
manifest?: string;
|
||||
shell_root?: string;
|
||||
}
|
||||
|
||||
export interface GoalProjectCreateInput {
|
||||
@@ -109,6 +111,7 @@ const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py');
|
||||
const PATCH_EXECUTOR_CLI = join(HARNESS_BIN, 'goal-patch-executor.py');
|
||||
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
|
||||
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json');
|
||||
const PROJECT_SCAFFOLDER = join(APP_ROOT, 'packages', 'casan-devkit', 'project-scaffold.py');
|
||||
|
||||
function parseJson<T>(value: string): T | null {
|
||||
try {
|
||||
@@ -260,10 +263,10 @@ export class GoalsService {
|
||||
if (actor.role !== 'org-admin') throw new ForbiddenException('GOAL_PROJECT_CREATE_DENIED');
|
||||
const projectId = String(input.projectId ?? '').trim();
|
||||
const domain = String(input.domain ?? '').trim();
|
||||
if (!/^[A-Za-z][A-Za-z0-9._-]{2,63}$/.test(projectId)) {
|
||||
if (!/^[a-z][a-z0-9-]{1,62}$/.test(projectId)) {
|
||||
throw new BadRequestException('GOAL_PROJECT_ID_INVALID');
|
||||
}
|
||||
if (domain.length < 3 || domain.length > 100) {
|
||||
if (!/^[\p{L}\p{N}][\p{L}\p{N} .&()'_-]{1,99}$/u.test(domain)) {
|
||||
throw new BadRequestException('GOAL_PROJECT_DOMAIN_INVALID');
|
||||
}
|
||||
const lockPath = `${PROJECT_REGISTRY}.lock`;
|
||||
@@ -291,13 +294,83 @@ export class GoalsService {
|
||||
absoluteRoot = join(canonicalProjects, projectId);
|
||||
mkdirSync(absoluteRoot, { mode: 0o750 });
|
||||
createdRoot = true;
|
||||
const relativeRoot = join('apps', 'projects', projectId);
|
||||
const entry = { project_id: projectId, domain, domain_root: relativeRoot, context_roots: [relativeRoot], harness_package: 'fpt-casan-sdd-harness', harness_version: '1.0.0', status: 'active' };
|
||||
execFileSync('python3', [PROJECT_SCAFFOLDER,
|
||||
'--target', absoluteRoot,
|
||||
'--project', projectId,
|
||||
'--name', domain,
|
||||
'--template', 'nestjs-react',
|
||||
'--with-harness',
|
||||
], {
|
||||
cwd: APP_ROOT,
|
||||
env: process.env,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const relativeRoot = join('apps', 'projects', projectId).replaceAll('\\', '/');
|
||||
const embeddedAppRoot = `${relativeRoot}/apps/${projectId}`;
|
||||
const adapterManifestPath = `${relativeRoot}/casan.workspace.manifest.json`;
|
||||
const adapterManifest = {
|
||||
schema_version: 1,
|
||||
project_id: projectId,
|
||||
display_name: domain,
|
||||
domain_root: `${embeddedAppRoot}/domain`,
|
||||
requirements: `${embeddedAppRoot}/domain/input/requirement.md`,
|
||||
architecture: `${embeddedAppRoot}/domain/input/architecture.md`,
|
||||
quality_profile: `${relativeRoot}/config/casan/quality-profiles/enterprise-web-v1.json`,
|
||||
feature: { id: `001-${projectId}-app`, module_id: 'MOD-01', slug: `${projectId}-core`, title: `${domain} Core` },
|
||||
source_roots: [`${embeddedAppRoot}/backend`, `${embeddedAppRoot}/frontend`],
|
||||
commands: {
|
||||
build: [['npm', '--prefix', relativeRoot, 'run', 'build']],
|
||||
test: [['npm', '--prefix', relativeRoot, 'test']],
|
||||
},
|
||||
verification: [
|
||||
{
|
||||
path_prefix: `${embeddedAppRoot}/backend/`,
|
||||
commands: [
|
||||
['npm', '--prefix', relativeRoot, 'run', 'build', '-w', `@${projectId}/backend`],
|
||||
['npm', '--prefix', relativeRoot, 'test', '-w', `@${projectId}/backend`],
|
||||
],
|
||||
},
|
||||
{
|
||||
path_prefix: `${embeddedAppRoot}/frontend/`,
|
||||
commands: [
|
||||
['npm', '--prefix', relativeRoot, 'run', 'build', '-w', `@${projectId}/frontend`],
|
||||
['npm', '--prefix', relativeRoot, 'test', '-w', `@${projectId}/frontend`],
|
||||
],
|
||||
},
|
||||
],
|
||||
artifacts_root: `${relativeRoot}/docs/output`,
|
||||
implementation_evidence: [
|
||||
`${embeddedAppRoot}/backend/src/main.ts`,
|
||||
`${embeddedAppRoot}/backend/test/health.test.ts`,
|
||||
`${embeddedAppRoot}/frontend/src/App.tsx`,
|
||||
`${embeddedAppRoot}/frontend/src/__tests__/App.test.tsx`,
|
||||
],
|
||||
tech_stack: 'NestJS 10, React 18, Vite 5, Tailwind CSS 3, strict TypeScript',
|
||||
};
|
||||
const adapterAbsolute = join(APP_ROOT, adapterManifestPath);
|
||||
const adapterTemporary = `${adapterAbsolute}.${process.pid}.${randomUUID()}.tmp`;
|
||||
writeFileSync(adapterTemporary, `${JSON.stringify(adapterManifest, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
renameSync(adapterTemporary, adapterAbsolute);
|
||||
|
||||
const entry = {
|
||||
project_id: projectId,
|
||||
domain,
|
||||
domain_root: `${embeddedAppRoot}/domain`,
|
||||
manifest: adapterManifestPath,
|
||||
context_roots: [relativeRoot],
|
||||
shell_root: relativeRoot,
|
||||
harness_package: 'fpt-casan-sdd-harness',
|
||||
harness_version: '1.0.0',
|
||||
status: 'active',
|
||||
};
|
||||
const updated = { ...registry, projects: [...registry.projects, entry] };
|
||||
const temporary = `${PROJECT_REGISTRY}.${process.pid}.${randomUUID()}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify(updated, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
renameSync(temporary, PROJECT_REGISTRY);
|
||||
return { project_id: projectId, domain, domain_root: relativeRoot, context_roots: [relativeRoot] };
|
||||
return { project_id: projectId, domain, domain_root: entry.domain_root, manifest: adapterManifestPath, shell_root: relativeRoot, context_roots: [relativeRoot] };
|
||||
} catch (error) {
|
||||
if (createdRoot && absoluteRoot) rmSync(absoluteRoot, { recursive: true, force: true });
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -379,7 +452,14 @@ export class GoalsService {
|
||||
}
|
||||
return relative;
|
||||
});
|
||||
return { project_id: projectId, domain: String(entry.domain ?? projectId), domain_root: domainRoot, context_roots: contextRoots };
|
||||
return {
|
||||
project_id: projectId,
|
||||
domain: String(entry.domain ?? projectId),
|
||||
domain_root: domainRoot,
|
||||
context_roots: contextRoots,
|
||||
manifest: entry.manifest ? String(entry.manifest) : undefined,
|
||||
shell_root: entry.shell_root ? String(entry.shell_root) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,39 @@ test('approval inbox submit -> approve applies governed setting and writes overs
|
||||
});
|
||||
});
|
||||
|
||||
test('operations owner request remains visible and actionable for an independent reviewer', async () => {
|
||||
await withTempGovernance(async () => {
|
||||
const svc = new ApprovalsService();
|
||||
const submitted = svc.submit({
|
||||
action: 'goal.workspace.execute',
|
||||
target: 'customer-portal',
|
||||
risk: 'high',
|
||||
sensitive: true,
|
||||
reason: 'apply the reviewed workspace patch',
|
||||
payload: { goal_id: 'goal-123', project_id: 'customer-portal' },
|
||||
}, projectAdmin) as { proposal: { id: string; status: string; proposer: string } };
|
||||
|
||||
const reviewerInbox = svc.list(approver, 'pending') as {
|
||||
count: number;
|
||||
proposals: Array<{ id: string; proposer: string; status: string }>;
|
||||
};
|
||||
const visible = reviewerInbox.proposals.find((proposal) => proposal.id === submitted.proposal.id);
|
||||
|
||||
assert.equal(reviewerInbox.count, 1);
|
||||
assert.equal(visible?.proposer, projectAdmin.actor);
|
||||
assert.equal(visible?.status, 'pending');
|
||||
|
||||
const decided = await svc.decide({
|
||||
id: submitted.proposal.id,
|
||||
decision: 'approve',
|
||||
reason: 'independent reviewer verified scope and controls',
|
||||
}, approver) as { proposal: { status: string; approver: string } };
|
||||
|
||||
assert.equal(decided.proposal.status, 'approved');
|
||||
assert.equal(decided.proposal.approver, approver.actor);
|
||||
});
|
||||
});
|
||||
|
||||
test('approval inbox denies forged JWT in strict mode without deciding proposal', async () => {
|
||||
await withTempGovernance(async ({ inbox }) => {
|
||||
const svc = new ApprovalsService();
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { GoalsService } from '../src/goals/goals.service.js';
|
||||
|
||||
const admin = { actor: 'goal-admin', role: 'org-admin', project: 'default', tenant: 'goal-test' };
|
||||
const root = resolve(import.meta.dirname, '../../../..');
|
||||
const registry = resolve(root, 'packages/casan-harness/level5/project-registry.json');
|
||||
|
||||
test('goal project selector exposes only active allowlisted registry entries', () => {
|
||||
const result = new GoalsService().projects(admin);
|
||||
@@ -25,3 +30,37 @@ test('goal project creation is restricted to organization administrators', () =>
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
test('goal project creation rejects unsafe template values before touching the workspace', () => {
|
||||
assert.throws(
|
||||
() => new GoalsService().createProject({ projectId: 'unsafe-shell', domain: 'Broken "Template"' }, admin),
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
test('goal project creation builds a complete isolated shell and central adapter manifest', () => {
|
||||
const projectId = `goal-shell-${process.pid}`;
|
||||
const projectRoot = resolve(root, 'apps/projects', projectId);
|
||||
const registryBefore = readFileSync(registry, 'utf8');
|
||||
try {
|
||||
const created = new GoalsService().createProject({ projectId, domain: 'Goal Shell Verification' }, admin);
|
||||
assert.equal(created.project_id, projectId);
|
||||
assert.equal(created.shell_root, `apps/projects/${projectId}`);
|
||||
assert.equal(created.manifest, `apps/projects/${projectId}/casan.workspace.manifest.json`);
|
||||
assert.ok(existsSync(resolve(projectRoot, '.github/workflows/ci.yml')));
|
||||
assert.ok(existsSync(resolve(projectRoot, `apps/${projectId}/domain/project.manifest.json`)));
|
||||
assert.ok(existsSync(resolve(projectRoot, 'packages/casan-harness/scripts/bash/project-gate.sh')));
|
||||
assert.ok(existsSync(resolve(projectRoot, 'casan.workspace.manifest.json')));
|
||||
|
||||
const validation = execFileSync('python3', [
|
||||
resolve(root, 'packages/casan-harness/scripts/bash/project_manifest.py'),
|
||||
'validate', '--root', root, '--manifest', created.manifest!,
|
||||
], { encoding: 'utf8' });
|
||||
assert.match(validation, /"status": "valid"/);
|
||||
assert.ok(new GoalsService().projects(admin).projects.some((project) => project.project_id === projectId && project.shell_root === created.shell_root));
|
||||
} finally {
|
||||
writeFileSync(registry, registryBefore, 'utf8');
|
||||
rmSync(projectRoot, { recursive: true, force: true });
|
||||
rmSync(`${registry}.lock`, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user