feat(projectinstance): allow live workflow config edits except switching

Activated projects can update duties, approval policy, and document enablement. Changing the workflow definition stays rejected, and project 143 remains frozen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 05:20:01 +00:00
co-authored by Cursor
parent b74441fb4f
commit f168e44ce7
11 changed files with 608 additions and 86 deletions
+1
View File
@@ -165,6 +165,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
}
if parts.Gov != nil {
instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov})
instSvc.SetTemplateDocMaterializer(&templateDocMaterializerAdapter{gov: parts.Gov})
}
}
taskMgr := taskrun.NewManager(pwSvc, parts.Ctx)
+27
View File
@@ -32,6 +32,33 @@ func (a *documentDutyProjectorAdapter) ProjectDocTaskDuties(
)
}
type templateDocMaterializerAdapter struct {
gov *docgov.GovernanceEngine
}
func (a *templateDocMaterializerAdapter) MaterializeTemplateDocs(ctx context.Context, projectID int64, plan projectinstance.WorkflowPlan) error {
if a == nil || a.gov == nil || projectID <= 0 {
return nil
}
specs := make([]docgov.TemplateDocSpec, 0, len(plan.Stages))
for _, stage := range plan.Stages {
for _, task := range stage.DocTasks {
_, effective, _ := projectinstance.ResolveDocTaskDuty(task)
specs = append(specs, docgov.TemplateDocSpec{
TaskKey: task.TaskKey,
Title: task.TaskTitle,
StageKey: stage.StageKey,
DutyPolicy: task.DutyPolicy,
Enabled: stage.Enabled && task.Enabled,
AuthorUserID: effective.AuthorUserID,
ReviewerUserID: effective.ReviewerUserID,
ApproverUserID: effective.ApproverUserID,
})
}
}
return a.gov.MaterializeTemplateDocs(ctx, projectID, specs)
}
type docTaskBusyCheckerAdapter struct {
gov *docgov.GovernanceEngine
taskRuns *taskrun.Manager
+27 -26
View File
@@ -7,33 +7,34 @@ import (
)
var (
ErrNotAvailable = errors.New("project instance store not available")
ErrProjectNotFound = errors.New("project not found")
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow")
ErrPlanFrozen = errors.New("plan frozen: workflow already active")
ErrWorkflowAlreadyActive = errors.New("workflow already active for project")
ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required")
ErrInvalidPlan = errors.New("invalid workflow plan")
ErrValidationFailed = errors.New("workflow plan validation failed")
ErrTokenInvalid = errors.New("activation token invalid or expired")
ErrConcurrentConflict = errors.New("conflict: project version mismatch")
ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch")
ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published")
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation")
ErrInstanceNotFound = errors.New("workflow instance not found")
ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved")
ErrStageNotActive = errors.New("stage is not the active stage")
ErrStageNotCompleted = errors.New("stage is not completed")
ErrRecheckAckRequired = errors.New("recheck acknowledgement required")
ErrInvalidStage = errors.New("invalid stage key for instance")
ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch")
ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate")
ErrTerminationNotAllowed = errors.New("termination not allowed for instance state")
ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled")
ErrNotAvailable = errors.New("project instance store not available")
ErrProjectNotFound = errors.New("project not found")
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow")
ErrPlanFrozen = errors.New("plan frozen: workflow already active")
ErrWorkflowSwitchFrozen = errors.New("workflow switch frozen: definition cannot change after activation")
ErrWorkflowAlreadyActive = errors.New("workflow already active for project")
ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required")
ErrInvalidPlan = errors.New("invalid workflow plan")
ErrValidationFailed = errors.New("workflow plan validation failed")
ErrTokenInvalid = errors.New("activation token invalid or expired")
ErrConcurrentConflict = errors.New("conflict: project version mismatch")
ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch")
ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published")
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation")
ErrInstanceNotFound = errors.New("workflow instance not found")
ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved")
ErrStageNotActive = errors.New("stage is not the active stage")
ErrStageNotCompleted = errors.New("stage is not completed")
ErrRecheckAckRequired = errors.New("recheck acknowledgement required")
ErrInvalidStage = errors.New("invalid stage key for instance")
ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch")
ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate")
ErrTerminationNotAllowed = errors.New("termination not allowed for instance state")
ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled")
ErrAuthorOnlyPolicyUnavailable = errors.New("author-only approval policy store unavailable")
ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview")
ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties")
ErrDocTaskNotFound = errors.New("document task not found or disabled")
ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview")
ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties")
ErrDocTaskNotFound = errors.New("document task not found or disabled")
)
// DocTaskBusyError 文档小队任务运行中,禁止改派。
+132
View File
@@ -0,0 +1,132 @@
package projectinstance
import (
"context"
"strings"
)
// UpdateLivePlan 在已激活实例上更新职责、审批策略和文档/阶段启用。
// 工作流定义与版本不可变。项目 143 保持冻结。
func (s *Service) UpdateLivePlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) {
if !isCreator {
return nil, ErrForbiddenNotCreator
}
if projectID == readonlyLivePlanProjectID {
return nil, ErrPlanFrozen
}
if !s.Available() {
return nil, ErrNotAvailable
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil {
return nil, err
}
if live == nil {
return nil, ErrInstanceNotFound
}
if live.Status == "completed" || live.Status == "terminated" {
return nil, ErrPlanFrozen
}
if in.ExpectedPlanRevision != live.ConfigRevision {
return nil, ErrInstanceRevisionConflict
}
plan := normalizeWorkflowPlanDuties(in.Plan)
if strings.TrimSpace(plan.WorkflowDefinitionKey) == "" {
plan.WorkflowDefinitionKey = live.ConfigSnapshot.WorkflowDefinitionKey
}
if plan.WorkflowVersion == 0 {
plan.WorkflowVersion = live.ConfigSnapshot.WorkflowVersion
}
if plan.WorkflowVersionID == 0 {
plan.WorkflowVersionID = live.WorkflowVersionID
}
if plan.WorkflowDefinitionKey != live.ConfigSnapshot.WorkflowDefinitionKey ||
plan.WorkflowVersion != live.ConfigSnapshot.WorkflowVersion ||
plan.WorkflowVersionID != live.WorkflowVersionID {
return nil, ErrWorkflowSwitchFrozen
}
plan.WorkflowVersionID = live.WorkflowVersionID
version, err := s.resolvePublishedVersion(ctx, plan)
if err != nil {
return nil, err
}
validation := validatePlan(plan, version)
if !validation.Valid {
return nil, newValidationError(validation)
}
if err := s.checkActiveMembers(ctx, projectID, plan); err != nil {
return nil, err
}
if planUsesAuthorOnly(plan) {
if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil {
return nil, err
}
}
if err := s.rejectBusyDocTaskChanges(ctx, projectID, live.ConfigSnapshot, plan); err != nil {
return nil, err
}
updated, err := s.store.UpdateLivePlan(ctx, projectID, plan, live.ConfigRevision, userID)
if err != nil {
return nil, err
}
s.projectLiveDuties(ctx, projectID, updated.ConfigSnapshot)
s.materializePlan(ctx, projectID, updated.ConfigSnapshot)
return s.GetSettings(ctx, projectID, userID, isCreator)
}
func (s *Service) rejectBusyDocTaskChanges(ctx context.Context, projectID int64, prev, next WorkflowPlan) error {
if s == nil || s.busyChecker == nil {
return nil
}
for _, st := range prev.Stages {
for _, before := range st.DocTasks {
after := findDocTaskPlan(next, before.TaskKey)
if after == nil || !liveDocTaskChanged(before, *after) {
continue
}
busy, reason, err := s.busyChecker.DocTaskSquadBusy(ctx, projectID, before.TaskKey)
if err != nil {
return err
}
if busy {
if reason == "" {
reason = "该文档小队任务进行中,完成后可更换负责人"
}
return &DocTaskBusyError{TaskKey: before.TaskKey, Reason: reason}
}
}
}
return nil
}
func liveDocTaskChanged(before, after DocTaskPlan) bool {
if before.Enabled != after.Enabled || before.DutyPolicy != after.DutyPolicy {
return true
}
return before.Duties != after.Duties
}
func (s *Service) projectLiveDuties(ctx context.Context, projectID int64, plan WorkflowPlan) {
if s == nil || s.projector == nil {
return
}
for _, st := range plan.Stages {
if !st.Enabled {
continue
}
for _, dt := range st.DocTasks {
if !dt.Enabled {
continue
}
mode, effective, _ := ResolveDocTaskDuty(dt)
if mode == DutyModeNone {
continue
}
_ = s.projector.ProjectDocTaskDuties(ctx, projectID, dt.TaskKey, effective, dt.DutyPolicy, DutyDisplayNames{})
}
}
}
+115
View File
@@ -0,0 +1,115 @@
package projectinstance
import (
"context"
"errors"
"testing"
"chat-agent-system/internal/workflowdef"
)
type countingMaterializer struct {
calls int
projectID int64
}
func (c *countingMaterializer) MaterializeTemplateDocs(_ context.Context, projectID int64, _ WorkflowPlan) error {
c.calls++
c.projectID = projectID
return nil
}
func TestUpdateLivePlanKeepsWorkflowAndMaterializes(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
mat := &countingMaterializer{}
svc := NewService(store, testResolver(), nil, nil)
svc.SetTemplateDocMaterializer(mat)
if _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
}); err != nil {
t.Fatal(err)
}
preview, err := svc.ActivationPreview(ctx, 42, 1, true)
if err != nil {
t.Fatal(err)
}
if _, err := svc.Activate(ctx, 42, 1, true, ActivateInput{
ConfirmationToken: preview.ConfirmationToken,
ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
}); err != nil {
t.Fatal(err)
}
if mat.calls != 1 || mat.projectID != 42 {
t.Fatalf("activate materialize calls=%d project=%d", mat.calls, mat.projectID)
}
settings, err := svc.GetSettings(ctx, 42, 1, true)
if err != nil {
t.Fatal(err)
}
if settings.PlanEditMode != PlanEditLive {
t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode)
}
plan := *settings.Plan
plan.Stages[0].DocTasks[0].Duties.ApproverUserID = 4
updated, err := svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{
Plan: plan, ExpectedPlanRevision: settings.WorkflowPlanRevision, LiveEdit: true,
})
if err != nil {
t.Fatal(err)
}
if updated.Plan.Stages[0].DocTasks[0].Duties.ApproverUserID != 4 {
t.Fatalf("duty not updated: %+v", updated.Plan.Stages[0].DocTasks[0].Duties)
}
if updated.Plan.WorkflowDefinitionKey != plan.WorkflowDefinitionKey {
t.Fatal("workflow key changed")
}
if mat.calls != 2 {
t.Fatalf("live update materialize calls=%d", mat.calls)
}
switched := plan
switched.WorkflowDefinitionKey = "other"
_, err = svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{
Plan: switched, ExpectedPlanRevision: updated.WorkflowPlanRevision,
})
if !errors.Is(err, ErrWorkflowSwitchFrozen) {
t.Fatalf("expected switch frozen, got %v", err)
}
}
func TestUpdateLivePlanRejectsReadonlyProject(t *testing.T) {
svc := NewService(newMemStore(), testResolver(), nil, nil)
_, err := svc.UpdateLivePlan(context.Background(), readonlyLivePlanProjectID, 1, true, SavePlanInput{
Plan: validPlan(), ExpectedPlanRevision: 1, LiveEdit: true,
})
if !errors.Is(err, ErrPlanFrozen) {
t.Fatalf("expected frozen, got %v", err)
}
}
func TestPlanEditModeFrozenForReadonlyLiveProject(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, readonlyLivePlanProjectID, LifecycleActive, workflowdef.OfficialKeyMain, 2, 1)
store.live[readonlyLivePlanProjectID] = &WorkflowInstance{
ID: 89,
ProjectID: readonlyLivePlanProjectID,
WorkflowVersionID: 1,
Status: InstanceActive,
ConfigRevision: 3,
ConfigSnapshot: validPlan(),
}
svc := NewService(store, testResolver(), nil, nil)
settings, err := svc.GetSettings(ctx, readonlyLivePlanProjectID, 1, true)
if err != nil {
t.Fatal(err)
}
if settings.PlanEditMode != PlanEditFrozen {
t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode)
}
}
+43
View File
@@ -0,0 +1,43 @@
package projectinstance
import (
"context"
"log/slog"
)
// readonlyLivePlanProjectID 是历史验收项目。激活后保持只读,禁止改写规划与补文档行。
const readonlyLivePlanProjectID int64 = 143
// TemplateDocMaterializer 把已启用文档任务物化到 document_records。
// 实现放在 app 适配层,避免 projectinstance 依赖 docgov。
type TemplateDocMaterializer interface {
MaterializeTemplateDocs(ctx context.Context, projectID int64, plan WorkflowPlan) error
}
func (s *Service) SetTemplateDocMaterializer(m TemplateDocMaterializer) {
if s == nil {
return
}
s.templateDocs = m
}
// MaterializeLiveDocs 按 live 快照补齐受控文档台账。列表接口可重复调用。
func (s *Service) MaterializeLiveDocs(ctx context.Context, projectID int64) error {
if s == nil || s.templateDocs == nil || s.store == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID {
return nil
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil || live == nil {
return err
}
return s.templateDocs.MaterializeTemplateDocs(ctx, projectID, live.ConfigSnapshot)
}
func (s *Service) materializePlan(ctx context.Context, projectID int64, plan WorkflowPlan) {
if s == nil || s.templateDocs == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID {
return
}
if err := s.templateDocs.MaterializeTemplateDocs(ctx, projectID, plan); err != nil {
slog.Warn("materialize template docs", "project_id", projectID, "err", err)
}
}
+22
View File
@@ -28,6 +28,7 @@ type Service struct {
authorOnlyPolicy AuthorOnlyPolicySource
projector DocumentDutyProjector
busyChecker DocTaskBusyChecker
templateDocs TemplateDocMaterializer
recheck RecheckGate
}
@@ -94,6 +95,7 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
settings.LiveInstance = liveInst
settings.CanEditPlan = false
settings.CanActivate = false
settings.WorkflowPlanRevision = liveInst.ConfigRevision
// 激活后权威规划在 live snapshot(planning 行已转为 active)。
snap := liveInst.ConfigSnapshot
settings.Plan = &snap
@@ -137,10 +139,27 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
}
}
settings.AuthorOnlyPolicy = s.loadAuthorOnlyCapability(ctx)
settings.PlanEditMode = planEditModeFor(projectID, lifecycle, liveInst)
_ = userID
return settings, nil
}
func planEditModeFor(projectID int64, lifecycle string, live *WorkflowInstance) string {
if projectID == readonlyLivePlanProjectID && (live != nil || lifecycle != LifecyclePlanning) {
return PlanEditFrozen
}
if live != nil {
if live.Status == "completed" || live.Status == "terminated" {
return PlanEditFrozen
}
return PlanEditLive
}
if lifecycle == LifecyclePlanning {
return PlanEditPlanning
}
return PlanEditFrozen
}
func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) {
if !isCreator {
return nil, ErrForbiddenNotCreator
@@ -318,6 +337,9 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
if err != nil {
return nil, err
}
if activated != nil {
s.materializePlan(ctx, projectID, activated.ConfigSnapshot)
}
return &ActivateResult{
ProjectID: projectID,
+172 -12
View File
@@ -62,6 +62,8 @@ type Store interface {
MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error
// UpdateLiveDocTaskDuties 更新 live 实例 config_snapshot 与 document_duty_assignments(单任务)。
UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error)
// UpdateLivePlan 更新 live 快照(职责、审批策略、文档/阶段启用),并同步实例行与职责指派。
UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error)
}
// NewStore 构建 PG 或内存 Store。
@@ -771,6 +773,118 @@ func (s *pgStore) UpdateLiveDocTaskDuties(ctx context.Context, projectID int64,
return inst, nil
}
func (s *pgStore) UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var instanceID int64
var revision int
err = tx.QueryRow(ctx, `
SELECT id, config_revision
FROM project_workflow_instances
WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation')
FOR UPDATE
`, projectID).Scan(&instanceID, &revision)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInstanceNotFound
}
if err != nil {
return nil, err
}
if revision != expectedConfigRevision {
return nil, ErrInstanceRevisionConflict
}
planHash, err := computePlanHash(plan)
if err != nil {
return nil, err
}
snapRaw, err := json.Marshal(plan)
if err != nil {
return nil, err
}
now := time.Now()
row := tx.QueryRow(ctx, `
UPDATE project_workflow_instances SET
config_snapshot = $2, plan_hash = $3, config_revision = config_revision + 1, updated_at = $4
WHERE id = $1 AND config_revision = $5
RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash,
config_snapshot, activated_by, activated_at, created_by, created_at, updated_at
`, instanceID, snapRaw, planHash, now, expectedConfigRevision)
inst, err := scanInstance(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInstanceRevisionConflict
}
if err != nil {
return nil, err
}
for _, st := range plan.Stages {
if _, err := tx.Exec(ctx, `
UPDATE project_stage_instances
SET enabled = $3, updated_at = $4
WHERE workflow_instance_id = $1 AND stage_key = $2
`, inst.ID, st.StageKey, st.Enabled, now); err != nil {
return nil, err
}
for _, dt := range st.DocTasks {
var docTaskID int64
err = tx.QueryRow(ctx, `
UPDATE project_doc_task_instances AS dti SET
enabled = $3,
status = CASE
WHEN $3 = false AND dti.status = 'pending' THEN 'skipped_disabled'
WHEN $3 = true AND dti.status = 'skipped_disabled' THEN 'pending'
ELSE dti.status
END,
updated_at = $4
FROM project_stage_instances si
WHERE dti.stage_instance_id = si.id
AND si.workflow_instance_id = $1
AND dti.task_key = $2
RETURNING dti.id
`, inst.ID, dt.TaskKey, dt.Enabled, now).Scan(&docTaskID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDocTaskNotFound
}
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `DELETE FROM document_duty_assignments WHERE doc_task_instance_id = $1`, docTaskID); err != nil {
return nil, err
}
if !dt.Enabled || !st.Enabled {
continue
}
mode, effective, _ := ResolveDocTaskDuty(dt)
if mode == DutyModeNone {
continue
}
for role, uid := range map[string]int64{
DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID,
} {
if uid <= 0 {
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO document_duty_assignments (doc_task_instance_id, duty_role, user_id, assigned_by)
VALUES ($1, $2, $3, $4)
`, docTaskID, role, uid, assignedBy); err != nil {
return nil, err
}
}
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return inst, nil
}
func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -834,21 +948,21 @@ type memDocTaskRecord struct {
}
type memStore struct {
mu sync.Mutex
meta map[int64]projectMeta
plan map[int64]*WorkflowInstance
live map[int64]*WorkflowInstance
tokens map[string]activationTokenRec
stages map[int64][]memStageRecord
nextStageID int64
nextTaskID int64
mu sync.Mutex
meta map[int64]projectMeta
plan map[int64]*WorkflowInstance
live map[int64]*WorkflowInstance
tokens map[string]activationTokenRec
stages map[int64][]memStageRecord
nextStageID int64
nextTaskID int64
}
type projectMeta struct {
lifecycle string
activeKey string
selectionVer int
planRevision int
lifecycle string
activeKey string
selectionVer int
planRevision int
}
type activationTokenRec struct {
@@ -1276,6 +1390,52 @@ func (m *memStore) UpdateLiveDocTaskDuties(_ context.Context, projectID int64, t
return &copyInst, nil
}
func (m *memStore) UpdateLivePlan(_ context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, _ int64) (*WorkflowInstance, error) {
m.mu.Lock()
defer m.mu.Unlock()
live := m.live[projectID]
if live == nil {
return nil, ErrInstanceNotFound
}
if live.ConfigRevision != expectedConfigRevision {
return nil, ErrInstanceRevisionConflict
}
planHash, err := computePlanHash(plan)
if err != nil {
return nil, err
}
copyInst := *live
copyInst.ConfigSnapshot = plan
copyInst.PlanHash = planHash
copyInst.ConfigRevision++
copyInst.UpdatedAt = time.Now()
m.live[projectID] = &copyInst
records := m.stages[copyInst.ID]
for i := range records {
for _, st := range plan.Stages {
if records[i].stageKey == st.StageKey {
records[i].enabled = st.Enabled
}
}
for j := range records[i].tasks {
dt := findDocTaskPlan(plan, records[i].tasks[j].taskKey)
if dt == nil {
continue
}
records[i].tasks[j].enabled = dt.Enabled
if !dt.Enabled && records[i].tasks[j].status == "pending" {
records[i].tasks[j].status = "skipped_disabled"
}
if dt.Enabled && records[i].tasks[j].status == "skipped_disabled" {
records[i].tasks[j].status = "pending"
}
}
}
m.stages[copyInst.ID] = records
return &copyInst, nil
}
func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
m.mu.Lock()
defer m.mu.Unlock()
+55 -47
View File
@@ -10,6 +10,10 @@ const (
LifecyclePlanning = "planning"
LifecycleActive = "active"
PlanEditPlanning = "planning"
PlanEditLive = "live"
PlanEditFrozen = "frozen"
InstancePlanning = "planning"
InstanceActive = "active"
@@ -32,10 +36,10 @@ type DocDuty struct {
// DocTaskPlan 文档任务裁剪与职责。
type DocTaskPlan struct {
TaskKey string `json:"task_key"`
TaskTitle string `json:"task_title,omitempty"`
Enabled bool `json:"enabled"`
ApprovalConfigurable bool `json:"approval_configurable,omitempty"`
TaskKey string `json:"task_key"`
TaskTitle string `json:"task_title,omitempty"`
Enabled bool `json:"enabled"`
ApprovalConfigurable bool `json:"approval_configurable,omitempty"`
// DutyPolicy 逐文档职责模式;空或缺省按 separated 解读(omitempty 保证旧快照 hash 不变)。
DutyPolicy string `json:"duty_policy,omitempty"`
Duties DocDuty `json:"duties"`
@@ -83,20 +87,22 @@ type GovernancePolicyCapability struct {
// ProjectSettings 项目设置聚合视图。
type ProjectSettings struct {
ProjectID int64 `json:"project_id"`
LifecycleStatus string `json:"lifecycle_status"`
ActiveWorkflowKey string `json:"active_workflow_key"`
WorkflowSelectionVersion int `json:"workflow_selection_version"`
WorkflowPlanRevision int `json:"workflow_plan_revision"`
Plan *WorkflowPlan `json:"plan,omitempty"`
PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"`
LiveInstance *WorkflowInstance `json:"live_instance,omitempty"`
CanEditPlan bool `json:"can_edit_plan"`
CanActivate bool `json:"can_activate"`
ActiveStageKey string `json:"active_stage_key,omitempty"`
StageAdvanceReady bool `json:"stage_advance_ready,omitempty"`
PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"`
AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"`
ProjectID int64 `json:"project_id"`
LifecycleStatus string `json:"lifecycle_status"`
ActiveWorkflowKey string `json:"active_workflow_key"`
WorkflowSelectionVersion int `json:"workflow_selection_version"`
WorkflowPlanRevision int `json:"workflow_plan_revision"`
Plan *WorkflowPlan `json:"plan,omitempty"`
PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"`
LiveInstance *WorkflowInstance `json:"live_instance,omitempty"`
CanEditPlan bool `json:"can_edit_plan"`
CanActivate bool `json:"can_activate"`
// PlanEditMode 是 planning | live | frozen。live 允许改职责/审批/启用,禁止切换工作流。
PlanEditMode string `json:"plan_edit_mode,omitempty"`
ActiveStageKey string `json:"active_stage_key,omitempty"`
StageAdvanceReady bool `json:"stage_advance_ready,omitempty"`
PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"`
AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"`
}
// PublishedOption 可选已发布工作流。
@@ -113,6 +119,8 @@ type SavePlanInput struct {
Plan WorkflowPlan `json:"plan"`
ExpectedPlanRevision int `json:"expected_plan_revision"`
ExpectedSelectionVersion int `json:"expected_selection_version"`
// LiveEdit 为 true 时走激活后的配置更新;缺省仍走规划保存(激活后拒绝)。
LiveEdit bool `json:"live_edit,omitempty"`
}
// ActivationPreview 激活预览与确认令牌。
@@ -135,18 +143,18 @@ type PreviewDocRef struct {
// PreviewSummary 激活影响摘要。
type PreviewSummary struct {
WorkflowName string `json:"workflow_name"`
WorkflowVersion int `json:"workflow_version"`
EnabledStages int `json:"enabled_stages"`
EnabledDocTasks int `json:"enabled_doc_tasks"`
GovernedDocTasks int `json:"governed_doc_tasks"`
UnmanagedDocTasks int `json:"unmanaged_doc_tasks"`
AssignedDuties int `json:"assigned_duties"`
AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"`
UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"`
AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"`
FirstStageKey string `json:"first_stage_key,omitempty"`
FirstStageName string `json:"first_stage_name,omitempty"`
WorkflowName string `json:"workflow_name"`
WorkflowVersion int `json:"workflow_version"`
EnabledStages int `json:"enabled_stages"`
EnabledDocTasks int `json:"enabled_doc_tasks"`
GovernedDocTasks int `json:"governed_doc_tasks"`
UnmanagedDocTasks int `json:"unmanaged_doc_tasks"`
AssignedDuties int `json:"assigned_duties"`
AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"`
UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"`
AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"`
FirstStageKey string `json:"first_stage_key,omitempty"`
FirstStageName string `json:"first_stage_name,omitempty"`
}
// ActivateInput 二次确认激活。
@@ -170,8 +178,8 @@ const (
StageCompleted = "completed"
StageSkipped = "skipped"
InstanceCompleted = "completed"
InstanceTerminated = "terminated"
InstanceCompleted = "completed"
InstanceTerminated = "terminated"
InstanceAwaitingStageConfirmation = "awaiting_stage_confirmation"
InstanceTerminationRequested = "termination_requested"
)
@@ -227,25 +235,25 @@ type GateIssue struct {
// DocTaskReadiness 文档任务就绪状态。
type DocTaskReadiness struct {
TaskKey string `json:"task_key"`
Required bool `json:"required"`
Enabled bool `json:"enabled"`
ApprovalManaged bool `json:"approval_managed"`
DocState string `json:"doc_state"`
Approved bool `json:"approved"`
TaskKey string `json:"task_key"`
Required bool `json:"required"`
Enabled bool `json:"enabled"`
ApprovalManaged bool `json:"approval_managed"`
DocState string `json:"doc_state"`
Approved bool `json:"approved"`
}
// StageReadiness 阶段推进就绪评估。
type StageReadiness struct {
InstanceID int64 `json:"instance_id"`
StageKey string `json:"stage_key"`
StageName string `json:"stage_name"`
Ready bool `json:"ready"`
CanAdvance bool `json:"can_advance"`
RequiresRecheckAck bool `json:"requires_recheck_ack"`
BlockingIssues []GateIssue `json:"blocking_issues"`
DocTasks []DocTaskReadiness `json:"doc_tasks"`
ConfigRevision int `json:"config_revision"`
InstanceID int64 `json:"instance_id"`
StageKey string `json:"stage_key"`
StageName string `json:"stage_name"`
Ready bool `json:"ready"`
CanAdvance bool `json:"can_advance"`
RequiresRecheckAck bool `json:"requires_recheck_ack"`
BlockingIssues []GateIssue `json:"blocking_issues"`
DocTasks []DocTaskReadiness `json:"doc_tasks"`
ConfigRevision int `json:"config_revision"`
}
// AdvanceStageInput 创建者确认推进。
+6
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"errors"
"log/slog"
"net/http"
"path/filepath"
"strconv"
@@ -83,6 +84,11 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
return
}
if s.instSvc != nil {
if err := s.instSvc.MaterializeLiveDocs(r.Context(), projectID); err != nil {
slog.Warn("materialize live docs", "project_id", projectID, "err", err)
}
}
docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID)
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error())
+8 -1
View File
@@ -98,7 +98,12 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request)
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
return
}
settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req)
var settings *projectinstance.ProjectSettings
if req.LiveEdit {
settings, err = s.instSvc.UpdateLivePlan(r.Context(), projectID, user.ID, isCreator, req)
} else {
settings, err = s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req)
}
if err != nil {
writeProjectInstanceError(w, err)
return
@@ -163,6 +168,8 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`)
case errors.Is(err, projectinstance.ErrPlanFrozen):
writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`)
case errors.Is(err, projectinstance.ErrWorkflowSwitchFrozen):
writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_SWITCH_FROZEN","message":"运行中不能切换工作流,只能调整职责、审批和文档启用"}`)
case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive):
writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`)
case errors.Is(err, projectinstance.ErrPlanChangedReconfirm):