feat(api): SWBOOT-05..08 wizard hooks and SWBOOT-10 allowlist stub
Add project-types/compliance catalogs, creation-preview, software create fields, and fail-closed repo allowlist stub without real patch/merge. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -193,6 +193,7 @@ imports:
|
||||
- squad
|
||||
- stream
|
||||
- stream/transport
|
||||
- swboot
|
||||
- taskrun
|
||||
- upload
|
||||
- workflowdef
|
||||
@@ -209,6 +210,7 @@ imports:
|
||||
stream: []
|
||||
stream/transport:
|
||||
- stream
|
||||
swboot: []
|
||||
taskrun:
|
||||
- app/uow
|
||||
- artifactvault
|
||||
|
||||
@@ -28,11 +28,11 @@ type memoryCatalog struct {
|
||||
order []string
|
||||
}
|
||||
|
||||
// NewDefaultCatalog 创建默认工作流目录(内建 main、product_manager、test_manager)
|
||||
// NewDefaultCatalog 创建默认工作流目录(医疗三 + 软件自举/交付)
|
||||
func NewDefaultCatalog() Catalog {
|
||||
c := &memoryCatalog{
|
||||
definitions: make(map[string]*Definition),
|
||||
order: []string{"main", "product_manager", "test_manager"},
|
||||
order: []string{"main", "product_manager", "test_manager", "software_bootstrap", "software_delivery"},
|
||||
}
|
||||
|
||||
c.definitions["main"] = &Definition{
|
||||
@@ -69,6 +69,28 @@ func NewDefaultCatalog() Catalog {
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
c.definitions["software_bootstrap"] = &Definition{
|
||||
Key: "software_bootstrap",
|
||||
Name: "软件项目自举流程",
|
||||
Description: "S0–S4:章程、现状、需求、架构、风险、路线与就绪评审",
|
||||
StageKey: "S0_boundary",
|
||||
TaskKey: "software_charter",
|
||||
OutputDocType: "software_charter",
|
||||
OutputDocTitle: "项目章程",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
c.definitions["software_delivery"] = &Definition{
|
||||
Key: "software_delivery",
|
||||
Name: "软件交付迭代流程",
|
||||
Description: "D0–D4:变更定义、实施证据、验证、发布与复盘",
|
||||
StageKey: "D0_change_definition",
|
||||
TaskKey: "change_brief",
|
||||
OutputDocType: "change_brief",
|
||||
OutputDocTitle: "变更简报",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,14 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
|
||||
}
|
||||
}
|
||||
|
||||
stage := "V0.1_triage"
|
||||
if strings.TrimSpace(in.ProductDomain) == "non_medical_software" {
|
||||
stage = "S0_boundary"
|
||||
if strings.TrimSpace(in.ExecutionMode) == "controlled_patch" {
|
||||
return nil, fmt.Errorf("%w: controlled_patch requires SWBOOT-10 executor", ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
p := &Project{
|
||||
Code: code,
|
||||
Name: name,
|
||||
@@ -68,7 +76,20 @@ func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in
|
||||
DeviceType: in.DeviceType,
|
||||
IntendedUse: in.IntendedUse,
|
||||
TargetMarket: in.TargetMarket,
|
||||
CurrentStage: "V0.1_triage",
|
||||
ProductDomain: strings.TrimSpace(in.ProductDomain),
|
||||
ProjectTypeKey: strings.TrimSpace(in.ProjectTypeKey),
|
||||
ComplianceProfileKey: strings.TrimSpace(in.ComplianceProfileKey),
|
||||
GovernanceProfile: strings.TrimSpace(in.GovernanceProfile),
|
||||
TeamMode: strings.TrimSpace(in.TeamMode),
|
||||
ExecutionMode: strings.TrimSpace(in.ExecutionMode),
|
||||
DataSensitivity: strings.TrimSpace(in.DataSensitivity),
|
||||
NetworkPolicy: strings.TrimSpace(in.NetworkPolicy),
|
||||
BudgetClass: strings.TrimSpace(in.BudgetClass),
|
||||
ProblemStatement: strings.TrimSpace(in.ProblemStatement),
|
||||
TargetUsers: strings.TrimSpace(in.TargetUsers),
|
||||
TargetOutcome: strings.TrimSpace(in.TargetOutcome),
|
||||
ProductShape: strings.TrimSpace(in.ProductShape),
|
||||
CurrentStage: stage,
|
||||
Environment: "dev",
|
||||
Status: "active",
|
||||
CreatorUserID: user.ID,
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("list keyed workflows: %v", err)
|
||||
}
|
||||
if len(keyedWFs) != 3 {
|
||||
if len(keyedWFs) != 5 {
|
||||
t.Fatalf("catalog must still mount after keyed create, got %d", len(keyedWFs))
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ func TestProjectWorkflowService_CreateAndCreatorACL(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ListProjectWorkflows failed: %v", err)
|
||||
}
|
||||
if len(wfs) != 3 {
|
||||
t.Fatalf("expected 3 default workflows, got %d", len(wfs))
|
||||
if len(wfs) != 5 {
|
||||
t.Fatalf("expected 5 default workflows, got %d", len(wfs))
|
||||
}
|
||||
|
||||
// 4. 非创建人尝试切换工作流 -> 必须被拒绝
|
||||
@@ -230,3 +230,48 @@ func TestProjectWorkflowService_LineageAndStale(t *testing.T) {
|
||||
t.Errorf("expected downstream 'doc_test_plan_001', got '%s'", staleUpdates[0].DownstreamDocID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSoftwareProject_NoMedicalDefaults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(NewStore(nil), NewDefaultCatalog())
|
||||
creator := &auth.UserAccount{ID: 201, Username: "dev", Name: "开发者"}
|
||||
|
||||
proj, err := svc.CreateProject(ctx, creator, CreateProjectInput{
|
||||
Name: "Agent-System 自举",
|
||||
ProductDomain: "non_medical_software",
|
||||
ProjectTypeKey: "existing_self_bootstrap",
|
||||
ComplianceProfileKey: "internal_control+security_baseline",
|
||||
GovernanceProfile: "standard",
|
||||
TeamMode: "solo",
|
||||
ExecutionMode: "docs_only",
|
||||
ProblemStatement: "建立可信路线",
|
||||
TargetUsers: "内部团队",
|
||||
TargetOutcome: "可执行发布路线",
|
||||
InitialWorkflowKey: "software_bootstrap",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject: %v", err)
|
||||
}
|
||||
if proj.ProductDomain != "non_medical_software" || proj.ProjectTypeKey != "existing_self_bootstrap" {
|
||||
t.Fatalf("domain/type not persisted: %+v", proj)
|
||||
}
|
||||
if proj.CurrentStage != "S0_boundary" {
|
||||
t.Fatalf("expected S0_boundary stage, got %s", proj.CurrentStage)
|
||||
}
|
||||
if proj.DeviceType != "" || proj.IntendedUse != "" {
|
||||
t.Fatalf("software create must not invent medical device fields: device=%q use=%q", proj.DeviceType, proj.IntendedUse)
|
||||
}
|
||||
if proj.ActiveWorkflowKey != "" {
|
||||
t.Fatal("must remain planning without activation")
|
||||
}
|
||||
|
||||
_, err = svc.CreateProject(ctx, creator, CreateProjectInput{
|
||||
Name: "bad",
|
||||
ProductDomain: "non_medical_software",
|
||||
ExecutionMode: "controlled_patch",
|
||||
})
|
||||
if err == nil || !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("expected controlled_patch rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,15 +73,29 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
|
||||
query := `
|
||||
INSERT INTO projects (
|
||||
code, name, customer_name, device_type, intended_use, target_market,
|
||||
product_domain, project_type_key, compliance_profile_key, governance_profile,
|
||||
team_mode, execution_mode, data_sensitivity, network_policy, budget_class,
|
||||
problem_statement, target_users, target_outcome, product_shape,
|
||||
current_stage, environment, status, creator_user_id, lifecycle_status,
|
||||
active_workflow_key, workflow_selection_version, workflow_plan_revision,
|
||||
created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15,
|
||||
$16, $17, $18, $19,
|
||||
$20, $21, $22, $23, $24,
|
||||
$25, $26, $27,
|
||||
$28, $29
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
err := s.pool.QueryRow(
|
||||
ctx, query,
|
||||
p.Code, p.Name, p.CustomerName, p.DeviceType, p.IntendedUse, p.TargetMarket,
|
||||
p.ProductDomain, p.ProjectTypeKey, p.ComplianceProfileKey, p.GovernanceProfile,
|
||||
p.TeamMode, p.ExecutionMode, p.DataSensitivity, p.NetworkPolicy, p.BudgetClass,
|
||||
p.ProblemStatement, p.TargetUsers, p.TargetOutcome, p.ProductShape,
|
||||
p.CurrentStage, p.Environment, p.Status, p.CreatorUserID, p.LifecycleStatus,
|
||||
p.ActiveWorkflowKey, p.WorkflowSelectionVersion, p.WorkflowPlanRevision,
|
||||
p.CreatedAt, p.UpdatedAt,
|
||||
@@ -96,6 +110,11 @@ func (s *pgStore) CreateProject(ctx context.Context, p *Project) (*Project, erro
|
||||
func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error) {
|
||||
query := `
|
||||
SELECT id, code, name, customer_name, device_type, intended_use, target_market,
|
||||
COALESCE(product_domain, ''), COALESCE(project_type_key, ''), COALESCE(compliance_profile_key, ''),
|
||||
COALESCE(governance_profile, ''), COALESCE(team_mode, ''), COALESCE(execution_mode, ''),
|
||||
COALESCE(data_sensitivity, ''), COALESCE(network_policy, ''), COALESCE(budget_class, ''),
|
||||
COALESCE(problem_statement, ''), COALESCE(target_users, ''), COALESCE(target_outcome, ''),
|
||||
COALESCE(product_shape, ''),
|
||||
current_stage, environment, status, COALESCE(creator_user_id, 1),
|
||||
COALESCE(lifecycle_status, 'planning'), COALESCE(active_workflow_key, ''),
|
||||
COALESCE(workflow_selection_version, 1), COALESCE(workflow_plan_revision, 0),
|
||||
@@ -106,6 +125,10 @@ func (s *pgStore) GetProjectByID(ctx context.Context, id int64) (*Project, error
|
||||
p := &Project{}
|
||||
err := s.pool.QueryRow(ctx, query, id).Scan(
|
||||
&p.ID, &p.Code, &p.Name, &p.CustomerName, &p.DeviceType, &p.IntendedUse, &p.TargetMarket,
|
||||
&p.ProductDomain, &p.ProjectTypeKey, &p.ComplianceProfileKey,
|
||||
&p.GovernanceProfile, &p.TeamMode, &p.ExecutionMode,
|
||||
&p.DataSensitivity, &p.NetworkPolicy, &p.BudgetClass,
|
||||
&p.ProblemStatement, &p.TargetUsers, &p.TargetOutcome, &p.ProductShape,
|
||||
&p.CurrentStage, &p.Environment, &p.Status, &p.CreatorUserID,
|
||||
&p.LifecycleStatus, &p.ActiveWorkflowKey, &p.WorkflowSelectionVersion, &p.WorkflowPlanRevision,
|
||||
&p.ActiveWorkflowChangedAt, &p.ActiveWorkflowChangedBy, &p.CreatedAt, &p.UpdatedAt,
|
||||
|
||||
@@ -13,6 +13,19 @@ type Project struct {
|
||||
DeviceType string `json:"device_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
TargetMarket string `json:"target_market"`
|
||||
ProductDomain string `json:"product_domain,omitempty"`
|
||||
ProjectTypeKey string `json:"project_type_key,omitempty"`
|
||||
ComplianceProfileKey string `json:"compliance_profile_key,omitempty"`
|
||||
GovernanceProfile string `json:"governance_profile,omitempty"`
|
||||
TeamMode string `json:"team_mode,omitempty"`
|
||||
ExecutionMode string `json:"execution_mode,omitempty"`
|
||||
DataSensitivity string `json:"data_sensitivity,omitempty"`
|
||||
NetworkPolicy string `json:"network_policy,omitempty"`
|
||||
BudgetClass string `json:"budget_class,omitempty"`
|
||||
ProblemStatement string `json:"problem_statement,omitempty"`
|
||||
TargetUsers string `json:"target_users,omitempty"`
|
||||
TargetOutcome string `json:"target_outcome,omitempty"`
|
||||
ProductShape string `json:"product_shape,omitempty"`
|
||||
CurrentStage string `json:"current_stage"`
|
||||
Environment string `json:"environment"`
|
||||
Status string `json:"status"`
|
||||
@@ -85,7 +98,7 @@ type DocumentLineage struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateProjectInput 创建项目输入参数
|
||||
// CreateProjectInput 创建项目输入参数(兼容医疗字段 + 软件向导钩子)。
|
||||
type CreateProjectInput struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code,omitempty"`
|
||||
@@ -93,6 +106,21 @@ type CreateProjectInput struct {
|
||||
DeviceType string `json:"device_type,omitempty"`
|
||||
IntendedUse string `json:"intended_use,omitempty"`
|
||||
TargetMarket string `json:"target_market,omitempty"`
|
||||
ProductDomain string `json:"product_domain,omitempty"`
|
||||
ProjectTypeKey string `json:"project_type_key,omitempty"`
|
||||
ComplianceProfileKey string `json:"compliance_profile_key,omitempty"`
|
||||
GovernanceProfile string `json:"governance_profile,omitempty"`
|
||||
TeamMode string `json:"team_mode,omitempty"`
|
||||
ExecutionMode string `json:"execution_mode,omitempty"`
|
||||
DataSensitivity string `json:"data_sensitivity,omitempty"`
|
||||
NetworkPolicy string `json:"network_policy,omitempty"`
|
||||
BudgetClass string `json:"budget_class,omitempty"`
|
||||
ProblemStatement string `json:"problem_statement,omitempty"`
|
||||
TargetUsers string `json:"target_users,omitempty"`
|
||||
TargetOutcome string `json:"target_outcome,omitempty"`
|
||||
ProductShape string `json:"product_shape,omitempty"`
|
||||
CreationToken string `json:"creation_token,omitempty"`
|
||||
PlanHash string `json:"plan_hash,omitempty"`
|
||||
// Deprecated: accepted for catalog existence checks only; never activates the project.
|
||||
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
|
||||
}
|
||||
|
||||
@@ -166,6 +166,9 @@ func (s *Server) Routes() http.Handler {
|
||||
|
||||
// 2. 项目与多工作流控制面
|
||||
mux.HandleFunc("POST /api/v1/projects", s.handleCreateProject)
|
||||
mux.HandleFunc("POST /api/v1/projects/creation-preview", s.handleProjectCreationPreview)
|
||||
mux.HandleFunc("GET /api/v1/project-types/catalog", s.handleProjectTypesCatalog)
|
||||
mux.HandleFunc("GET /api/v1/compliance-profiles/catalog", s.handleComplianceProfilesCatalog)
|
||||
mux.HandleFunc("GET /api/v1/projects", s.handleListProjects)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}", s.handleGetProjectByID)
|
||||
mux.HandleFunc("GET /api/v1/projects/{id}/members", s.handleProjectMembers)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/swboot"
|
||||
)
|
||||
|
||||
// handleProjectTypesCatalog GET /api/v1/project-types/catalog
|
||||
func (s *Server) handleProjectTypesCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requirePrincipal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"project_types": swboot.ProjectTypesCatalog(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleComplianceProfilesCatalog GET /api/v1/compliance-profiles/catalog
|
||||
func (s *Server) handleComplianceProfilesCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requirePrincipal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"compliance_profiles": swboot.ComplianceProfilesCatalog(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleProjectCreationPreview POST /api/v1/projects/creation-preview
|
||||
func (s *Server) handleProjectCreationPreview(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.authenticateUser(r)
|
||||
if err != nil || user == nil {
|
||||
writeSafeError(w, r, http.StatusUnauthorized, `{"error":"ERR_UNAUTHENTICATED","message":"请先登录"}`)
|
||||
return
|
||||
}
|
||||
if ok, permErr := s.userHasSystemPermission(r.Context(), user, "project:create"); permErr != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, `{"error":"ERR_INTERNAL","message":"`+permErr.Error()+`"}`)
|
||||
return
|
||||
} else if !ok {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "无权创建项目",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req swboot.CreationPreviewInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
return
|
||||
}
|
||||
res := swboot.BuildCreationPreview(req, time.Now().UTC())
|
||||
status := http.StatusOK
|
||||
if !res.Valid {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeJSONResponse(w, status, res)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package swboot
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Product domains and enumerations (SWBOOT-00 contract).
|
||||
const (
|
||||
DomainNonMedicalSoftware = "non_medical_software"
|
||||
DomainMedicalDevice = "medical_device"
|
||||
|
||||
ProjectTypeGreenfield = "greenfield"
|
||||
ProjectTypeExistingProduct = "existing_product"
|
||||
ProjectTypeLegacyModernization = "legacy_modernization"
|
||||
ProjectTypeExistingSelfBootstrap = "existing_self_bootstrap"
|
||||
|
||||
GovernanceLean = "lean"
|
||||
GovernanceStandard = "standard"
|
||||
GovernanceControlled = "controlled"
|
||||
|
||||
TeamModeSolo = "solo"
|
||||
TeamModeCollaborative = "collaborative"
|
||||
|
||||
ExecutionDocsOnly = "docs_only"
|
||||
ExecutionReadOnlyRepo = "read_only_repo"
|
||||
ExecutionControlledPatch = "controlled_patch"
|
||||
|
||||
ApprovalIndependentThreeDuties = "independent_three_duties"
|
||||
ApprovalCreatorAcknowledgement = "creator_acknowledgement"
|
||||
)
|
||||
|
||||
// ProjectTypeCatalogItem is a create-wizard selectable project type.
|
||||
type ProjectTypeCatalogItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ProductDomain string `json:"product_domain"`
|
||||
Description string `json:"description"`
|
||||
DefaultGovernance string `json:"default_governance"`
|
||||
DefaultWorkflowKey string `json:"default_workflow_key"`
|
||||
RecommendedWorkflows []string `json:"recommended_workflows"`
|
||||
}
|
||||
|
||||
// ComplianceProfileCatalogItem is a create-wizard compliance option.
|
||||
type ComplianceProfileCatalogItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RequiresIndependentReview bool `json:"requires_independent_review"`
|
||||
}
|
||||
|
||||
// CreationPreviewInput is the seven-step wizard confirmation payload.
|
||||
type CreationPreviewInput struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code,omitempty"`
|
||||
ProductDomain string `json:"product_domain"`
|
||||
ProjectTypeKey string `json:"project_type_key"`
|
||||
ProductShape string `json:"product_shape,omitempty"`
|
||||
ProblemStatement string `json:"problem_statement"`
|
||||
TargetUsers string `json:"target_users"`
|
||||
TargetOutcome string `json:"target_outcome"`
|
||||
GovernanceProfile string `json:"governance_profile"`
|
||||
TeamMode string `json:"team_mode"`
|
||||
ComplianceProfileKey string `json:"compliance_profile_key"`
|
||||
DataSensitivity string `json:"data_sensitivity,omitempty"`
|
||||
NetworkPolicy string `json:"network_policy,omitempty"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
BudgetClass string `json:"budget_class,omitempty"`
|
||||
InitialWorkflowKey string `json:"initial_workflow_key,omitempty"`
|
||||
CatalogVersionHint string `json:"expected_catalog_version,omitempty"`
|
||||
}
|
||||
|
||||
// CreationPreviewResult is returned by POST /projects/creation-preview.
|
||||
type CreationPreviewResult struct {
|
||||
CreationToken string `json:"creation_token"`
|
||||
PlanHash string `json:"plan_hash"`
|
||||
SourceHash string `json:"source_hash"`
|
||||
ExpectedCatalogVersion string `json:"expected_catalog_version"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ApprovalPolicy string `json:"approval_policy"`
|
||||
RecommendedWorkflow string `json:"recommended_workflow"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Issues []string `json:"issues,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
// ProjectTypesCatalog returns seeded software project types.
|
||||
func ProjectTypesCatalog() []ProjectTypeCatalogItem {
|
||||
return []ProjectTypeCatalogItem{
|
||||
{
|
||||
Key: ProjectTypeGreenfield, Name: "绿地立项", ProductDomain: DomainNonMedicalSoftware,
|
||||
Description: "从零规划新软件", DefaultGovernance: GovernanceStandard,
|
||||
DefaultWorkflowKey: "software_bootstrap", RecommendedWorkflows: []string{"software_bootstrap", "software_delivery"},
|
||||
},
|
||||
{
|
||||
Key: ProjectTypeExistingProduct, Name: "既有产品梳理", ProductDomain: DomainNonMedicalSoftware,
|
||||
Description: "梳理已有产品与仓库", DefaultGovernance: GovernanceStandard,
|
||||
DefaultWorkflowKey: "software_bootstrap", RecommendedWorkflows: []string{"software_bootstrap", "software_delivery"},
|
||||
},
|
||||
{
|
||||
Key: ProjectTypeLegacyModernization, Name: "遗留现代化", ProductDomain: DomainNonMedicalSoftware,
|
||||
Description: "遗留系统改造与迁移", DefaultGovernance: GovernanceStandard,
|
||||
DefaultWorkflowKey: "software_bootstrap", RecommendedWorkflows: []string{"software_bootstrap", "software_delivery"},
|
||||
},
|
||||
{
|
||||
Key: ProjectTypeExistingSelfBootstrap, Name: "系统自举", ProductDomain: DomainNonMedicalSoftware,
|
||||
Description: "用 Agent-System 梳理并建设自身", DefaultGovernance: GovernanceStandard,
|
||||
DefaultWorkflowKey: "software_bootstrap", RecommendedWorkflows: []string{"software_bootstrap", "software_delivery"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ComplianceProfilesCatalog returns seeded compliance profiles.
|
||||
func ComplianceProfilesCatalog() []ComplianceProfileCatalogItem {
|
||||
return []ComplianceProfileCatalogItem{
|
||||
{Key: "none", Name: "无外部合规预设", Description: "不预设外部法规步骤;鉴权/审计/安全基线仍生效"},
|
||||
{Key: "internal_control", Name: "内部控制", Description: "内部流程、权限与审计约束"},
|
||||
{Key: "security_baseline", Name: "安全基线", Description: "威胁建模、依赖与 secret 边界"},
|
||||
{Key: "privacy", Name: "隐私保护", Description: "数据分类与最小权限", RequiresIndependentReview: true},
|
||||
{Key: "custom", Name: "自定义合规", Description: "项目自定义合规包", RequiresIndependentReview: true},
|
||||
{Key: "internal_control+security_baseline", Name: "内部控制+安全基线", Description: "首期推荐组合"},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildCreationPreview validates wizard input and issues a short-lived token.
|
||||
func BuildCreationPreview(in CreationPreviewInput, now time.Time) CreationPreviewResult {
|
||||
res := CreationPreviewResult{
|
||||
Valid: true,
|
||||
ExpectedCatalogVersion: strings.TrimSpace(in.CatalogVersionHint),
|
||||
ExpiresAt: now.Add(15 * time.Minute),
|
||||
SourceHash: "sha256:empty",
|
||||
}
|
||||
if res.ExpectedCatalogVersion == "" {
|
||||
res.ExpectedCatalogVersion = "software-v1"
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "name is required")
|
||||
}
|
||||
domain := strings.TrimSpace(in.ProductDomain)
|
||||
if domain == "" {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "product_domain is required")
|
||||
}
|
||||
if domain != "" && domain != DomainNonMedicalSoftware && domain != DomainMedicalDevice {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "unsupported product_domain")
|
||||
}
|
||||
ptype := strings.TrimSpace(in.ProjectTypeKey)
|
||||
if domain == DomainNonMedicalSoftware {
|
||||
if !isKnownProjectType(ptype) {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "project_type_key is required for non_medical_software")
|
||||
}
|
||||
if strings.TrimSpace(in.ProblemStatement) == "" || strings.TrimSpace(in.TargetUsers) == "" || strings.TrimSpace(in.TargetOutcome) == "" {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "problem_statement, target_users and target_outcome are required")
|
||||
}
|
||||
}
|
||||
|
||||
gov := strings.TrimSpace(in.GovernanceProfile)
|
||||
if gov == "" {
|
||||
gov = GovernanceStandard
|
||||
}
|
||||
if gov != GovernanceLean && gov != GovernanceStandard && gov != GovernanceControlled {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "invalid governance_profile")
|
||||
}
|
||||
|
||||
team := strings.TrimSpace(in.TeamMode)
|
||||
if team == "" {
|
||||
team = TeamModeCollaborative
|
||||
}
|
||||
compliance := strings.TrimSpace(in.ComplianceProfileKey)
|
||||
if compliance == "" {
|
||||
compliance = "internal_control+security_baseline"
|
||||
}
|
||||
if requiresIndependentReview(compliance) && team == TeamModeSolo {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "compliance profile requiring independent review forbids solo")
|
||||
}
|
||||
if gov == GovernanceControlled && team == TeamModeSolo {
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "controlled governance forbids solo")
|
||||
}
|
||||
|
||||
execMode := strings.TrimSpace(in.ExecutionMode)
|
||||
if execMode == "" {
|
||||
execMode = ExecutionDocsOnly
|
||||
}
|
||||
switch execMode {
|
||||
case ExecutionDocsOnly, ExecutionReadOnlyRepo:
|
||||
// ok for SWBOOT-00..09
|
||||
case ExecutionControlledPatch:
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "ERR_EXECUTION_MODE_DISABLED: controlled_patch requires SWBOOT-10 executor")
|
||||
default:
|
||||
res.Valid = false
|
||||
res.Issues = append(res.Issues, "invalid execution_mode")
|
||||
}
|
||||
|
||||
wf := strings.TrimSpace(in.InitialWorkflowKey)
|
||||
if wf == "" && domain == DomainNonMedicalSoftware {
|
||||
wf = "software_bootstrap"
|
||||
}
|
||||
if domain == DomainNonMedicalSoftware && wf != "" && wf != "software_bootstrap" && wf != "software_delivery" {
|
||||
res.Warnings = append(res.Warnings, "non-software workflow selected for non_medical_software project")
|
||||
}
|
||||
res.RecommendedWorkflow = wf
|
||||
if team == TeamModeSolo {
|
||||
res.ApprovalPolicy = ApprovalCreatorAcknowledgement
|
||||
res.Warnings = append(res.Warnings, "未独立评审:solo 使用 creator_acknowledgement")
|
||||
} else {
|
||||
res.ApprovalPolicy = ApprovalIndependentThreeDuties
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"name": name, "code": strings.TrimSpace(in.Code),
|
||||
"product_domain": domain, "project_type_key": ptype,
|
||||
"governance_profile": gov, "team_mode": team,
|
||||
"compliance_profile_key": compliance, "execution_mode": execMode,
|
||||
"workflow": wf, "catalog": res.ExpectedCatalogVersion,
|
||||
"problem_statement": strings.TrimSpace(in.ProblemStatement),
|
||||
"target_users": strings.TrimSpace(in.TargetUsers),
|
||||
"target_outcome": strings.TrimSpace(in.TargetOutcome),
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
sum := sha256.Sum256(raw)
|
||||
res.PlanHash = "sha256:" + hex.EncodeToString(sum[:])
|
||||
res.CreationToken = fmt.Sprintf("crt_%s", hex.EncodeToString(sum[:8]))
|
||||
if !res.Valid {
|
||||
res.CreationToken = ""
|
||||
res.PlanHash = ""
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func isKnownProjectType(key string) bool {
|
||||
switch key {
|
||||
case ProjectTypeGreenfield, ProjectTypeExistingProduct, ProjectTypeLegacyModernization, ProjectTypeExistingSelfBootstrap:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func requiresIndependentReview(profile string) bool {
|
||||
p := strings.ToLower(profile)
|
||||
return strings.Contains(p, "privacy") || strings.Contains(p, "custom") || p == "controlled"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package swboot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProjectTypesCatalog(t *testing.T) {
|
||||
items := ProjectTypesCatalog()
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("expected 4 project types, got %d", len(items))
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.ProductDomain != DomainNonMedicalSoftware {
|
||||
t.Fatalf("project type %s polluted domain %q", it.Key, it.ProductDomain)
|
||||
}
|
||||
if it.DefaultWorkflowKey != "software_bootstrap" {
|
||||
t.Fatalf("expected software_bootstrap default for %s", it.Key)
|
||||
}
|
||||
blob := strings.ToLower(it.Name + " " + it.Description)
|
||||
for _, bad := range []string{"临床", "nmpa", "cdmo"} {
|
||||
if strings.Contains(blob, bad) {
|
||||
t.Fatalf("medical term in project type %s", it.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreationPreview_SelfBootstrapSolo(t *testing.T) {
|
||||
res := BuildCreationPreview(CreationPreviewInput{
|
||||
Name: "Agent-System 自举", ProductDomain: DomainNonMedicalSoftware,
|
||||
ProjectTypeKey: ProjectTypeExistingSelfBootstrap,
|
||||
ProblemStatement: "建立可信路线", TargetUsers: "内部团队", TargetOutcome: "可执行发布路线",
|
||||
GovernanceProfile: GovernanceStandard, TeamMode: TeamModeSolo,
|
||||
ComplianceProfileKey: "internal_control+security_baseline",
|
||||
ExecutionMode: ExecutionDocsOnly,
|
||||
}, time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC))
|
||||
if !res.Valid {
|
||||
t.Fatalf("expected valid preview, issues=%v", res.Issues)
|
||||
}
|
||||
if res.ApprovalPolicy != ApprovalCreatorAcknowledgement {
|
||||
t.Fatalf("expected creator acknowledgement, got %s", res.ApprovalPolicy)
|
||||
}
|
||||
if res.RecommendedWorkflow != "software_bootstrap" {
|
||||
t.Fatalf("expected software_bootstrap, got %s", res.RecommendedWorkflow)
|
||||
}
|
||||
if res.CreationToken == "" || res.PlanHash == "" {
|
||||
t.Fatal("expected token and plan hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreationPreview_RejectsControlledPatchAndSoloPrivacy(t *testing.T) {
|
||||
res := BuildCreationPreview(CreationPreviewInput{
|
||||
Name: "x", ProductDomain: DomainNonMedicalSoftware,
|
||||
ProjectTypeKey: ProjectTypeGreenfield,
|
||||
ProblemStatement: "p", TargetUsers: "u", TargetOutcome: "o",
|
||||
TeamMode: TeamModeSolo, ComplianceProfileKey: "privacy",
|
||||
ExecutionMode: ExecutionControlledPatch,
|
||||
}, time.Now().UTC())
|
||||
if res.Valid {
|
||||
t.Fatal("expected invalid")
|
||||
}
|
||||
joined := strings.Join(res.Issues, ";")
|
||||
if !strings.Contains(joined, "solo") {
|
||||
t.Fatalf("expected solo/privacy issue, got %v", res.Issues)
|
||||
}
|
||||
if !strings.Contains(joined, "ERR_EXECUTION_MODE_DISABLED") {
|
||||
t.Fatalf("expected controlled_patch disabled, got %v", res.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoAllowlistStub(t *testing.T) {
|
||||
al := NewRepoAllowlist([]string{"/home/weichen/projects/Agent-System"})
|
||||
if err := al.CheckReadOnlyAccess("/home/weichen/projects/Agent-System/cmd"); err != nil {
|
||||
t.Fatalf("expected allowlisted path ok: %v", err)
|
||||
}
|
||||
if err := al.CheckReadOnlyAccess("/etc/passwd"); !errors.Is(err, ErrRepoPathNotAllowlisted) {
|
||||
t.Fatalf("expected not allowlisted, got %v", err)
|
||||
}
|
||||
if err := al.ExecuteControlledPatch("/home/weichen/projects/Agent-System", nil); !errors.Is(err, ErrPatchExecutionUnimplemented) {
|
||||
t.Fatalf("expected stub rejection, got %v", err)
|
||||
}
|
||||
if err := ApproveMerge("feature/x"); !errors.Is(err, ErrPatchExecutionUnimplemented) {
|
||||
t.Fatalf("expected merge stub rejection, got %v", err)
|
||||
}
|
||||
empty := DefaultRepoAllowlist()
|
||||
if empty.IsAllowlisted("/home/weichen/projects/Agent-System") {
|
||||
t.Fatal("empty allowlist must deny all")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package swboot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SWBOOT-10 stub errors — real patch/merge is intentionally unimplemented.
|
||||
var (
|
||||
ErrRepoPathNotAllowlisted = errors.New("ERR_REPO_PATH_NOT_ALLOWLISTED")
|
||||
ErrControlledPatchDisabled = errors.New("ERR_EXECUTION_MODE_DISABLED")
|
||||
ErrPatchExecutionUnimplemented = errors.New("ERR_SWBOOT10_STUB_ONLY")
|
||||
)
|
||||
|
||||
// RepoAllowlist is an in-memory / admin-seeded path prefix allowlist stub.
|
||||
type RepoAllowlist struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
// NewRepoAllowlist builds a stub allowlist from configured path prefixes.
|
||||
func NewRepoAllowlist(prefixes []string) *RepoAllowlist {
|
||||
out := make([]string, 0, len(prefixes))
|
||||
for _, p := range prefixes {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Clean(p))
|
||||
}
|
||||
return &RepoAllowlist{prefixes: out}
|
||||
}
|
||||
|
||||
// DefaultRepoAllowlist returns an empty allowlist (deny-all until admin seeds paths).
|
||||
func DefaultRepoAllowlist() *RepoAllowlist {
|
||||
return NewRepoAllowlist(nil)
|
||||
}
|
||||
|
||||
// IsAllowlisted reports whether absPath is under an enabled prefix.
|
||||
func (a *RepoAllowlist) IsAllowlisted(absPath string) bool {
|
||||
if a == nil || len(a.prefixes) == 0 {
|
||||
return false
|
||||
}
|
||||
clean := filepath.Clean(strings.TrimSpace(absPath))
|
||||
if !filepath.IsAbs(clean) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range a.prefixes {
|
||||
if clean == prefix || strings.HasPrefix(clean, prefix+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckReadOnlyAccess validates a path for docs_only / read_only_repo modes.
|
||||
func (a *RepoAllowlist) CheckReadOnlyAccess(absPath string) error {
|
||||
if !a.IsAllowlisted(absPath) {
|
||||
return fmt.Errorf("%w: path %q not in admin allowlist", ErrRepoPathNotAllowlisted, absPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteControlledPatch is the SWBOOT-10 stub: always rejects real patch/merge.
|
||||
func (a *RepoAllowlist) ExecuteControlledPatch(absPath string, _ []string) error {
|
||||
if err := a.CheckReadOnlyAccess(absPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: controlled patch/merge not implemented; docs_only/read_only_repo only", ErrPatchExecutionUnimplemented)
|
||||
}
|
||||
|
||||
// ApproveMerge is the SWBOOT-10 stub: approve-execution never implies merge.
|
||||
func ApproveMerge(_ string) error {
|
||||
return fmt.Errorf("%w: merge/push/deploy require a separate future approval path", ErrPatchExecutionUnimplemented)
|
||||
}
|
||||
Reference in New Issue
Block a user