package service import ( "fmt" "sort" "strings" "time" "browser.local/platform/domain" "browser.local/platform/repo" ) type scumWorkflowTemplateDefinition struct { Key string Title string Steps []scumWorkflowStepDefinition } type scumWorkflowStepDefinition struct { Key string DependsOn []string OperationKey string QueryTemplateKey string Capability string TargetKey string MutatesState bool MaxAttempts int Summary string } func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string, request domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) { request = domain.CopySCUMWorkflowInstance(request) user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.SCUMWorkflowInstance{}, err } if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { return domain.SCUMWorkflowInstance{}, err } instance, err := svc.store.ServerInstances().Get(serverID) if err != nil { return domain.SCUMWorkflowInstance{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.SCUMWorkflowInstance{}, err } template, ok := scumWorkflowTemplates()[request.TemplateKey] if !ok { return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow template is not declared") } if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { return domain.SCUMWorkflowInstance{}, validationError("workflow idempotency key is required") } if existing, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}); err == nil && len(existing) > 0 { return domain.CopySCUMWorkflowInstance(existing[0]), nil } else if err != nil { return domain.SCUMWorkflowInstance{}, err } stamp := svc.now() workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM background sequence queued with declared steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp} if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil { return domain.SCUMWorkflowInstance{}, err } for index, step := range template.Steps { maxAttempts := step.MaxAttempts if maxAttempts == 0 { maxAttempts = 1 } record := domain.SCUMWorkflowStep{ID: fmt.Sprintf("%s.step.%02d.%s", workflow.ID, index+1, step.Key), WorkflowID: workflow.ID, ServerInstanceID: serverID, StepKey: step.Key, DependsOn: domain.CopyStringSlice(step.DependsOn), Status: domain.SCUMWorkflowStepQueued, OperationKey: step.OperationKey, QueryTemplateKey: step.QueryTemplateKey, Capability: step.Capability, TargetKey: step.TargetKey, MaxAttempts: maxAttempts, MutatesState: step.MutatesState, SafeSummary: domain.SCUMSafeSummary{Title: step.Key, Message: step.Summary}, CreatedAt: stamp, UpdatedAt: stamp} if err := svc.store.SCUMWorkflowSteps().Create(record); err != nil { return domain.SCUMWorkflowInstance{}, err } } _, err = svc.recordAuditEventWithID(user.ID, "scum.workflow.create", "scum-workflow", workflow.ID, domain.AuditResultQueued, "typed SCUM workflow queued") return domain.CopySCUMWorkflowInstance(workflow), err } func (svc *CoreService) ListSCUMWorkflowsForSession(sessionID string, filter domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMWorkflowInstances().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) ListSCUMWorkflowStepsForSession(sessionID string, filter domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMWorkflowSteps().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) DispatchNextSCUMWorkflowSteps(serverID string, limit int) ([]domain.SCUMWorkflowStep, error) { if limit <= 0 { limit = 1 } workflows, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID}) if err != nil { return nil, err } sort.SliceStable(workflows, func(i, j int) bool { if workflows[i].CreatedAt.Equal(workflows[j].CreatedAt) { return workflows[i].IdempotencyKey < workflows[j].IdempotencyKey } return workflows[i].CreatedAt.Before(workflows[j].CreatedAt) }) dispatched := []domain.SCUMWorkflowStep{} activeMutating, err := svc.hasActiveSCUMMutatingStep(serverID) if err != nil { return nil, err } for _, workflow := range workflows { if !scumWorkflowRunnable(workflow.Status) || len(dispatched) >= limit { continue } steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID) if err != nil { return nil, err } for _, step := range steps { if len(dispatched) >= limit || !scumWorkflowStepRunnable(step.Status) || !scumWorkflowDependenciesConfirmed(step, steps) { continue } if step.MutatesState && activeMutating { return dispatched, nil } if blocked, err := svc.blockSCUMStepIfRunUnavailable(workflow, step); err != nil || blocked.ID != "" { if err != nil { return nil, err } dispatched = append(dispatched, blocked) return dispatched, nil } step.Status = domain.SCUMWorkflowStepRunning step.Attempt++ step.UpdatedAt = svc.now() if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { return nil, err } workflow.Status = domain.SCUMWorkflowRunning workflow.CurrentStepKey = step.StepKey workflow.UpdatedAt = step.UpdatedAt if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { return nil, err } dispatched = append(dispatched, domain.CopySCUMWorkflowStep(step)) if step.MutatesState { activeMutating = true return dispatched, nil } } } return dispatched, nil } func (svc *CoreService) CompleteSCUMWorkflowStep(stepID string, status domain.SCUMWorkflowStepStatus, confirmation domain.SCUMOperationConfirmation) (domain.SCUMWorkflowInstance, error) { step, err := svc.store.SCUMWorkflowSteps().Get(stepID) if err != nil { return domain.SCUMWorkflowInstance{}, err } workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID) if err != nil { return domain.SCUMWorkflowInstance{}, err } if !scumWorkflowStepTerminal(status) { return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow step completion status must be terminal") } stamp := svc.now() step.Status = status step.Confirmation = domain.CopySCUMOperationConfirmation(confirmation) step.CompletedAt = stamp step.UpdatedAt = stamp if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { return domain.SCUMWorkflowInstance{}, err } return svc.refreshSCUMWorkflowStatus(workflow) } func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflowStep, error) { step, err := svc.store.SCUMWorkflowSteps().Get(stepID) if err != nil { return domain.SCUMWorkflowStep{}, err } workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID) if err != nil { return domain.SCUMWorkflowStep{}, err } if step.Attempt >= step.MaxAttempts { return domain.SCUMWorkflowStep{}, validationError("SCUM workflow step retry limit reached") } if step.MutatesState && step.Status == domain.SCUMWorkflowStepUnknown && step.Confirmation.Status != "confirmed" { step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; confirmation/readback must complete before retry to avoid duplicate effects."} step.UpdatedAt = svc.now() if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { return domain.SCUMWorkflowStep{}, err } return domain.CopySCUMWorkflowStep(step), nil } step.Status = domain.SCUMWorkflowStepQueued step.Confirmation = domain.SCUMOperationConfirmation{} step.CompletedAt = time.Time{} step.UpdatedAt = svc.now() if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { return domain.SCUMWorkflowStep{}, err } workflow.Status = domain.SCUMWorkflowQueued workflow.BlockerReason = "" workflow.UpdatedAt = step.UpdatedAt if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { return domain.SCUMWorkflowStep{}, err } return domain.CopySCUMWorkflowStep(step), nil } func (svc *CoreService) sortedSCUMWorkflowSteps(workflowID string) ([]domain.SCUMWorkflowStep, error) { steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{WorkflowID: workflowID}) if err != nil { return nil, err } sort.SliceStable(steps, func(i, j int) bool { if steps[i].CreatedAt.Equal(steps[j].CreatedAt) { return steps[i].ID < steps[j].ID } return steps[i].CreatedAt.Before(steps[j].CreatedAt) }) return steps, nil } func (svc *CoreService) hasActiveSCUMMutatingStep(serverID string) (bool, error) { mutates := true for _, status := range []domain.SCUMWorkflowStepStatus{domain.SCUMWorkflowStepRunning, domain.SCUMWorkflowStepConfirming} { steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, Status: status, MutatesState: &mutates}) if err != nil { return false, err } if len(steps) > 0 { return true, nil } } return false, nil } func (svc *CoreService) blockSCUMStepIfRunUnavailable(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep) (domain.SCUMWorkflowStep, error) { if strings.TrimSpace(step.Capability) == "" { return domain.SCUMWorkflowStep{}, nil } instance, err := svc.store.ServerInstances().Get(workflow.ServerInstanceID) if err != nil { return domain.SCUMWorkflowStep{}, err } endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) if err != nil { if err == repo.ErrNotFound { return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "No bound run endpoint is available for this typed SCUM workflow step.") } return domain.SCUMWorkflowStep{}, err } if err := svc.validateRunnableEndpoint(endpoint, step.Capability); err != nil { return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "Bound run cannot currently claim the declared workflow capability.") } return domain.SCUMWorkflowStep{}, nil } func (svc *CoreService) blockSCUMWorkflowStep(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep, title string, message string) (domain.SCUMWorkflowStep, error) { stamp := svc.now() step.Status = domain.SCUMWorkflowStepBlocked step.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"stepKey": step.StepKey, "capability": step.Capability}} step.UpdatedAt = stamp workflow.Status = domain.SCUMWorkflowBlocked workflow.CurrentStepKey = step.StepKey workflow.BlockerReason = title workflow.SafeSummary = step.SafeSummary workflow.UpdatedAt = stamp if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil { return domain.SCUMWorkflowStep{}, err } if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { return domain.SCUMWorkflowStep{}, err } return domain.CopySCUMWorkflowStep(step), nil } func (svc *CoreService) refreshSCUMWorkflowStatus(workflow domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) { steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID) if err != nil { return domain.SCUMWorkflowInstance{}, err } allConfirmed := len(steps) > 0 stamp := svc.now() for _, step := range steps { switch step.Status { case domain.SCUMWorkflowStepFailed: workflow.Status = domain.SCUMWorkflowFailed case domain.SCUMWorkflowStepUnknown: workflow.Status = domain.SCUMWorkflowUnknown case domain.SCUMWorkflowStepCancelled: workflow.Status = domain.SCUMWorkflowCancelled case domain.SCUMWorkflowStepConfirmed: default: allConfirmed = false } if workflow.Status == domain.SCUMWorkflowFailed || workflow.Status == domain.SCUMWorkflowUnknown || workflow.Status == domain.SCUMWorkflowCancelled { workflow.CurrentStepKey = step.StepKey workflow.CompletedAt = stamp workflow.UpdatedAt = stamp return domain.CopySCUMWorkflowInstance(workflow), svc.store.SCUMWorkflowInstances().Update(workflow) } } if allConfirmed { workflow.Status = domain.SCUMWorkflowConfirmed workflow.CurrentStepKey = "" workflow.CompletedAt = stamp } else { workflow.Status = domain.SCUMWorkflowQueued workflow.CurrentStepKey = "" } workflow.UpdatedAt = stamp if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil { return domain.SCUMWorkflowInstance{}, err } return domain.CopySCUMWorkflowInstance(workflow), nil } func scumWorkflowDependenciesConfirmed(step domain.SCUMWorkflowStep, steps []domain.SCUMWorkflowStep) bool { if len(step.DependsOn) == 0 { return true } statuses := map[string]domain.SCUMWorkflowStepStatus{} for _, candidate := range steps { statuses[candidate.StepKey] = candidate.Status } for _, dependency := range step.DependsOn { if statuses[dependency] != domain.SCUMWorkflowStepConfirmed { return false } } return true } func scumWorkflowRunnable(status domain.SCUMWorkflowStatus) bool { switch status { case domain.SCUMWorkflowQueued, domain.SCUMWorkflowRunning, domain.SCUMWorkflowWaiting: return true default: return false } } func scumWorkflowStepRunnable(status domain.SCUMWorkflowStepStatus) bool { switch status { case domain.SCUMWorkflowStepQueued, domain.SCUMWorkflowStepWaiting: return true default: return false } } func scumWorkflowStepTerminal(status domain.SCUMWorkflowStepStatus) bool { switch status { case domain.SCUMWorkflowStepConfirmed, domain.SCUMWorkflowStepFailed, domain.SCUMWorkflowStepUnknown, domain.SCUMWorkflowStepCancelled: return true default: return false } } func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition { read := domain.JobCapabilityRemoteRunDBSQLiteQuery logs := domain.JobCapabilityRemoteRunLogsTransfer protectedSQL := domain.JobCapabilityRemoteRunProtectedSQL rcon := domain.JobCapabilityRemoteRunRCONCommand return map[string]scumWorkflowTemplateDefinition{ "scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before local sync."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log cursor."}}}, "scum.player-refresh": {Key: "scum.player-refresh", Title: "Refresh SCUM player", Steps: []scumWorkflowStepDefinition{{Key: "login-evidence", Capability: logs, TargetKey: "scum-login", Summary: "Sync login/logout evidence."}, {Key: "player-profile", DependsOn: []string{"login-evidence"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Read player profile/economy facts."}, {Key: "position-read", DependsOn: []string{"player-profile"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Read current player coordinates."}}}, "scum.world-refresh": {Key: "scum.world-refresh", Title: "Refresh SCUM world", Steps: []scumWorkflowStepDefinition{{Key: "squad-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squads", MaxAttempts: 2, Summary: "Refresh squads."}, {Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", MaxAttempts: 2, Summary: "Refresh vehicles."}, {Key: "flag-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", MaxAttempts: 2, Summary: "Refresh flags."}, {Key: "position-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", MaxAttempts: 2, Summary: "Refresh map positions."}}}, "scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current local data, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved declared action through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}}, "scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through the declared action."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}}, "scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory check", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Compare owner/member consistency signals."}}}, "scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle check", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}}, "scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and player draft inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable config diff or named-field draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human confirmation through declared paths."}}}, "scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe local status surfaces."}}}, } }