Files
weichen 83561682eb feat(swboot): wire SWBOOT-10 controlled patch executor (fail-closed)
DB-backed repo_path_allowlist reader (deny-all until admin seeds), per-prefix
admin test_commands (goose 00075), execution evidence table (goose 00074),
isolated git-worktree patch executor with command allowlist/time/output caps
and failure-stops evidence; controlled_patch creation/preview unlocked only
with an allowlisted repo binding (projectworkflow gate + catalog flag);
creator-only server endpoints for bindings/executions; merge/push/deploy stay
behind a typed separate-authorization boundary. Module boundary: app->swboot
composition import registered.
2026-09-26 00:56:59 +00:00

363 lines
11 KiB
Go

package projectworkflow
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"chat-agent-system/internal/auth"
)
// Service 项目多工作流与血缘领域服务
type Service struct {
store Store
catalog Catalog
// RepoAllowlistChecker unlocks execution_mode=controlled_patch at
// creation when the bound repo path passes the admin allowlist (SWBOOT-10).
// nil keeps the fail-closed rejection.
RepoAllowlistChecker func(repoPath string) bool
}
// NewService 构造项目工作流服务
func NewService(store Store, catalog Catalog) *Service {
if catalog == nil {
catalog = NewDefaultCatalog()
}
return &Service{
store: store,
catalog: catalog,
}
}
// Catalog 获取静态工作流规范目录
func (s *Service) Catalog() Catalog {
return s.catalog
}
// Store 获取持久化存储接口
func (s *Service) Store() Store {
return s.store
}
// CreateProject 创建新项目,严格从认证令牌注入权威创建人,并自动初始化挂载工作流
func (s *Service) CreateProject(ctx context.Context, user *auth.UserAccount, in CreateProjectInput) (*Project, error) {
if user == nil || user.ID <= 0 {
return nil, ErrUnauthenticated
}
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, fmt.Errorf("%w: 项目名称为必填项", ErrInvalidInput)
}
clientKey := strings.TrimSpace(in.ClientKey)
if clientKey != "" {
existing, err := s.store.GetProjectByClientKey(ctx, user.ID, clientKey)
if err == nil && existing != nil {
return existing, nil
}
if err != nil && err != ErrProjectNotFound {
return nil, err
}
}
if existing, err := s.store.FindProjectByName(ctx, name); err == nil && existing != nil {
return nil, fmt.Errorf("%w: 项目名「%s」已被占用,请更换名称后重试", ErrDuplicateProjectName, name)
} else if err != nil && err != ErrProjectNotFound {
return nil, err
}
code := strings.TrimSpace(in.Code)
if code == "" {
code = generateProjectCode(name)
}
initialWorkflow := strings.TrimSpace(in.InitialWorkflowKey)
if initialWorkflow != "" {
if _, ok := s.catalog.Get(initialWorkflow); !ok {
return nil, fmt.Errorf("%w: 初始工作流 %s 不存在", ErrWorkflowNotFound, initialWorkflow)
}
}
stage := "V0.1_triage"
if strings.TrimSpace(in.ProductDomain) == "non_medical_software" {
stage = "S0_boundary"
if strings.TrimSpace(in.ExecutionMode) == "controlled_patch" {
repoPath := strings.TrimSpace(in.RepoPath)
if repoPath == "" || s.RepoAllowlistChecker == nil || !s.RepoAllowlistChecker(repoPath) {
return nil, fmt.Errorf("%w: controlled_patch requires an allowlisted repo binding (SWBOOT-10)", ErrInvalidInput)
}
}
}
p := &Project{
Code: code,
Name: name,
CustomerName: in.CustomerName,
DeviceType: in.DeviceType,
IntendedUse: in.IntendedUse,
TargetMarket: in.TargetMarket,
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,
LifecycleStatus: "planning",
ActiveWorkflowKey: "",
WorkflowSelectionVersion: 1,
WorkflowPlanRevision: 0,
ClientKey: clientKey,
}
// initialWorkflow is catalog-validated only; activation stays on the settings path.
created, err := s.store.CreateProject(ctx, p)
if err != nil {
return nil, err
}
// SWBOOT-10: persist the repo binding for controlled_patch projects so
// the executor can verify it on every later execution.
if strings.TrimSpace(in.ExecutionMode) == "controlled_patch" && strings.TrimSpace(in.RepoPath) != "" {
if err := s.store.SaveProjectSourceBinding(ctx, created.ID, strings.TrimSpace(in.RepoPath), "controlled_patch", user.ID); err != nil {
return nil, err
}
}
// 按产品域挂载目录工作流,避免软件项目克隆医疗 key。
allKeys := WorkflowKeysForProductDomain(in.ProductDomain)
if len(allKeys) == 0 {
for _, def := range s.catalog.List() {
allKeys = append(allKeys, def.Key)
}
}
if err := s.store.EnsureProjectWorkflows(ctx, created.ID, allKeys); err != nil {
return nil, fmt.Errorf("initialize project workflows failed: %w", err)
}
return created, nil
}
// GetProject 根据项目 ID 获取项目详情
func (s *Service) GetProject(ctx context.Context, projectID int64, userID int64) (*Project, error) {
if projectID <= 0 {
return nil, ErrProjectNotFound
}
p, err := s.store.GetProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
return p, nil
}
// GetProjectByCode 根据项目唯一编码获取项目详情
func (s *Service) GetProjectByCode(ctx context.Context, code string) (*Project, error) {
return s.store.GetProjectByCode(ctx, code)
}
// ListProjects 查询指定用户可见的项目列表
func (s *Service) ListProjects(ctx context.Context, userID int64) ([]*Project, error) {
return s.store.ListProjectsForUser(ctx, userID)
}
// ListAllProjects 查询全部项目,仅供已完成系统管理员鉴权的调用方使用。
func (s *Service) ListAllProjects(ctx context.Context) ([]*Project, error) {
return s.store.ListAllProjects(ctx)
}
// IsProjectCreator 判断用户是否为指定项目的权威创建人
func (s *Service) IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error) {
p, err := s.store.GetProjectByID(ctx, projectID)
if err != nil {
return false, err
}
return p.CreatorUserID == userID, nil
}
// ListProjectWorkflows 查询项目已挂载的工作流清单
func (s *Service) ListProjectWorkflows(ctx context.Context, projectID int64) ([]*ProjectWorkflow, error) {
domain := ""
if p, err := s.store.GetProjectByID(ctx, projectID); err == nil && p != nil {
domain = p.ProductDomain
}
allKeys := WorkflowKeysForProductDomain(domain)
if len(allKeys) == 0 {
for _, def := range s.catalog.List() {
allKeys = append(allKeys, def.Key)
}
}
_ = s.store.EnsureProjectWorkflows(ctx, projectID, allKeys)
return s.store.ListProjectWorkflows(ctx, projectID)
}
// ActivateWorkflow 原子切换项目当前激活的工作流焦点(仅限创建人)
func (s *Service) ActivateWorkflow(
ctx context.Context,
projectID int64,
userID int64,
in ActivateWorkflowInput,
) (*ActivateWorkflowResult, error) {
if userID <= 0 {
return nil, ErrUnauthenticated
}
targetKey := strings.TrimSpace(in.WorkflowKey)
if targetKey == "" {
return nil, fmt.Errorf("%w: target workflow_key cannot be empty", ErrInvalidInput)
}
def, ok := s.catalog.Get(targetKey)
if !ok || !def.Enabled {
return nil, fmt.Errorf("%w: workflow '%s' is not supported or disabled", ErrWorkflowNotFound, targetKey)
}
updatedProject, audit, err := s.store.UpdateActiveWorkflow(
ctx,
projectID,
targetKey,
in.ExpectedVersion,
userID,
in.Reason,
)
if err != nil {
return nil, err
}
now := time.Now()
if audit != nil {
now = audit.CreatedAt
} else if updatedProject.ActiveWorkflowChangedAt != nil {
now = *updatedProject.ActiveWorkflowChangedAt
}
return &ActivateWorkflowResult{
ProjectID: updatedProject.ID,
ActiveWorkflowKey: updatedProject.ActiveWorkflowKey,
Version: updatedProject.WorkflowSelectionVersion,
ChangedAt: now,
ChangedBy: userID,
}, nil
}
// CreateWorkflowRun 创建工作流运行实例
func (s *Service) CreateWorkflowRun(ctx context.Context, run *WorkflowRun) (*WorkflowRun, error) {
if run.RunID == "" {
run.RunID = generateRunID(run.WorkflowKey)
}
if run.Status == "" {
run.Status = "running"
}
return s.store.CreateWorkflowRun(ctx, run)
}
// UpdateWorkflowRun 结束/更新工作流运行实例
func (s *Service) UpdateWorkflowRun(
ctx context.Context,
runID string,
status string,
outputDocID string,
summary string,
errMsg string,
) error {
return s.store.UpdateWorkflowRun(ctx, runID, status, outputDocID, summary, errMsg)
}
// GetWorkflowRun 获取运行记录详情
func (s *Service) GetWorkflowRun(ctx context.Context, runID string) (*WorkflowRun, error) {
return s.store.GetWorkflowRun(ctx, runID)
}
// ListWorkflowRuns 列出项目的运行历史
func (s *Service) ListWorkflowRuns(ctx context.Context, projectID int64, workflowKey string, limit int) ([]*WorkflowRun, error) {
return s.store.ListWorkflowRuns(ctx, projectID, workflowKey, limit)
}
// RecordLineage 记录文档版本血缘
func (s *Service) RecordLineage(ctx context.Context, lineage *DocumentLineage) error {
return s.store.RecordLineage(ctx, lineage)
}
// GetLineageForDownstream 查询下游文档所依赖的上游版本信息。
// 内部失效传播仍用此方法;HTTP 读取必须走 GetLineageForProjectDownstream。
func (s *Service) GetLineageForDownstream(ctx context.Context, docID string, version int) ([]*DocumentLineage, error) {
return s.store.GetLineageForDownstream(ctx, docID, version)
}
// GetLineageForProjectDownstream 只返回 URL 项目内的血缘边。
func (s *Service) GetLineageForProjectDownstream(ctx context.Context, projectID int64, docID string, version int) ([]*DocumentLineage, error) {
if projectID <= 0 || strings.TrimSpace(docID) == "" {
return nil, nil
}
return s.store.GetLineageForProjectDownstream(ctx, projectID, strings.TrimSpace(docID), version)
}
// CheckAndPropagateStale 当上游文档更新且内容哈希变化时,通知并将关联下游文档标记为 stale
func (s *Service) CheckAndPropagateStale(
ctx context.Context,
upstreamDocID string,
newSHA256 string,
reason string,
) ([]*DocumentLineage, error) {
lineages, err := s.store.GetLineagesForUpstream(ctx, upstreamDocID)
if err != nil {
return nil, err
}
needsStale := false
for _, l := range lineages {
if l.UpstreamSHA256 != "" && l.UpstreamSHA256 != newSHA256 && !l.IsStale {
needsStale = true
break
}
}
if !needsStale {
return nil, nil
}
if reason == "" {
reason = fmt.Sprintf("上游文档 %s 生成了新版本,内容已发生实质变化", upstreamDocID)
}
return s.store.MarkDownstreamStale(ctx, upstreamDocID, reason)
}
// 辅助方法:生成项目短代码(按 rune 截断,避免切断 UTF-8 多字节字符导致 PG 插入失败)
func generateProjectCode(name string) string {
b := make([]byte, 4)
_, _ = rand.Read(b)
hash := hex.EncodeToString(b)
cleanName := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), " ", "-"))
runes := []rune(cleanName)
if len(runes) > 12 {
runes = runes[:12]
cleanName = string(runes)
}
if cleanName == "" {
cleanName = "proj"
}
return fmt.Sprintf("%s-%s", cleanName, hash)
}
// 辅助方法:生成运行实例 ID
func generateRunID(workflowKey string) string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return fmt.Sprintf("run_%s_%s", workflowKey, hex.EncodeToString(b))
}