Remove AI config approval flow

This commit is contained in:
npc0-hue
2026-09-22 15:33:32 +08:00
parent 20008b4043
commit a8b5d483a3
54 changed files with 364 additions and 875 deletions
+22 -10
View File
@@ -3,7 +3,6 @@ package service
import (
"errors"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
@@ -24,7 +23,7 @@ func (MockAIProviderClient) Invoke(provider domain.AIProvider, request domain.AI
if model == "" && len(provider.Models) > 0 {
model = provider.Models[0]
}
recommendation := "Mock AI recommendation for " + request.Purpose + ": review the proposed change before dispatch."
recommendation := "Mock AI recommendation for " + request.Purpose + ": configuration changes are dispatched automatically."
result := domain.AIProviderInvocationResult{
Recommendation: recommendation,
Usage: domain.AIInvocationUsage{
@@ -46,10 +45,12 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
if err := validator.ValidateAIInvocationRequest(request); err != nil {
return domain.AIInvocationResponse{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
if _, err := svc.GetCurrentUser(sessionID); err != nil {
return domain.AIInvocationResponse{}, err
}
var currentConfig domain.ServerConfig
var hasCurrentConfig bool
var err error
if request.ServerInstanceID != "" {
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
if err != nil {
@@ -63,6 +64,8 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
if err != nil {
return domain.AIInvocationResponse{}, err
}
currentConfig = config
hasCurrentConfig = true
request.CurrentConfig = config.Content
}
}
@@ -111,16 +114,25 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
Usage: result.Usage,
}
if result.SuggestedConfig != "" {
if request.ServerInstanceID == "" {
if request.ServerInstanceID == "" || !hasCurrentConfig {
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
}
idempotencyKey := aiConfigWriteIdempotencyKey(request.RequestID, request.ServerInstanceID)
svc.pluginOperationsMu.Lock()
preview, persistErr := svc.persistAIConfigDiff(user.ID, provider, request, result)
dispatch, dispatchErr := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{
ServerInstanceID: request.ServerInstanceID,
ExpectedConfigVersion: currentConfig.ConfigVersion,
ExpectedChecksum: currentConfig.Checksum,
Key: currentConfig.Key,
ProposedContent: result.SuggestedConfig,
IdempotencyKey: idempotencyKey,
})
svc.pluginOperationsMu.Unlock()
if persistErr != nil {
return domain.AIInvocationResponse{}, persistErr
if dispatchErr != nil {
return domain.AIInvocationResponse{}, dispatchErr
}
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: preview.Key, SuggestedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, DiffID: preview.ID, ExpiresAt: preview.ExpiresAt.Format(time.RFC3339)}
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: currentConfig.Key, SuggestedConfig: result.SuggestedConfig, DiffSummary: "AI 配置建议已直接派发 config.write 任务"}
response.ConfigExecution = &domain.AIConfigExecution{Status: dispatch.Status, Job: dispatch.Job}
}
if err := validator.ValidateAIInvocationResponse(response); err != nil {
return domain.AIInvocationResponse{}, err
@@ -179,7 +191,7 @@ func buildSuggestedConfig(currentConfig string, prompt string) string {
if strings.Contains(strings.ToLower(prompt), "pvp") && !strings.Contains(base, "pvp=") {
base += "\npvp=false"
}
return base + "\n# ai.recommendation=review-required\n"
return base + "\n# ai.recommendation=auto-applied\n"
}
func boundedTokenEstimate(value string) int {
+1 -1
View File
@@ -283,7 +283,7 @@ func boundedProviderPrompt(request domain.AIInvocationRequest) string {
builder.WriteString(request.CurrentConfig)
}
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes require separate operator approval.")
builder.WriteString("\nReturn JSON with recommendation and suggestedConfig. Configuration changes are dispatched automatically as a bounded config.write job.")
}
return builder.String()
}
+3 -142
View File
@@ -13,10 +13,6 @@ import (
"browser.local/platform/validator"
)
const (
aiConfigDiffTTL = 30 * time.Minute
)
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
@@ -128,84 +124,6 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d
return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Status: "queued"}), nil
}
func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return nil, err
}
previews, err := svc.store.AIConfigDiffs().List(filter)
if err != nil {
return nil, err
}
visible := make([]domain.AIConfigDiffPreview, 0, len(previews))
for _, preview := range previews {
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
if err == nil && canAccessServer(user, instance) {
visible = append(visible, preview)
}
}
sort.Slice(visible, func(i, j int) bool { return visible[i].CreatedAt.After(visible[j].CreatedAt) })
return domain.CopyAIConfigDiffPreviews(visible), nil
}
func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error) {
if err := validator.ValidateAIConfigDiffApprovalRequest(request); err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
svc.pluginOperationsMu.Lock()
defer svc.pluginOperationsMu.Unlock()
preview, err := svc.store.AIConfigDiffs().Get(request.DiffID)
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID)
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
if !canAccessServer(user, instance) || (!isPlatformAdmin(user) && preview.CreatedBy != user.ID) {
return domain.AIConfigDiffApprovalResult{}, ErrForbidden
}
if preview.State == domain.AIConfigDiffStateApproved {
if preview.ApprovalIdempotencyKey != request.IdempotencyKey {
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is already approved with another idempotency key")
}
job, err := svc.store.Jobs().Get(preview.JobID)
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
dispatch := domain.ServerConfigWriteDispatch{Job: job, Status: "queued"}
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
}
if preview.State != domain.AIConfigDiffStatePending {
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff is not pending approval")
}
if !preview.ExpiresAt.After(svc.now()) {
preview.State = domain.AIConfigDiffStateExpired
preview.UpdatedAt = svc.now()
_ = svc.store.AIConfigDiffs().Update(preview)
return domain.AIConfigDiffApprovalResult{}, validationError("AI config diff has expired")
}
dispatch, err := svc.ApproveServerConfigWriteForSession(sessionID, domain.ServerConfigWriteApproval{ServerInstanceID: preview.ServerInstanceID, ExpectedConfigVersion: preview.ConfigVersion, ExpectedChecksum: preview.CurrentConfigChecksum, Key: preview.Key, ProposedContent: preview.ProposedConfig, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
stamp := svc.now()
preview.State = domain.AIConfigDiffStateApproved
preview.ApprovedBy = user.ID
preview.ApprovedAt = stamp
preview.ApprovalIdempotencyKey = request.IdempotencyKey
preview.JobID = dispatch.Job.ID
preview.UpdatedAt = stamp
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
return domain.AIConfigDiffApprovalResult{}, err
}
return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil
}
func (svc *CoreService) projectPluginOperationsJobResult(job domain.Job, stamp time.Time) error {
if !strings.HasPrefix(job.ID, "job-plugin-lifecycle-") || job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
return nil
@@ -230,63 +148,6 @@ func (svc *CoreService) projectPluginOperationsJobResult(job domain.Job, stamp t
return svc.store.PluginLifecycles().Update(installation)
}
func (svc *CoreService) persistAIConfigDiff(actorID string, provider domain.AIProvider, request domain.AIInvocationRequest, result domain.AIProviderInvocationResult) (domain.AIConfigDiffPreview, error) {
config, err := svc.getServerConfigForUser(actorID, request.ServerInstanceID)
if err != nil {
return domain.AIConfigDiffPreview{}, err
}
stamp := svc.now()
preview := domain.AIConfigDiffPreview{ID: aiConfigDiffID(request.RequestID, request.ServerInstanceID), RequestID: request.RequestID, CreatedBy: actorID, ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, ProviderID: provider.ID, Model: result.Usage.Model, Key: config.Key, ConfigVersion: config.ConfigVersion, CurrentConfigChecksum: config.Checksum, ProposedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch", State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(aiConfigDiffTTL), CreatedAt: stamp, UpdatedAt: stamp}
if err := validator.ValidateAIConfigDiffPreview(preview); err != nil {
return domain.AIConfigDiffPreview{}, err
}
if err := svc.store.AIConfigDiffs().Create(preview); err != nil {
if !errors.Is(err, repo.ErrDuplicate) {
return domain.AIConfigDiffPreview{}, err
}
existing, getErr := svc.store.AIConfigDiffs().Get(preview.ID)
if getErr != nil {
return domain.AIConfigDiffPreview{}, getErr
}
if existing.CreatedBy != actorID || existing.ServerInstanceID != request.ServerInstanceID || existing.PluginID != request.PluginID || existing.ProposedConfig != result.SuggestedConfig {
return domain.AIConfigDiffPreview{}, validationError("requestId is already used for a different AI config recommendation")
}
return existing, nil
}
return preview, nil
}
func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string) (domain.ServerConfig, error) {
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return domain.ServerConfig{}, err
}
user, err := svc.store.Users().Get(userID)
if err != nil {
return domain.ServerConfig{}, err
}
if !canAccessServer(user, instance) {
return domain.ServerConfig{}, ErrForbidden
}
config := domain.ServerConfig{ServerInstanceID: instance.ID, ConfigVersion: instance.ConfigVersion, Format: "properties", Key: instance.ConfigKey, Content: instance.ConfigContent, Checksum: instance.ConfigChecksum, Source: "platform-derived", UpdatedAt: instance.ConfigUpdatedAt}
if config.ConfigVersion <= 0 {
config.ConfigVersion = 1
}
if config.Key == "" {
config.Key = "server.properties"
}
if config.Content == "" {
config.Content = buildLogicalServerConfig(instance)
}
if config.Checksum == "" {
config.Checksum = validator.BytesChecksum([]byte(config.Content))
}
if config.UpdatedAt.IsZero() {
config.UpdatedAt = svc.now()
}
return config, validator.ValidateServerConfig(config)
}
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
svc.pluginOperationsMu.Lock()
defer svc.pluginOperationsMu.Unlock()
@@ -436,7 +297,7 @@ func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string {
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
}
func aiConfigDiffID(requestID, serverInstanceID string) string {
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
return "ai-config-diff-" + hex.EncodeToString(sum[:12])
func aiConfigWriteIdempotencyKey(requestID, serverInstanceID string) string {
sum := sha256.Sum256([]byte("ai-config-write\x00" + requestID + "\x00" + serverInstanceID))
return "ai-config-write-" + hex.EncodeToString(sum[:16])
}
+11 -35
View File
@@ -72,7 +72,7 @@ func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) {
}
}
func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) {
func TestAIConfigRecommendationDispatchesConfigWriteImmediately(t *testing.T) {
svc, session, instance := newPluginOperationsFixture(t)
provider, err := svc.CreateAIProvider(domain.AIProvider{ID: "ai-local", Name: "Local AI", Kind: domain.AIProviderKindOllama, BaseURL: "http://127.0.0.1:11434/v1", Models: []string{"test-model"}, DefaultModel: "test-model", RelayMode: domain.AIRelayModeLocal, TimeoutMS: 1000, Status: domain.AIProviderStatusActive, RedactionPolicy: "strict"})
if err != nil {
@@ -82,43 +82,19 @@ func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testin
if err != nil {
t.Fatalf("invoke AI: %v", err)
}
if response.ConfigRecommendation == nil || response.ConfigRecommendation.DiffID == "" {
t.Fatalf("expected persisted config recommendation, got %+v", response)
if response.ConfigRecommendation == nil || response.ConfigExecution == nil || response.ConfigExecution.Job.ID == "" {
t.Fatalf("expected direct config write dispatch, got %+v", response)
}
if response.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite || response.ConfigExecution.Status != "queued" {
t.Fatalf("unexpected AI config execution: %+v", response.ConfigExecution)
}
jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if len(jobs) != 0 {
t.Fatalf("AI recommendation must not dispatch before approval: %+v", jobs)
if len(jobs) != 1 || jobs[0].ID != response.ConfigExecution.Job.ID {
t.Fatalf("AI recommendation must dispatch exactly one config write job: %+v", jobs)
}
approved, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
if err != nil {
t.Fatalf("approve AI diff: %v", err)
}
if approved.Preview.State != domain.AIConfigDiffStateApproved || approved.Dispatch.Job.ID == "" {
t.Fatalf("expected approved diff and queued job, got %+v", approved)
}
repeated, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: response.ConfigRecommendation.DiffID, IdempotencyKey: "approve-ai-config-1"})
if err != nil || repeated.Dispatch.Job.ID != approved.Dispatch.Job.ID {
t.Fatalf("repeat approval must return original job: %+v err=%v", repeated, err)
}
jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if len(jobs) != 1 {
t.Fatalf("approval must dispatch exactly one job, got %+v", jobs)
}
staleResponse, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-stale", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
if err != nil {
t.Fatalf("invoke stale AI candidate: %v", err)
}
stored, err := svc.store.ServerInstances().Get(instance.ID)
if err != nil {
t.Fatalf("get server: %v", err)
}
stored.ConfigVersion++
if err := svc.store.ServerInstances().Update(stored); err != nil {
t.Fatalf("advance config revision: %v", err)
}
if _, err := svc.ApproveAIConfigDiffForSession(session, domain.AIConfigDiffApprovalRequest{DiffID: staleResponse.ConfigRecommendation.DiffID, IdempotencyKey: "approve-stale"}); err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
t.Fatalf("expected stale revision rejection, got %v", err)
repeated, err := svc.InvokeAIForSession(session, domain.AIInvocationRequest{RequestID: "ai-config-1", ServerInstanceID: instance.ID, ProviderID: provider.ID, Purpose: "config.suggest", Prompt: "disable pvp"})
if err != nil || repeated.ConfigExecution == nil || repeated.ConfigExecution.Job.ID != response.ConfigExecution.Job.ID {
t.Fatalf("repeat AI request must reuse the idempotent job: %+v err=%v", repeated, err)
}
}
+7 -15
View File
@@ -116,8 +116,6 @@ type Core interface {
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error)
ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
ApproveAIConfigDiffForSession(string, domain.AIConfigDiffApprovalRequest) (domain.AIConfigDiffApprovalResult, error)
IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error)
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
@@ -912,17 +910,6 @@ func (svc *CoreService) replaceGamePluginReference(fromPluginID, toPluginID, toP
return err
}
}
previews, err := svc.store.AIConfigDiffs().List(domain.AIConfigDiffFilter{PluginID: fromPluginID})
if err != nil {
return err
}
for _, preview := range previews {
preview.PluginID = toPluginID
preview.UpdatedAt = stamp
if err := svc.store.AIConfigDiffs().Update(preview); err != nil {
return err
}
}
return svc.replacePluginDataReferences(fromPluginID, toPluginID)
}
@@ -1215,9 +1202,14 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
if response.ConfigRecommendation != nil {
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
base.Result["diffId"] = response.ConfigRecommendation.DiffID
base.Result["key"] = response.ConfigRecommendation.Key
base.Result["expiresAt"] = response.ConfigRecommendation.ExpiresAt
}
if response.ConfigExecution != nil {
base.Result["executionStatus"] = response.ConfigExecution.Status
base.Result["jobId"] = response.ConfigExecution.Job.ID
base.Result["capability"] = response.ConfigExecution.Job.Capability
base.Result["targetKey"] = response.ConfigExecution.Job.TargetKey
base.Result["inputRef"] = response.ConfigExecution.Job.InputRef
}
if response.Error != nil {
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
+1 -1
View File
@@ -2448,7 +2448,7 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
},
},
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform"},
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
},