Remove AI config approval flow
This commit is contained in:
@@ -25,7 +25,8 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeMethodNotAllowed(w, http.MethodGet)
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
instance, streams, subscription, err := h.openLogEventSubscription(r)
|
jobID := strings.TrimSpace(r.URL.Query().Get("jobId"))
|
||||||
|
instance, streams, subscription, err := h.openLogEventSubscription(r, jobID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(w, err)
|
writeServiceError(w, err)
|
||||||
return
|
return
|
||||||
@@ -45,7 +46,9 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
active := supervisedLogSession{}
|
active := supervisedLogSession{}
|
||||||
if isComponentLogRequest(r) {
|
if jobID != "" {
|
||||||
|
active = activeJobLogSession(jobID, streams)
|
||||||
|
} else if isComponentLogRequest(r) {
|
||||||
active = activeComponentLogSession(streams)
|
active = activeComponentLogSession(streams)
|
||||||
} else {
|
} else {
|
||||||
active = activeSupervisedLogSession(streams)
|
active = activeSupervisedLogSession(streams)
|
||||||
@@ -72,6 +75,9 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if subscriptionEvent.Kind == service.LogEventSubscriptionEventProcessState {
|
if subscriptionEvent.Kind == service.LogEventSubscriptionEventProcessState {
|
||||||
|
if jobID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if isComponentLogRequest(r) {
|
if isComponentLogRequest(r) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -90,7 +96,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r), jobID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -111,10 +117,16 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
event := subscriptionEvent.LogEvent
|
event := subscriptionEvent.LogEvent
|
||||||
candidate := activeSupervisedLogSession([]domain.LogStream{event.Stream})
|
candidate := activeSupervisedLogSession([]domain.LogStream{event.Stream})
|
||||||
|
if jobID != "" {
|
||||||
|
if !strings.HasPrefix(event.Stream.ID, "job."+jobID+".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate = activeJobLogSession(jobID, []domain.LogStream{event.Stream})
|
||||||
|
}
|
||||||
if active.allStreams {
|
if active.allStreams {
|
||||||
candidate = supervisedLogSession{}
|
candidate = supervisedLogSession{}
|
||||||
}
|
}
|
||||||
if candidate.sessionID != "" && newerLogSession(candidate, active) {
|
if jobID == "" && candidate.sessionID != "" && newerLogSession(candidate, active) {
|
||||||
next := candidate
|
next := candidate
|
||||||
if !sameSupervisedLogSession(active, next) {
|
if !sameSupervisedLogSession(active, next) {
|
||||||
active = next
|
active = next
|
||||||
@@ -129,9 +141,13 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !active.contains(event.Stream) {
|
if !active.contains(event.Stream) {
|
||||||
|
if jobID == "" {
|
||||||
if !active.allStreams || event.Stream.ServerInstanceID != instance.ID {
|
if !active.allStreams || event.Stream.ServerInstanceID != instance.ID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
} else if !strings.HasPrefix(event.Stream.ID, "job."+jobID+".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
active.streams = append(active.streams, event.Stream)
|
active.streams = append(active.streams, event.Stream)
|
||||||
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil {
|
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil {
|
||||||
return
|
return
|
||||||
@@ -161,6 +177,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type supervisedLogSession struct {
|
type supervisedLogSession struct {
|
||||||
|
jobID string
|
||||||
sessionID string
|
sessionID string
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
streams []domain.LogStream
|
streams []domain.LogStream
|
||||||
@@ -193,6 +210,11 @@ func activeSupervisedLogSession(streams []domain.LogStream) supervisedLogSession
|
|||||||
return active
|
return active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func activeJobLogSession(jobID string, streams []domain.LogStream) supervisedLogSession {
|
||||||
|
active := supervisedLogSession{jobID: jobID, sessionID: "job:" + jobID, streams: append([]domain.LogStream(nil), streams...)}
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
func newerLogSession(candidate supervisedLogSession, current supervisedLogSession) bool {
|
func newerLogSession(candidate supervisedLogSession, current supervisedLogSession) bool {
|
||||||
if candidate.sessionID == "" || candidate.sessionID == current.sessionID {
|
if candidate.sessionID == "" || candidate.sessionID == current.sessionID {
|
||||||
return false
|
return false
|
||||||
@@ -207,10 +229,13 @@ func newerLogSession(candidate supervisedLogSession, current supervisedLogSessio
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sameSupervisedLogSession(left supervisedLogSession, right supervisedLogSession) bool {
|
func sameSupervisedLogSession(left supervisedLogSession, right supervisedLogSession) bool {
|
||||||
return left.sessionID == right.sessionID && left.startedAt.Equal(right.startedAt)
|
return left.jobID == right.jobID && left.sessionID == right.sessionID && left.startedAt.Equal(right.startedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (session supervisedLogSession) contains(stream domain.LogStream) bool {
|
func (session supervisedLogSession) contains(stream domain.LogStream) bool {
|
||||||
|
if session.jobID != "" {
|
||||||
|
return strings.HasPrefix(stream.ID, "job."+session.jobID+".") && session.hasStream(stream.ID)
|
||||||
|
}
|
||||||
if session.allStreams {
|
if session.allStreams {
|
||||||
return session.hasStream(stream.ID)
|
return session.hasStream(stream.ID)
|
||||||
}
|
}
|
||||||
@@ -240,7 +265,7 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
|
|||||||
return emittedThrough, nil
|
return emittedThrough, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
|
func (h *coreHandlers) openLogEventSubscription(r *http.Request, jobID string) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
|
||||||
var instance domain.ServerInstance
|
var instance domain.ServerInstance
|
||||||
var streams []domain.LogStream
|
var streams []domain.LogStream
|
||||||
var subscription service.LogEventSubscription
|
var subscription service.LogEventSubscription
|
||||||
@@ -270,8 +295,23 @@ func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerI
|
|||||||
return domain.ServerInstance{}, nil, subscription, err
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err == nil && jobID != "" {
|
||||||
|
if h.enforceAuthorization && isComponentLogRequest(r) {
|
||||||
|
err = fmt.Errorf("job log streams require session authorization")
|
||||||
|
} else {
|
||||||
|
var job domain.Job
|
||||||
|
if h.enforceAuthorization {
|
||||||
|
job, err = h.core.GetJobForSession(bearerToken(r), jobID)
|
||||||
|
} else {
|
||||||
|
job, err = h.core.GetJob(jobID)
|
||||||
|
}
|
||||||
|
if err == nil && job.ServerInstanceID != instance.ID {
|
||||||
|
err = service.ErrForbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r))
|
streams, err = h.loadLiveLogSnapshot(instance.ID, isComponentLogRequest(r), jobID)
|
||||||
}
|
}
|
||||||
if err != nil && subscription.Close != nil {
|
if err != nil && subscription.Close != nil {
|
||||||
subscription.Close()
|
subscription.Close()
|
||||||
@@ -288,11 +328,12 @@ func isComponentLogRequest(r *http.Request) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDeclaredStreams bool) ([]domain.LogStream, error) {
|
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDeclaredStreams bool, jobID string) ([]domain.LogStream, error) {
|
||||||
instance, err := h.core.GetServerInstance(serverInstanceID)
|
instance, err := h.core.GetServerInstance(serverInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if jobID == "" {
|
||||||
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
if strings.TrimSpace(instance.RunEndpointID) == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -306,12 +347,19 @@ func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string, includeDecla
|
|||||||
if endpoint.Status != domain.RunEndpointStatusOnline {
|
if endpoint.Status != domain.RunEndpointStatusOnline {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
current := make([]domain.LogStream, 0, len(streams))
|
current := make([]domain.LogStream, 0, len(streams))
|
||||||
for _, stream := range streams {
|
for _, stream := range streams {
|
||||||
|
if jobID != "" {
|
||||||
|
if strings.HasPrefix(stream.ID, "job."+jobID+".") {
|
||||||
|
current = append(current, stream)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if includeDeclaredStreams {
|
if includeDeclaredStreams {
|
||||||
if stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram {
|
if stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -189,6 +189,56 @@ func TestLogEventsSSEStreamsBufferedAppendAfterOpen(t *testing.T) {
|
|||||||
assertSSEEvent(t, reader, "log", `"seq":3`)
|
assertSSEEvent(t, reader, "log", `"seq":3`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLogEventsSSEStreamsScopedJobLogsWhenServerIsStopped(t *testing.T) {
|
||||||
|
router := newTestRouter()
|
||||||
|
hello := createLogIngestAPIFixtures(t, router)
|
||||||
|
job := postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
|
||||||
|
ID: "job-scoped-log", ServerInstanceID: "server-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-scoped-log",
|
||||||
|
})
|
||||||
|
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{
|
||||||
|
RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded,
|
||||||
|
Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"},
|
||||||
|
}), http.StatusOK)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?jobId="+job.ID, nil).WithContext(ctx)
|
||||||
|
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
router.ServeHTTP(streamWriter, request)
|
||||||
|
_ = streamWriter.Close()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
_ = streamReader.Close()
|
||||||
|
<-done
|
||||||
|
})
|
||||||
|
if status := <-streamWriter.status; status != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected scoped job SSE status: %d", status)
|
||||||
|
}
|
||||||
|
reader := bufio.NewReader(streamReader)
|
||||||
|
assertSSEEvent(t, reader, "session", `"logSessionId":"job:`+job.ID+`"`)
|
||||||
|
streamOne, streamOneData := readSSEEvent(t, reader)
|
||||||
|
streamTwo, streamTwoData := readSSEEvent(t, reader)
|
||||||
|
if streamOne != "stream" || streamTwo != "stream" || !strings.Contains(streamOneData, `"id":"job.`+job.ID+`.stdout"`) && !strings.Contains(streamTwoData, `"id":"job.`+job.ID+`.stdout"`) || !strings.Contains(streamOneData, `"id":"job.`+job.ID+`.stderr"`) && !strings.Contains(streamTwoData, `"id":"job.`+job.ID+`.stderr"`) {
|
||||||
|
t.Fatalf("unexpected scoped job stream snapshot: %q %s; %q %s", streamOne, streamOneData, streamTwo, streamTwoData)
|
||||||
|
}
|
||||||
|
assertSSEEvent(t, reader, "ready", `"streamCount":2`)
|
||||||
|
|
||||||
|
batch := validLogBatchRequestForStream(t, hello.SessionToken, "job."+job.ID+".stdout", "stdout", 1, 1, 0)
|
||||||
|
batch.LogSessionID = ""
|
||||||
|
batch.SessionStartedAt = time.Time{}
|
||||||
|
batch.Entries[0].Line = "JOB-SCOPED-LIVE-OUTPUT"
|
||||||
|
checksum, err := validator.LogEntriesChecksum([]domain.LogEntry{{Seq: batch.Entries[0].Seq, Timestamp: batch.Entries[0].Timestamp, Level: batch.Entries[0].Level, Line: batch.Entries[0].Line}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checksum scoped job log: %v", err)
|
||||||
|
}
|
||||||
|
batch.Checksum = checksum
|
||||||
|
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
|
||||||
|
assertSSEEvent(t, reader, "log", `"line":"JOB-SCOPED-LIVE-OUTPUT"`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
|
func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
|
||||||
router := newTestRouter()
|
router := newTestRouter()
|
||||||
hello := createLogIngestAPIFixtures(t, router)
|
hello := createLogIngestAPIFixtures(t, router)
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics)
|
||||||
mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles)
|
mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles)
|
||||||
mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction)
|
mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction)
|
||||||
mux.HandleFunc("/api/v1/ai/config-diffs", h.aiConfigDiffs)
|
|
||||||
mux.HandleFunc("/api/v1/ai/config-diffs/{id}/approve", h.aiConfigDiffApprove)
|
|
||||||
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
||||||
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
||||||
mux.HandleFunc("/api/v1/backups", h.backups)
|
mux.HandleFunc("/api/v1/backups", h.backups)
|
||||||
@@ -201,60 +199,6 @@ func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Requ
|
|||||||
writeJSON(w, http.StatusAccepted, dto.PluginLifecycleResultFromDomain(result))
|
writeJSON(w, http.StatusAccepted, dto.PluginLifecycleResultFromDomain(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
// aiConfigDiffs godoc
|
|
||||||
// @Summary List reviewable AI config diffs
|
|
||||||
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
|
|
||||||
// @Tags plugin-operations
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} dto.AIConfigDiffListResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/ai/config-diffs [get]
|
|
||||||
func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
writeMethodNotAllowed(w, http.MethodGet)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := h.core.ListAIConfigDiffsForSession(bearerToken(r), domain.AIConfigDiffFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), PluginID: r.URL.Query().Get("pluginId"), State: domain.AIConfigDiffState(r.URL.Query().Get("state"))})
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, dto.AIConfigDiffListFromDomain(items))
|
|
||||||
}
|
|
||||||
|
|
||||||
// aiConfigDiffApprove godoc
|
|
||||||
// @Summary Approve one reviewable AI config diff
|
|
||||||
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
|
|
||||||
// @Tags plugin-operations
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "AI config diff ID"
|
|
||||||
// @Param body body dto.AIConfigDiffApprovalRequest true "AI config diff approval"
|
|
||||||
// @Success 202 {object} dto.AIConfigDiffApprovalResponse
|
|
||||||
// @Failure 400 {object} dto.ErrorResponse
|
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
|
||||||
// @Failure 403 {object} dto.ErrorResponse
|
|
||||||
// @Failure 405 {object} dto.ErrorResponse
|
|
||||||
// @Router /api/v1/ai/config-diffs/{id}/approve [post]
|
|
||||||
func (h *coreHandlers) aiConfigDiffApprove(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
writeMethodNotAllowed(w, http.MethodPost)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request, err := decodeJSON[dto.AIConfigDiffApprovalRequest](r)
|
|
||||||
if err != nil {
|
|
||||||
writeDecodeError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := h.core.ApproveAIConfigDiffForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
|
||||||
if err != nil {
|
|
||||||
writeServiceError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusAccepted, dto.AIConfigDiffApprovalFromDomain(result))
|
|
||||||
}
|
|
||||||
|
|
||||||
// authRegister godoc
|
// authRegister godoc
|
||||||
// @Summary Register a platform account
|
// @Summary Register a platform account
|
||||||
// @Description Creates a pending low-privilege platform account without granting platform administrator rights.
|
// @Description Creates a pending low-privilege platform account without granting platform administrator rights.
|
||||||
@@ -865,7 +809,7 @@ func (h *coreHandlers) aiConfigSuggestion(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
response, err := h.core.InvokeAIForSession(bearerToken(r), domain.AIInvocationRequest{
|
response, err := h.core.InvokeAIForSession(bearerToken(r), domain.AIInvocationRequest{
|
||||||
RequestID: "config-suggestion:" + request.ServerInstanceID,
|
RequestID: fmt.Sprintf("config-suggestion:%s:%d", request.ServerInstanceID, time.Now().UnixNano()),
|
||||||
ServerInstanceID: request.ServerInstanceID,
|
ServerInstanceID: request.ServerInstanceID,
|
||||||
Purpose: "config.suggest",
|
Purpose: "config.suggest",
|
||||||
Prompt: request.Prompt,
|
Prompt: request.Prompt,
|
||||||
@@ -888,7 +832,11 @@ func (h *coreHandlers) aiConfigSuggestion(w http.ResponseWriter, r *http.Request
|
|||||||
if response.ConfigRecommendation != nil {
|
if response.ConfigRecommendation != nil {
|
||||||
suggested = response.ConfigRecommendation.SuggestedConfig
|
suggested = response.ConfigRecommendation.SuggestedConfig
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested})
|
var execution *dto.AIConfigExecutionResponse
|
||||||
|
if response.ConfigExecution != nil {
|
||||||
|
execution = &dto.AIConfigExecutionResponse{Status: response.ConfigExecution.Status, Job: dto.JobFromDomain(response.ConfigExecution.Job)}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, dto.LlmConfigSuggestionResponse{ServerInstanceID: request.ServerInstanceID, Recommendation: response.Recommendation, SuggestedConfig: suggested, ConfigExecution: execution})
|
||||||
}
|
}
|
||||||
|
|
||||||
// gamePlugins godoc
|
// gamePlugins godoc
|
||||||
|
|||||||
@@ -1072,6 +1072,7 @@ func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
|
|||||||
adminSession := createAdminSession(t, router)
|
adminSession := createAdminSession(t, router)
|
||||||
createAIProviderFixture(t, router, adminSession)
|
createAIProviderFixture(t, router, adminSession)
|
||||||
registration := validGamePluginManifestRegistrationRequest()
|
registration := validGamePluginManifestRegistrationRequest()
|
||||||
|
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityConfigWrite)
|
||||||
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"}
|
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"}
|
||||||
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)}
|
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)}
|
||||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
|
||||||
@@ -1117,19 +1118,20 @@ func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
|
|||||||
}, adminSession)
|
}, adminSession)
|
||||||
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
|
assertErrorResponse(t, unsafe, http.StatusBadRequest, errorCodeValidation)
|
||||||
|
|
||||||
|
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
||||||
configRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-suggestions", dto.LlmConfigSuggestionRequest{
|
configRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-suggestions", dto.LlmConfigSuggestionRequest{
|
||||||
ServerInstanceID: instance.ID,
|
ServerInstanceID: instance.ID,
|
||||||
Prompt: "Turn off pvp and keep this reviewable",
|
Prompt: "Turn off pvp and apply it now",
|
||||||
CurrentConfig: "server.name=AI API Server\n",
|
CurrentConfig: "server.name=AI API Server\n",
|
||||||
}, adminSession)
|
}, adminSession)
|
||||||
assertStatus(t, configRecorder, http.StatusOK)
|
assertStatus(t, configRecorder, http.StatusOK)
|
||||||
config := decodeBody[dto.LlmConfigSuggestionResponse](t, configRecorder)
|
config := decodeBody[dto.LlmConfigSuggestionResponse](t, configRecorder)
|
||||||
if config.SuggestedConfig == "" || !strings.Contains(config.SuggestedConfig, "ai.recommendation=review-required") {
|
if config.SuggestedConfig == "" || config.ConfigExecution == nil || config.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||||
t.Fatalf("expected reviewable config suggestion, got %+v", config)
|
t.Fatalf("expected immediately dispatched config suggestion, got %+v", config)
|
||||||
}
|
}
|
||||||
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-ai-api", adminSession)
|
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-ai-api", adminSession)
|
||||||
if jobs.Count != 0 {
|
if jobs.Count != 1 || !containsJobID(jobs.Items, config.ConfigExecution.Job.ID) {
|
||||||
t.Fatalf("AI suggestion must not dispatch config writes, got %+v", jobs)
|
t.Fatalf("AI suggestion must dispatch one config write, got %+v", jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
|
||||||
@@ -1597,22 +1599,16 @@ func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) {
|
|||||||
t.Fatalf("expected persisted plugin lifecycle, got %+v", lifecycles)
|
t.Fatalf("expected persisted plugin lifecycle, got %+v", lifecycles)
|
||||||
}
|
}
|
||||||
|
|
||||||
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-diff", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-direct", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession)
|
||||||
if invocation.ConfigRecommendation == nil || invocation.ConfigRecommendation.DiffID == "" {
|
if invocation.ConfigRecommendation == nil || invocation.ConfigExecution == nil || invocation.ConfigExecution.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||||
t.Fatalf("expected persisted AI config diff, got %+v", invocation)
|
t.Fatalf("expected direct AI config write dispatch, got %+v", invocation)
|
||||||
}
|
}
|
||||||
diffs := getJSONWithAuth[dto.AIConfigDiffListResponse](t, router, "/api/v1/ai/config-diffs?serverInstanceId="+serverID, adminSession)
|
jobs := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId="+serverID, adminSession)
|
||||||
if diffs.Count != 1 || diffs.Items[0].State != string(domain.AIConfigDiffStatePending) {
|
if jobs.Count < 2 || !containsJobID(jobs.Items, invocation.ConfigExecution.Job.ID) {
|
||||||
t.Fatalf("expected one pending AI diff, got %+v", diffs)
|
t.Fatalf("expected AI config write job in durable job list, got %+v", jobs)
|
||||||
}
|
|
||||||
approvalRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-diffs/"+invocation.ConfigRecommendation.DiffID+"/approve", dto.AIConfigDiffApprovalRequest{IdempotencyKey: "api-ai-diff-approve"}, adminSession)
|
|
||||||
assertStatus(t, approvalRecorder, http.StatusAccepted)
|
|
||||||
approval := decodeBody[dto.AIConfigDiffApprovalResponse](t, approvalRecorder)
|
|
||||||
if approval.Preview.State != string(domain.AIConfigDiffStateApproved) || approval.Dispatch.Job.Capability != domain.JobCapabilityConfigWrite {
|
|
||||||
t.Fatalf("expected approved diff with config write job, got %+v", approval)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
evidence := fmt.Sprintf("%+v %+v %+v %+v", lifecycle, lifecycles, diffs, approval)
|
evidence := fmt.Sprintf("%+v %+v %+v", lifecycle, lifecycles, invocation)
|
||||||
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} {
|
||||||
if strings.Contains(evidence, forbidden) {
|
if strings.Contains(evidence, forbidden) {
|
||||||
t.Fatalf("plugin operations response leaked forbidden fragment %q: %s", forbidden, evidence)
|
t.Fatalf("plugin operations response leaked forbidden fragment %q: %s", forbidden, evidence)
|
||||||
@@ -1992,7 +1988,7 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra
|
|||||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform"},
|
||||||
ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
|
ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
|
||||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||||
},
|
},
|
||||||
@@ -2010,3 +2006,12 @@ func validRunEndpointRequest() dto.RunEndpointCreateRequest {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsJobID(items []dto.JobResponse, id string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Server owner membership actions hide and reject platform administrators. Server
|
|||||||
|
|
||||||
- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators.
|
- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators.
|
||||||
- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user.
|
- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user.
|
||||||
Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns reviewable typed diff metadata through AI config-diff approval flows rather than raw config text.
|
Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns typed recommendation and execution metadata; Platform validates and dispatches the bounded config.write job immediately rather than exposing a separate approval queue.
|
||||||
|
|
||||||
Observability responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
|
Observability responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
|
||||||
|
|
||||||
@@ -85,10 +85,10 @@ File dispatch responses expose only logical target keys, scoped input/artifact r
|
|||||||
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
|
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
|
||||||
- `POST /api/v1/ai-providers/{id}/test`: invoke the configured provider client with a bounded health request and return only a redacted `AIProviderTestResponse`.
|
- `POST /api/v1/ai-providers/{id}/test`: invoke the configured provider client with a bounded health request and return only a redacted `AIProviderTestResponse`.
|
||||||
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
|
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
|
||||||
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
|
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional config recommendation, direct `configExecution.job` metadata, and safe errors.
|
||||||
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
|
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` after the bounded config.write job is queued.
|
||||||
|
|
||||||
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions persist an expiring diff and never dispatch run-side writes before separate approval.
|
AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions validate the current config revision/checksum, permissions, runtime binding, endpoint/plugin capability, and content safety before dispatching one bounded run-side write job.
|
||||||
AI Provider management responses also return only `baseUrlConfigured` and `apiKeyConfigured`; an empty base URL or secret reference in an update preserves the Platform-owned value instead of round-tripping it through the browser.
|
AI Provider management responses also return only `baseUrlConfigured` and `apiKeyConfigured`; an empty base URL or secret reference in an update preserves the Platform-owned value instead of round-tripping it through the browser.
|
||||||
|
|
||||||
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a scoped secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a scoped secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser.
|
||||||
@@ -115,8 +115,6 @@ Marketplace catalog state remains separate from production lifecycle installatio
|
|||||||
|
|
||||||
- `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations.
|
- `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations.
|
||||||
- `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, and idempotency before creating one durable Run job.
|
- `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, and idempotency before creating one durable Run job.
|
||||||
- `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session.
|
|
||||||
- `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job.
|
|
||||||
|
|
||||||
These responses expose logical IDs, states, safe diagnostics, and job links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material.
|
These responses expose logical IDs, states, safe diagnostics, and job links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material.
|
||||||
|
|
||||||
@@ -152,7 +150,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
|||||||
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
|
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
|
||||||
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
||||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries or interpret their body; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events, or a scoped durable job log stream when `jobId` is supplied. It does not interpret log bodies; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||||
|
|
||||||
Runtime distribution APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
Runtime distribution APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ type AIConfigRecommendation struct {
|
|||||||
Key string
|
Key string
|
||||||
SuggestedConfig string
|
SuggestedConfig string
|
||||||
DiffSummary string
|
DiffSummary string
|
||||||
DiffID string
|
}
|
||||||
ExpiresAt string
|
|
||||||
|
type AIConfigExecution struct {
|
||||||
|
Status string
|
||||||
|
Job Job
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIInvocationSafeError struct {
|
type AIInvocationSafeError struct {
|
||||||
@@ -43,6 +46,7 @@ type AIInvocationResponse struct {
|
|||||||
Status string
|
Status string
|
||||||
Recommendation string
|
Recommendation string
|
||||||
ConfigRecommendation *AIConfigRecommendation
|
ConfigRecommendation *AIConfigRecommendation
|
||||||
|
ConfigExecution *AIConfigExecution
|
||||||
Usage AIInvocationUsage
|
Usage AIInvocationUsage
|
||||||
Error *AIInvocationSafeError
|
Error *AIInvocationSafeError
|
||||||
}
|
}
|
||||||
@@ -64,6 +68,11 @@ func CopyAIInvocationResponse(response AIInvocationResponse) AIInvocationRespons
|
|||||||
recommendation := *response.ConfigRecommendation
|
recommendation := *response.ConfigRecommendation
|
||||||
response.ConfigRecommendation = &recommendation
|
response.ConfigRecommendation = &recommendation
|
||||||
}
|
}
|
||||||
|
if response.ConfigExecution != nil {
|
||||||
|
execution := *response.ConfigExecution
|
||||||
|
execution.Job = CopyJob(execution.Job)
|
||||||
|
response.ConfigExecution = &execution
|
||||||
|
}
|
||||||
if response.Error != nil {
|
if response.Error != nil {
|
||||||
errorCopy := *response.Error
|
errorCopy := *response.Error
|
||||||
errorCopy.Details = CopyStringSlice(errorCopy.Details)
|
errorCopy.Details = CopyStringSlice(errorCopy.Details)
|
||||||
|
|||||||
@@ -66,56 +66,6 @@ type PluginLifecycleResult struct {
|
|||||||
Status string
|
Status string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIConfigDiffState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AIConfigDiffStatePending AIConfigDiffState = "pending"
|
|
||||||
AIConfigDiffStateApproved AIConfigDiffState = "approved"
|
|
||||||
AIConfigDiffStateCancelled AIConfigDiffState = "cancelled"
|
|
||||||
AIConfigDiffStateExpired AIConfigDiffState = "expired"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AIConfigDiffPreview struct {
|
|
||||||
ID string
|
|
||||||
RequestID string
|
|
||||||
CreatedBy string
|
|
||||||
ServerInstanceID string
|
|
||||||
PluginID string
|
|
||||||
ProviderID string
|
|
||||||
Model string
|
|
||||||
Key string
|
|
||||||
ConfigVersion int
|
|
||||||
CurrentConfigChecksum string
|
|
||||||
ProposedConfig string
|
|
||||||
DiffSummary string
|
|
||||||
State AIConfigDiffState
|
|
||||||
ExpiresAt time.Time
|
|
||||||
ApprovedBy string
|
|
||||||
ApprovedAt time.Time
|
|
||||||
ApprovalIdempotencyKey string
|
|
||||||
CancelledBy string
|
|
||||||
CancelledAt time.Time
|
|
||||||
JobID string
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type AIConfigDiffFilter struct {
|
|
||||||
ServerInstanceID string
|
|
||||||
PluginID string
|
|
||||||
State AIConfigDiffState
|
|
||||||
}
|
|
||||||
|
|
||||||
type AIConfigDiffApprovalRequest struct {
|
|
||||||
DiffID string
|
|
||||||
IdempotencyKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AIConfigDiffApprovalResult struct {
|
|
||||||
Preview AIConfigDiffPreview
|
|
||||||
Dispatch ServerConfigWriteDispatch
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyPluginLifecycleInstallation(installation PluginLifecycleInstallation) PluginLifecycleInstallation {
|
func CopyPluginLifecycleInstallation(installation PluginLifecycleInstallation) PluginLifecycleInstallation {
|
||||||
return installation
|
return installation
|
||||||
}
|
}
|
||||||
@@ -134,22 +84,3 @@ func CopyPluginLifecycleResult(result PluginLifecycleResult) PluginLifecycleResu
|
|||||||
result.Job = CopyJob(result.Job)
|
result.Job = CopyJob(result.Job)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyAIConfigDiffPreview(preview AIConfigDiffPreview) AIConfigDiffPreview {
|
|
||||||
return preview
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyAIConfigDiffPreviews(previews []AIConfigDiffPreview) []AIConfigDiffPreview {
|
|
||||||
if previews == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]AIConfigDiffPreview, len(previews))
|
|
||||||
copy(out, previews)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func CopyAIConfigDiffApprovalResult(result AIConfigDiffApprovalResult) AIConfigDiffApprovalResult {
|
|
||||||
result.Preview = CopyAIConfigDiffPreview(result.Preview)
|
|
||||||
result.Dispatch = CopyServerConfigWriteDispatch(result.Dispatch)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -365,7 +365,6 @@ type GamePluginManifestServer struct {
|
|||||||
type GamePluginManifestAI struct {
|
type GamePluginManifestAI struct {
|
||||||
Purposes []string
|
Purposes []string
|
||||||
Mediation string
|
Mediation string
|
||||||
ConfigWritePolicy string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginProductionLifecycle struct {
|
type GamePluginProductionLifecycle struct {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type LlmConfigSuggestionResponse struct {
|
|||||||
ServerInstanceID string `json:"serverInstanceId"`
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
Recommendation string `json:"recommendation"`
|
Recommendation string `json:"recommendation"`
|
||||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||||
|
ConfigExecution *AIConfigExecutionResponse `json:"configExecution,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIInvocationUsageResponse struct {
|
type AIInvocationUsageResponse struct {
|
||||||
@@ -39,8 +40,11 @@ type AIConfigRecommendationResponse struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
SuggestedConfig string `json:"suggestedConfig,omitempty"`
|
||||||
DiffSummary string `json:"diffSummary"`
|
DiffSummary string `json:"diffSummary"`
|
||||||
DiffID string `json:"diffId,omitempty"`
|
}
|
||||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
|
||||||
|
type AIConfigExecutionResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Job JobResponse `json:"job"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIInvocationSafeErrorResponse struct {
|
type AIInvocationSafeErrorResponse struct {
|
||||||
@@ -57,6 +61,7 @@ type AIInvocationResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Recommendation string `json:"recommendation,omitempty"`
|
Recommendation string `json:"recommendation,omitempty"`
|
||||||
ConfigRecommendation *AIConfigRecommendationResponse `json:"configRecommendation,omitempty"`
|
ConfigRecommendation *AIConfigRecommendationResponse `json:"configRecommendation,omitempty"`
|
||||||
|
ConfigExecution *AIConfigExecutionResponse `json:"configExecution,omitempty"`
|
||||||
Usage AIInvocationUsageResponse `json:"usage"`
|
Usage AIInvocationUsageResponse `json:"usage"`
|
||||||
Error *AIInvocationSafeErrorResponse `json:"error,omitempty"`
|
Error *AIInvocationSafeErrorResponse `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -84,10 +89,12 @@ func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationRe
|
|||||||
Key: response.ConfigRecommendation.Key,
|
Key: response.ConfigRecommendation.Key,
|
||||||
SuggestedConfig: response.ConfigRecommendation.SuggestedConfig,
|
SuggestedConfig: response.ConfigRecommendation.SuggestedConfig,
|
||||||
DiffSummary: response.ConfigRecommendation.DiffSummary,
|
DiffSummary: response.ConfigRecommendation.DiffSummary,
|
||||||
DiffID: response.ConfigRecommendation.DiffID,
|
|
||||||
ExpiresAt: response.ConfigRecommendation.ExpiresAt,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var execution *AIConfigExecutionResponse
|
||||||
|
if response.ConfigExecution != nil {
|
||||||
|
execution = &AIConfigExecutionResponse{Status: response.ConfigExecution.Status, Job: JobFromDomain(response.ConfigExecution.Job)}
|
||||||
|
}
|
||||||
var safeError *AIInvocationSafeErrorResponse
|
var safeError *AIInvocationSafeErrorResponse
|
||||||
if response.Error != nil {
|
if response.Error != nil {
|
||||||
safeError = &AIInvocationSafeErrorResponse{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
safeError = &AIInvocationSafeErrorResponse{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
||||||
@@ -100,6 +107,7 @@ func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationRe
|
|||||||
Status: response.Status,
|
Status: response.Status,
|
||||||
Recommendation: response.Recommendation,
|
Recommendation: response.Recommendation,
|
||||||
ConfigRecommendation: config,
|
ConfigRecommendation: config,
|
||||||
|
ConfigExecution: execution,
|
||||||
Usage: AIInvocationUsageResponse{
|
Usage: AIInvocationUsageResponse{
|
||||||
ProviderID: response.Usage.ProviderID,
|
ProviderID: response.Usage.ProviderID,
|
||||||
Model: response.Usage.Model,
|
Model: response.Usage.Model,
|
||||||
|
|||||||
@@ -42,44 +42,6 @@ type PluginLifecycleActionResponse struct {
|
|||||||
Job JobResponse `json:"job"`
|
Job JobResponse `json:"job"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIConfigDiffPreviewResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
CreatedBy string `json:"createdBy"`
|
|
||||||
ServerInstanceID string `json:"serverInstanceId"`
|
|
||||||
PluginID string `json:"pluginId,omitempty"`
|
|
||||||
ProviderID string `json:"providerId,omitempty"`
|
|
||||||
Model string `json:"model,omitempty"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
ConfigVersion int `json:"configVersion"`
|
|
||||||
CurrentConfigChecksum string `json:"currentConfigChecksum,omitempty"`
|
|
||||||
ProposedConfig string `json:"proposedConfig,omitempty"`
|
|
||||||
DiffSummary string `json:"diffSummary"`
|
|
||||||
State string `json:"state"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
|
||||||
ApprovedBy string `json:"approvedBy,omitempty"`
|
|
||||||
ApprovedAt time.Time `json:"approvedAt,omitempty"`
|
|
||||||
CancelledBy string `json:"cancelledBy,omitempty"`
|
|
||||||
CancelledAt time.Time `json:"cancelledAt,omitempty"`
|
|
||||||
JobID string `json:"jobId,omitempty"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AIConfigDiffListResponse struct {
|
|
||||||
Items []AIConfigDiffPreviewResponse `json:"items"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AIConfigDiffApprovalRequest struct {
|
|
||||||
IdempotencyKey string `json:"idempotencyKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AIConfigDiffApprovalResponse struct {
|
|
||||||
Preview AIConfigDiffPreviewResponse `json:"preview"`
|
|
||||||
Dispatch ServerConfigWriteDispatchResponse `json:"dispatch"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (request PluginLifecycleActionRequest) ToDomain(pluginID string) domain.PluginLifecycleRequest {
|
func (request PluginLifecycleActionRequest) ToDomain(pluginID string) domain.PluginLifecycleRequest {
|
||||||
return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey}
|
return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey}
|
||||||
}
|
}
|
||||||
@@ -101,25 +63,3 @@ func PluginLifecycleResultFromDomain(result domain.PluginLifecycleResult) Plugin
|
|||||||
result = domain.CopyPluginLifecycleResult(result)
|
result = domain.CopyPluginLifecycleResult(result)
|
||||||
return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job)}
|
return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse {
|
|
||||||
preview = domain.CopyAIConfigDiffPreview(preview)
|
|
||||||
return AIConfigDiffPreviewResponse{ID: preview.ID, RequestID: preview.RequestID, CreatedBy: preview.CreatedBy, ServerInstanceID: preview.ServerInstanceID, PluginID: preview.PluginID, ProviderID: preview.ProviderID, Model: preview.Model, Key: preview.Key, ConfigVersion: preview.ConfigVersion, CurrentConfigChecksum: preview.CurrentConfigChecksum, ProposedConfig: preview.ProposedConfig, DiffSummary: preview.DiffSummary, State: string(preview.State), ExpiresAt: preview.ExpiresAt, ApprovedBy: preview.ApprovedBy, ApprovedAt: preview.ApprovedAt, CancelledBy: preview.CancelledBy, CancelledAt: preview.CancelledAt, JobID: preview.JobID, CreatedAt: preview.CreatedAt, UpdatedAt: preview.UpdatedAt}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AIConfigDiffListFromDomain(previews []domain.AIConfigDiffPreview) AIConfigDiffListResponse {
|
|
||||||
items := make([]AIConfigDiffPreviewResponse, len(previews))
|
|
||||||
for i, preview := range previews {
|
|
||||||
items[i] = AIConfigDiffFromDomain(preview)
|
|
||||||
}
|
|
||||||
return AIConfigDiffListResponse{Items: items, Count: len(items)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (request AIConfigDiffApprovalRequest) ToDomain(diffID string) domain.AIConfigDiffApprovalRequest {
|
|
||||||
return domain.AIConfigDiffApprovalRequest{DiffID: diffID, IdempotencyKey: request.IdempotencyKey}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AIConfigDiffApprovalFromDomain(result domain.AIConfigDiffApprovalResult) AIConfigDiffApprovalResponse {
|
|
||||||
result = domain.CopyAIConfigDiffApprovalResult(result)
|
|
||||||
return AIConfigDiffApprovalResponse{Preview: AIConfigDiffFromDomain(result.Preview), Dispatch: ServerConfigWriteDispatchFromDomain(result.Dispatch)}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -245,7 +245,6 @@ type GamePluginManifestServerBody struct {
|
|||||||
type GamePluginManifestAIBody struct {
|
type GamePluginManifestAIBody struct {
|
||||||
Purposes []string `json:"purposes,omitempty"`
|
Purposes []string `json:"purposes,omitempty"`
|
||||||
Mediation string `json:"mediation,omitempty"`
|
Mediation string `json:"mediation,omitempty"`
|
||||||
ConfigWritePolicy string `json:"configWritePolicy,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginProductionLifecycleBody struct {
|
type GamePluginProductionLifecycleBody struct {
|
||||||
@@ -1206,7 +1205,7 @@ func (server GamePluginManifestServerBody) ToDomain() domain.GamePluginManifestS
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI {
|
func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI {
|
||||||
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes), Mediation: ai.Mediation, ConfigWritePolicy: ai.ConfigWritePolicy}
|
return domain.GamePluginManifestAI{Purposes: domain.CopyStringSlice(ai.Purposes), Mediation: ai.Mediation}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lifecycle GamePluginProductionLifecycleBody) ToDomain() domain.GamePluginProductionLifecycle {
|
func (lifecycle GamePluginProductionLifecycleBody) ToDomain() domain.GamePluginProductionLifecycle {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Required model groups:
|
|||||||
- log streams and ingestion cursors.
|
- log streams and ingestion cursors.
|
||||||
- operational events.
|
- operational events.
|
||||||
- server-bound plugin lifecycle installations and linked jobs.
|
- server-bound plugin lifecycle installations and linked jobs.
|
||||||
- reviewable AI config diffs and approval fences.
|
- AI config execution metadata is carried by durable jobs; retired recommendation records are no longer part of the active model or snapshot schema.
|
||||||
|
|
||||||
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
|
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
|
||||||
# Durable Client Manager persistence
|
# Durable Client Manager persistence
|
||||||
|
|||||||
@@ -455,33 +455,6 @@ type PluginLifecycleInstallation struct {
|
|||||||
|
|
||||||
func (PluginLifecycleInstallation) TableName() string { return "plugin_lifecycle_installations" }
|
func (PluginLifecycleInstallation) TableName() string { return "plugin_lifecycle_installations" }
|
||||||
|
|
||||||
type AIConfigDiff struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
RequestID string `json:"requestId" db:"request_id"`
|
|
||||||
CreatedBy string `json:"createdBy" db:"created_by"`
|
|
||||||
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
|
|
||||||
PluginID string `json:"pluginId,omitempty" db:"plugin_id"`
|
|
||||||
ProviderID string `json:"providerId,omitempty" db:"provider_id"`
|
|
||||||
Model string `json:"model,omitempty" db:"model"`
|
|
||||||
Key string `json:"key" db:"key"`
|
|
||||||
ConfigVersion int `json:"configVersion" db:"config_version"`
|
|
||||||
CurrentConfigChecksum string `json:"currentConfigChecksum" db:"current_config_checksum"`
|
|
||||||
ProposedConfig string `json:"proposedConfig" db:"proposed_config"`
|
|
||||||
DiffSummary string `json:"diffSummary" db:"diff_summary"`
|
|
||||||
State domain.AIConfigDiffState `json:"state" db:"state"`
|
|
||||||
ExpiresAt time.Time `json:"expiresAt" db:"expires_at"`
|
|
||||||
ApprovedBy string `json:"approvedBy,omitempty" db:"approved_by"`
|
|
||||||
ApprovedAt time.Time `json:"approvedAt,omitempty" db:"approved_at"`
|
|
||||||
ApprovalIdempotencyKey string `json:"approvalIdempotencyKey,omitempty" db:"approval_idempotency_key"`
|
|
||||||
CancelledBy string `json:"cancelledBy,omitempty" db:"cancelled_by"`
|
|
||||||
CancelledAt time.Time `json:"cancelledAt,omitempty" db:"cancelled_at"`
|
|
||||||
JobID string `json:"jobId,omitempty" db:"job_id"`
|
|
||||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (AIConfigDiff) TableName() string { return "ai_config_diffs" }
|
|
||||||
|
|
||||||
func UserFromDomain(user domain.User) User {
|
func UserFromDomain(user domain.User) User {
|
||||||
user = domain.CopyUser(user)
|
user = domain.CopyUser(user)
|
||||||
return User{
|
return User{
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ func TestTableNames(t *testing.T) {
|
|||||||
Artifact{}.TableName(): "artifacts",
|
Artifact{}.TableName(): "artifacts",
|
||||||
LogStream{}.TableName(): "log_streams",
|
LogStream{}.TableName(): "log_streams",
|
||||||
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations",
|
||||||
AIConfigDiff{}.TableName(): "ai_config_diffs",
|
|
||||||
SCUMUser{}.TableName(): "scum_user",
|
SCUMUser{}.TableName(): "scum_user",
|
||||||
SCUMUserTrajectory{}.TableName(): "scum_user_trajectory",
|
SCUMUserTrajectory{}.TableName(): "scum_user_trajectory",
|
||||||
SCUMVehicle{}.TableName(): "scum_vehicle",
|
SCUMVehicle{}.TableName(): "scum_vehicle",
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ AI invocation responses must be bounded and must not include raw provider creden
|
|||||||
|
|
||||||
Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode.
|
Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/<id>` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode.
|
||||||
|
|
||||||
Provider failures return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected.
|
Provider failures return a stable safe error without URL, header, key, request-body secret, or stack details. A config suggestion response includes the proposed content plus `configExecution` metadata for the bounded `config.write` job that Platform dispatches immediately after the current config version/checksum, permissions, runtime binding, and content-safety checks pass. The response contains the job ID and target key so the caller can follow execution logs without receiving provider credentials.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ A server instance is created from one installed game management plugin and is la
|
|||||||
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
|
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
|
||||||
- `POST /api/v1/server-instances/{id}/restart` validates the instance is `running` or `stopped`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues the plugin-declared stop job recorded as a `restart` lifecycle operation.
|
- `POST /api/v1/server-instances/{id}/restart` validates the instance is `running` or `stopped`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues the plugin-declared stop job recorded as a `restart` lifecycle operation.
|
||||||
- `POST /api/v1/server-instances/{id}/update` validates the instance is `running`, `stopped`, `ready`, or `failed`, checks the expected config version, verifies the plugin install action and run endpoint `process.install` capability, and queues the plugin-declared install/update job recorded as an `update` lifecycle operation.
|
- `POST /api/v1/server-instances/{id}/update` validates the instance is `running`, `stopped`, `ready`, or `failed`, checks the expected config version, verifies the plugin install action and run endpoint `process.install` capability, and queues the plugin-declared install/update job recorded as an `update` lifecycle operation.
|
||||||
- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses reviewable AI config-diff approvals and typed dispatch metadata without returning raw config text to plugin pages.
|
- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses the platform-mediated direct dispatch response from `config.suggest`; the response carries typed `configExecution` job metadata without returning provider credentials to plugin pages.
|
||||||
- `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks.
|
- `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks.
|
||||||
- `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user.
|
- `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user.
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ Workflow route responses include the accepted action, bounded server instance me
|
|||||||
|
|
||||||
Server metrics responses are bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys.
|
Server metrics responses are bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys.
|
||||||
|
|
||||||
Config write approval and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads.
|
Config write dispatch and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads.
|
||||||
|
|
||||||
Marketplace state actions update only registry install state. They do not download packages, dispatch run jobs, execute plugin bridge code, write server files, expose package bytes, or contact external services. Package acquisition and runtime execution remain deferred to explicit future changes.
|
Marketplace state actions update only registry install state. They do not download packages, dispatch run jobs, execute plugin bridge code, write server files, expose package bytes, or contact external services. Package acquisition and runtime execution remain deferred to explicit future changes.
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ type StoreSnapshot struct {
|
|||||||
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
MetricSamples []domain.MetricSample `json:"metricSamples"`
|
||||||
Backups []domain.BackupRecord `json:"backups"`
|
Backups []domain.BackupRecord `json:"backups"`
|
||||||
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"`
|
||||||
AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"`
|
|
||||||
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
|
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
|
||||||
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
|
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
|
||||||
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
|
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"`
|
||||||
@@ -152,10 +151,6 @@ func (store *FileStore) PluginLifecycles() PluginLifecycleRepository {
|
|||||||
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileStore) AIConfigDiffs() AIConfigDiffRepository {
|
|
||||||
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *FileStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
func (store *FileStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||||
return &persistentGameClientBridgeCommandRepository{
|
return &persistentGameClientBridgeCommandRepository{
|
||||||
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||||
@@ -327,7 +322,6 @@ func (store *FileStore) snapshot() StoreSnapshot {
|
|||||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
|
||||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||||
@@ -368,7 +362,6 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
|||||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
|
||||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||||
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||||
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ func normalizeStoreSnapshot(snapshot StoreSnapshot) StoreSnapshot {
|
|||||||
snapshot.ServerInstances = rewriteSnapshotServerInstances(snapshot.ServerInstances, replacements)
|
snapshot.ServerInstances = rewriteSnapshotServerInstances(snapshot.ServerInstances, replacements)
|
||||||
snapshot.RuntimeBindings = rewriteSnapshotRuntimeBindings(snapshot.RuntimeBindings, replacements)
|
snapshot.RuntimeBindings = rewriteSnapshotRuntimeBindings(snapshot.RuntimeBindings, replacements)
|
||||||
snapshot.PluginLifecycles = rewriteSnapshotPluginLifecycles(snapshot.PluginLifecycles, replacements)
|
snapshot.PluginLifecycles = rewriteSnapshotPluginLifecycles(snapshot.PluginLifecycles, replacements)
|
||||||
snapshot.AIConfigDiffs = rewriteSnapshotAIConfigDiffs(snapshot.AIConfigDiffs, replacements)
|
|
||||||
snapshot.PluginDataRecords = retainedSnapshotPluginData(snapshot.PluginDataRecords, replacements)
|
snapshot.PluginDataRecords = retainedSnapshotPluginData(snapshot.PluginDataRecords, replacements)
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
@@ -81,17 +80,6 @@ func rewriteSnapshotPluginLifecycles(values []domain.PluginLifecycleInstallation
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewriteSnapshotAIConfigDiffs(values []domain.AIConfigDiffPreview, replacements map[string]domain.GamePlugin) []domain.AIConfigDiffPreview {
|
|
||||||
out := make([]domain.AIConfigDiffPreview, len(values))
|
|
||||||
for index, value := range values {
|
|
||||||
if kept, ok := replacements[value.PluginID]; ok {
|
|
||||||
value.PluginID = kept.ID
|
|
||||||
}
|
|
||||||
out[index] = domain.CopyAIConfigDiffPreview(value)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func retainedSnapshotPluginData(values []domain.PluginDataRecord, replacements map[string]domain.GamePlugin) []domain.PluginDataRecord {
|
func retainedSnapshotPluginData(values []domain.PluginDataRecord, replacements map[string]domain.GamePlugin) []domain.PluginDataRecord {
|
||||||
byID := map[string]domain.PluginDataRecord{}
|
byID := map[string]domain.PluginDataRecord{}
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
|
|||||||
@@ -132,10 +132,6 @@ func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository {
|
|||||||
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) AIConfigDiffs() AIConfigDiffRepository {
|
|
||||||
return &persistentRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]{repository: store.MemoryStore.aiConfigDiffs, persist: store.persist}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *MySQLStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
func (store *MySQLStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||||
return &persistentGameClientBridgeCommandRepository{
|
return &persistentGameClientBridgeCommandRepository{
|
||||||
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
persistentRepository: &persistentRepository[domain.GameClientBridgeCommand, domain.GameClientBridgeCommandFilter]{repository: store.MemoryStore.bridgeCommands, persist: store.persist},
|
||||||
@@ -328,7 +324,6 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
|
|||||||
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
MetricSamples: snapshotRepository(store.MemoryStore.metricSamples),
|
||||||
Backups: snapshotRepository(store.MemoryStore.backups),
|
Backups: snapshotRepository(store.MemoryStore.backups),
|
||||||
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle),
|
||||||
AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs),
|
|
||||||
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
|
||||||
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
|
||||||
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
|
||||||
@@ -370,7 +365,6 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
|||||||
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples)
|
||||||
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
loadRepository(store.MemoryStore.backups, snapshot.Backups)
|
||||||
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles)
|
||||||
loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs)
|
|
||||||
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
|
||||||
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
|
||||||
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams)
|
||||||
|
|||||||
@@ -147,13 +147,6 @@ type PluginLifecycleRepository interface {
|
|||||||
Update(domain.PluginLifecycleInstallation) error
|
Update(domain.PluginLifecycleInstallation) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIConfigDiffRepository interface {
|
|
||||||
Create(domain.AIConfigDiffPreview) error
|
|
||||||
Get(id string) (domain.AIConfigDiffPreview, error)
|
|
||||||
List(domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error)
|
|
||||||
Update(domain.AIConfigDiffPreview) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameClientBridgeCommandRepository interface {
|
type GameClientBridgeCommandRepository interface {
|
||||||
Create(domain.GameClientBridgeCommand) error
|
Create(domain.GameClientBridgeCommand) error
|
||||||
Get(id string) (domain.GameClientBridgeCommand, error)
|
Get(id string) (domain.GameClientBridgeCommand, error)
|
||||||
@@ -247,7 +240,6 @@ type Store interface {
|
|||||||
MetricSamples() MetricSampleRepository
|
MetricSamples() MetricSampleRepository
|
||||||
Backups() BackupRepository
|
Backups() BackupRepository
|
||||||
PluginLifecycles() PluginLifecycleRepository
|
PluginLifecycles() PluginLifecycleRepository
|
||||||
AIConfigDiffs() AIConfigDiffRepository
|
|
||||||
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
GameClientBridgeCommands() GameClientBridgeCommandRepository
|
||||||
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
|
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
|
||||||
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
|
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
|
||||||
@@ -278,7 +270,6 @@ type MemoryStore struct {
|
|||||||
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
|
||||||
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
|
||||||
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
|
||||||
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
|
|
||||||
bridgeCommands *memoryGameClientBridgeCommandRepository
|
bridgeCommands *memoryGameClientBridgeCommandRepository
|
||||||
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
|
||||||
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]
|
||||||
@@ -378,11 +369,6 @@ func NewMemoryStore() *MemoryStore {
|
|||||||
domain.CopyPluginLifecycleInstallation,
|
domain.CopyPluginLifecycleInstallation,
|
||||||
matchPluginLifecycle,
|
matchPluginLifecycle,
|
||||||
),
|
),
|
||||||
aiConfigDiffs: newMemoryRepository(
|
|
||||||
func(preview domain.AIConfigDiffPreview) string { return preview.ID },
|
|
||||||
domain.CopyAIConfigDiffPreview,
|
|
||||||
matchAIConfigDiff,
|
|
||||||
),
|
|
||||||
bridgeCommands: newMemoryGameClientBridgeCommandRepository(),
|
bridgeCommands: newMemoryGameClientBridgeCommandRepository(),
|
||||||
bridgeSnapshots: newMemoryGameClientBridgeSnapshotRepository(),
|
bridgeSnapshots: newMemoryGameClientBridgeSnapshotRepository(),
|
||||||
bridgeStreams: newMemoryRepository(
|
bridgeStreams: newMemoryRepository(
|
||||||
@@ -421,7 +407,6 @@ func (store *MemoryStore) Backups() BackupRepository { retu
|
|||||||
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
|
func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository {
|
||||||
return store.pluginLifecycle
|
return store.pluginLifecycle
|
||||||
}
|
}
|
||||||
func (store *MemoryStore) AIConfigDiffs() AIConfigDiffRepository { return store.aiConfigDiffs }
|
|
||||||
func (store *MemoryStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
func (store *MemoryStore) GameClientBridgeCommands() GameClientBridgeCommandRepository {
|
||||||
return store.bridgeCommands
|
return store.bridgeCommands
|
||||||
}
|
}
|
||||||
@@ -722,12 +707,6 @@ func matchPluginLifecycle(installation domain.PluginLifecycleInstallation, filte
|
|||||||
(filter.CurrentState == "" || installation.CurrentState == filter.CurrentState)
|
(filter.CurrentState == "" || installation.CurrentState == filter.CurrentState)
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchAIConfigDiff(preview domain.AIConfigDiffPreview, filter domain.AIConfigDiffFilter) bool {
|
|
||||||
return (filter.ServerInstanceID == "" || preview.ServerInstanceID == filter.ServerInstanceID) &&
|
|
||||||
(filter.PluginID == "" || preview.PluginID == filter.PluginID) &&
|
|
||||||
(filter.State == "" || preview.State == filter.State)
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchGameClientBridgeCommand(command domain.GameClientBridgeCommand, filter domain.GameClientBridgeCommandFilter) bool {
|
func matchGameClientBridgeCommand(command domain.GameClientBridgeCommand, filter domain.GameClientBridgeCommandFilter) bool {
|
||||||
return (filter.ServerInstanceID == "" || command.ServerInstanceID == filter.ServerInstanceID) &&
|
return (filter.ServerInstanceID == "" || command.ServerInstanceID == filter.ServerInstanceID) &&
|
||||||
(filter.PluginID == "" || command.PluginID == filter.PluginID) &&
|
(filter.PluginID == "" || command.PluginID == filter.PluginID) &&
|
||||||
|
|||||||
@@ -435,19 +435,9 @@ func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
|||||||
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade",
|
DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade",
|
||||||
IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp,
|
||||||
}
|
}
|
||||||
diff := domain.AIConfigDiffPreview{
|
|
||||||
ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1",
|
|
||||||
ServerInstanceID: "server-1", PluginID: "game.scum", ProviderID: "ai.openai", Model: "gpt-4.1",
|
|
||||||
Key: "server.properties", ConfigVersion: 4, CurrentConfigChecksum: "sha256:" + strings.Repeat("a", 64),
|
|
||||||
ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch",
|
|
||||||
State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp,
|
|
||||||
}
|
|
||||||
if err := store.PluginLifecycles().Create(installation); err != nil {
|
if err := store.PluginLifecycles().Create(installation); err != nil {
|
||||||
t.Fatalf("create plugin lifecycle: %v", err)
|
t.Fatalf("create plugin lifecycle: %v", err)
|
||||||
}
|
}
|
||||||
if err := store.AIConfigDiffs().Create(diff); err != nil {
|
|
||||||
t.Fatalf("create AI config diff: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
restarted, err := NewFileStore(path)
|
restarted, err := NewFileStore(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -457,10 +447,6 @@ func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
|||||||
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
|
if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID {
|
||||||
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
|
t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr)
|
||||||
}
|
}
|
||||||
gotDiff, diffErr := restarted.AIConfigDiffs().Get(diff.ID)
|
|
||||||
if diffErr != nil || gotDiff.State != diff.State || gotDiff.CurrentConfigChecksum != diff.CurrentConfigChecksum || gotDiff.ProposedConfig != diff.ProposedConfig {
|
|
||||||
t.Fatalf("unexpected durable AI config diff: diff=%+v err=%v", gotDiff, diffErr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
func TestMySQLStoreRequiresDSN(t *testing.T) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
"browser.local/platform/repo"
|
"browser.local/platform/repo"
|
||||||
@@ -24,7 +23,7 @@ func (MockAIProviderClient) Invoke(provider domain.AIProvider, request domain.AI
|
|||||||
if model == "" && len(provider.Models) > 0 {
|
if model == "" && len(provider.Models) > 0 {
|
||||||
model = 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{
|
result := domain.AIProviderInvocationResult{
|
||||||
Recommendation: recommendation,
|
Recommendation: recommendation,
|
||||||
Usage: domain.AIInvocationUsage{
|
Usage: domain.AIInvocationUsage{
|
||||||
@@ -46,10 +45,12 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
|||||||
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
if err := validator.ValidateAIInvocationRequest(request); err != nil {
|
||||||
return domain.AIInvocationResponse{}, err
|
return domain.AIInvocationResponse{}, err
|
||||||
}
|
}
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
if _, err := svc.GetCurrentUser(sessionID); err != nil {
|
||||||
if err != nil {
|
|
||||||
return domain.AIInvocationResponse{}, err
|
return domain.AIInvocationResponse{}, err
|
||||||
}
|
}
|
||||||
|
var currentConfig domain.ServerConfig
|
||||||
|
var hasCurrentConfig bool
|
||||||
|
var err error
|
||||||
if request.ServerInstanceID != "" {
|
if request.ServerInstanceID != "" {
|
||||||
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,6 +64,8 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.AIInvocationResponse{}, err
|
return domain.AIInvocationResponse{}, err
|
||||||
}
|
}
|
||||||
|
currentConfig = config
|
||||||
|
hasCurrentConfig = true
|
||||||
request.CurrentConfig = config.Content
|
request.CurrentConfig = config.Content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,16 +114,25 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn
|
|||||||
Usage: result.Usage,
|
Usage: result.Usage,
|
||||||
}
|
}
|
||||||
if result.SuggestedConfig != "" {
|
if result.SuggestedConfig != "" {
|
||||||
if request.ServerInstanceID == "" {
|
if request.ServerInstanceID == "" || !hasCurrentConfig {
|
||||||
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
|
return domain.AIInvocationResponse{}, validationError("serverInstanceId is required for AI config recommendations")
|
||||||
}
|
}
|
||||||
|
idempotencyKey := aiConfigWriteIdempotencyKey(request.RequestID, request.ServerInstanceID)
|
||||||
svc.pluginOperationsMu.Lock()
|
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()
|
svc.pluginOperationsMu.Unlock()
|
||||||
if persistErr != nil {
|
if dispatchErr != nil {
|
||||||
return domain.AIInvocationResponse{}, persistErr
|
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 {
|
if err := validator.ValidateAIInvocationResponse(response); err != nil {
|
||||||
return domain.AIInvocationResponse{}, err
|
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=") {
|
if strings.Contains(strings.ToLower(prompt), "pvp") && !strings.Contains(base, "pvp=") {
|
||||||
base += "\npvp=false"
|
base += "\npvp=false"
|
||||||
}
|
}
|
||||||
return base + "\n# ai.recommendation=review-required\n"
|
return base + "\n# ai.recommendation=auto-applied\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
func boundedTokenEstimate(value string) int {
|
func boundedTokenEstimate(value string) int {
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ func boundedProviderPrompt(request domain.AIInvocationRequest) string {
|
|||||||
builder.WriteString(request.CurrentConfig)
|
builder.WriteString(request.CurrentConfig)
|
||||||
}
|
}
|
||||||
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
|
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()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ import (
|
|||||||
"browser.local/platform/validator"
|
"browser.local/platform/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
aiConfigDiffTTL = 30 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
|
func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) {
|
||||||
user, err := svc.GetCurrentUser(sessionID)
|
user, err := svc.GetCurrentUser(sessionID)
|
||||||
if err != nil {
|
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
|
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 {
|
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 == "" {
|
if !strings.HasPrefix(job.ID, "job-plugin-lifecycle-") || job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -230,63 +148,6 @@ func (svc *CoreService) projectPluginOperationsJobResult(job domain.Job, stamp t
|
|||||||
return svc.store.PluginLifecycles().Update(installation)
|
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) {
|
func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) {
|
||||||
svc.pluginOperationsMu.Lock()
|
svc.pluginOperationsMu.Lock()
|
||||||
defer svc.pluginOperationsMu.Unlock()
|
defer svc.pluginOperationsMu.Unlock()
|
||||||
@@ -436,7 +297,7 @@ func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string {
|
|||||||
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
|
return "plugin-lifecycle-" + hex.EncodeToString(sum[:12])
|
||||||
}
|
}
|
||||||
|
|
||||||
func aiConfigDiffID(requestID, serverInstanceID string) string {
|
func aiConfigWriteIdempotencyKey(requestID, serverInstanceID string) string {
|
||||||
sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID))
|
sum := sha256.Sum256([]byte("ai-config-write\x00" + requestID + "\x00" + serverInstanceID))
|
||||||
return "ai-config-diff-" + hex.EncodeToString(sum[:12])
|
return "ai-config-write-" + hex.EncodeToString(sum[:16])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testing.T) {
|
func TestAIConfigRecommendationDispatchesConfigWriteImmediately(t *testing.T) {
|
||||||
svc, session, instance := newPluginOperationsFixture(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"})
|
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 {
|
if err != nil {
|
||||||
@@ -82,43 +82,19 @@ func TestAIConfigRecommendationRequiresApprovalAndRejectsStaleRevision(t *testin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("invoke AI: %v", err)
|
t.Fatalf("invoke AI: %v", err)
|
||||||
}
|
}
|
||||||
if response.ConfigRecommendation == nil || response.ConfigRecommendation.DiffID == "" {
|
if response.ConfigRecommendation == nil || response.ConfigExecution == nil || response.ConfigExecution.Job.ID == "" {
|
||||||
t.Fatalf("expected persisted config recommendation, got %+v", response)
|
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})
|
jobs, _ := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||||
if len(jobs) != 0 {
|
if len(jobs) != 1 || jobs[0].ID != response.ConfigExecution.Job.ID {
|
||||||
t.Fatalf("AI recommendation must not dispatch before approval: %+v", jobs)
|
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"})
|
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 {
|
if err != nil || repeated.ConfigExecution == nil || repeated.ConfigExecution.Job.ID != response.ConfigExecution.Job.ID {
|
||||||
t.Fatalf("approve AI diff: %v", err)
|
t.Fatalf("repeat AI request must reuse the idempotent job: %+v err=%v", repeated, 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,8 +116,6 @@ type Core interface {
|
|||||||
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
||||||
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error)
|
||||||
RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, 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)
|
IngestMetricBatch(domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error)
|
||||||
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
|
ListMetricSamplesForSession(string, domain.MetricSampleFilter) ([]domain.MetricSample, error)
|
||||||
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
|
CreateBackupForSession(string, domain.BackupRecord) (domain.BackupRecord, error)
|
||||||
@@ -912,17 +910,6 @@ func (svc *CoreService) replaceGamePluginReference(fromPluginID, toPluginID, toP
|
|||||||
return err
|
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)
|
return svc.replacePluginDataReferences(fromPluginID, toPluginID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,9 +1202,14 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug
|
|||||||
if response.ConfigRecommendation != nil {
|
if response.ConfigRecommendation != nil {
|
||||||
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
|
base.Result["suggestedConfig"] = response.ConfigRecommendation.SuggestedConfig
|
||||||
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
|
base.Result["diffSummary"] = response.ConfigRecommendation.DiffSummary
|
||||||
base.Result["diffId"] = response.ConfigRecommendation.DiffID
|
|
||||||
base.Result["key"] = response.ConfigRecommendation.Key
|
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 {
|
if response.Error != nil {
|
||||||
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
base.Error = &domain.PluginBridgeSafeError{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
|
||||||
|
|||||||
@@ -2448,7 +2448,7 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
|
|||||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
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"},
|
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"}}}},
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,42 +44,6 @@ func ValidatePluginLifecycleRequest(request domain.PluginLifecycleRequest) error
|
|||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateAIConfigDiffPreview(preview domain.AIConfigDiffPreview) error {
|
|
||||||
var violations []string
|
|
||||||
violations = appendRequired(violations, "id", preview.ID)
|
|
||||||
violations = appendRequired(violations, "requestId", preview.RequestID)
|
|
||||||
violations = appendRequired(violations, "createdBy", preview.CreatedBy)
|
|
||||||
violations = appendRequired(violations, "serverInstanceId", preview.ServerInstanceID)
|
|
||||||
violations = appendRequired(violations, "key", preview.Key)
|
|
||||||
violations = appendRequired(violations, "diffSummary", preview.DiffSummary)
|
|
||||||
if preview.ConfigVersion <= 0 {
|
|
||||||
violations = append(violations, "configVersion must be positive")
|
|
||||||
}
|
|
||||||
if !validAIConfigDiffState(preview.State) {
|
|
||||||
violations = append(violations, "state is invalid")
|
|
||||||
}
|
|
||||||
if !validLogicalFileKey(preview.Key) || !validConfigFileKey(preview.Key) {
|
|
||||||
violations = append(violations, "key is invalid")
|
|
||||||
}
|
|
||||||
if len([]byte(preview.ProposedConfig)) > maxServerConfigContentSize || containsUnsafeRuntimeSecret(preview.ProposedConfig) || looksLikeRawHostPath(preview.ProposedConfig) {
|
|
||||||
violations = append(violations, "proposedConfig is unsafe")
|
|
||||||
}
|
|
||||||
if len(preview.DiffSummary) > maxProductionMessageLength || unsafeProductionText(preview.DiffSummary) {
|
|
||||||
violations = append(violations, "diffSummary is unsafe")
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequest) error {
|
|
||||||
var violations []string
|
|
||||||
violations = appendRequired(violations, "diffId", request.DiffID)
|
|
||||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
|
||||||
if unsafeProductionText(request.DiffID) || unsafeProductionText(request.IdempotencyKey) {
|
|
||||||
violations = append(violations, "AI config approval request is unsafe")
|
|
||||||
}
|
|
||||||
return finish(violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validPluginLifecycleState(state domain.PluginLifecycleState) bool {
|
func validPluginLifecycleState(state domain.PluginLifecycleState) bool {
|
||||||
switch state {
|
switch state {
|
||||||
case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed:
|
case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed:
|
||||||
@@ -98,15 +62,6 @@ func validPluginLifecycleOperation(operation domain.PluginLifecycleOperation) bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validAIConfigDiffState(state domain.AIConfigDiffState) bool {
|
|
||||||
switch state {
|
|
||||||
case domain.AIConfigDiffStatePending, domain.AIConfigDiffStateApproved, domain.AIConfigDiffStateCancelled, domain.AIConfigDiffStateExpired:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func unsafeProductionText(value string) bool {
|
func unsafeProductionText(value string) bool {
|
||||||
lower := strings.ToLower(value)
|
lower := strings.ToLower(value)
|
||||||
return containsUnsafeRuntimeSecret(value) ||
|
return containsUnsafeRuntimeSecret(value) ||
|
||||||
|
|||||||
@@ -231,9 +231,6 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
|||||||
if manifest.AI.Mediation != "platform" {
|
if manifest.AI.Mediation != "platform" {
|
||||||
violations = append(violations, "manifest.ai.mediation must be platform")
|
violations = append(violations, "manifest.ai.mediation must be platform")
|
||||||
}
|
}
|
||||||
if manifest.AI.ConfigWritePolicy != "review-required" {
|
|
||||||
violations = append(violations, "manifest.ai.configWritePolicy must be review-required")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
||||||
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
||||||
@@ -2123,7 +2120,7 @@ func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []f
|
|||||||
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
||||||
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
||||||
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
||||||
values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "ai.configWritePolicy", value: manifest.AI.ConfigWritePolicy}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy})
|
values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy})
|
||||||
values = appendStringSliceFields(values, "productionLifecycle.operations", manifest.ProductionLifecycle.Operations)
|
values = appendStringSliceFields(values, "productionLifecycle.operations", manifest.ProductionLifecycle.Operations)
|
||||||
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
||||||
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ func validGamePluginManifestRegistration() domain.GamePluginManifestRegistration
|
|||||||
Pages: []domain.GamePluginPage{
|
Pages: []domain.GamePluginPage{
|
||||||
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
||||||
},
|
},
|
||||||
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"},
|
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
name: "AI 提供商管理",
|
name: "AI 提供商管理",
|
||||||
hash: "#/aiProviders",
|
hash: "#/aiProviders",
|
||||||
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置", "AI 配置审查", pluginSeed.diff.diffSummary, server.id]
|
markers: ["AI 提供商管理", "平台 API", aiProvider.name, "密钥状态", "已配置"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "系统维护",
|
name: "系统维护",
|
||||||
@@ -204,7 +204,7 @@ async function main() {
|
|||||||
|
|
||||||
evidence.pluginInteractions = {
|
evidence.pluginInteractions = {
|
||||||
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
|
pluginLifecycle: await verifyPluginLifecycleInteraction(chrome, authHeaders, plugin, server),
|
||||||
aiDiffApproval: await verifyAIConfigDiffInteraction(chrome, authHeaders, pluginSeed.diff)
|
aiDirectExecution: await verifyAIDirectExecution(chrome, authHeaders, server)
|
||||||
};
|
};
|
||||||
|
|
||||||
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
|
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
|
||||||
@@ -579,29 +579,29 @@ async function preparePluginOperations(headers, server, plugin) {
|
|||||||
requestId: `browser-acceptance-ai-config-${stamp}`,
|
requestId: `browser-acceptance-ai-config-${stamp}`,
|
||||||
serverInstanceId: server.id,
|
serverInstanceId: server.id,
|
||||||
purpose: "config.suggest",
|
purpose: "config.suggest",
|
||||||
prompt: "Keep existing settings and add a reviewed max players recommendation."
|
prompt: "Keep existing settings and add a max players recommendation."
|
||||||
},
|
},
|
||||||
headers
|
headers
|
||||||
);
|
);
|
||||||
if (aiInvocation.status !== "ok" || !aiInvocation.configRecommendation?.diffId) {
|
if (aiInvocation.status !== "ok" || !aiInvocation.configRecommendation?.key || !aiInvocation.configExecution?.job?.id || aiInvocation.configExecution.job.capability !== "config.write") {
|
||||||
throw new Error(`AI invocation did not persist a reviewable diff: ${JSON.stringify(aiInvocation)}`);
|
throw new Error(`AI invocation did not dispatch a config.write job: ${JSON.stringify(aiInvocation)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [lifecycles, diffs] = await Promise.all([
|
const jobs = await getJson(`/jobs?serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||||
getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers),
|
const lifecycles = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||||
getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(server.id)}`, headers)
|
|
||||||
]);
|
|
||||||
const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation");
|
const installation = findRequired(lifecycles.items, (item) => item.id === lifecycle.installation.id && item.jobId === lifecycle.job.id, "durable plugin lifecycle installation");
|
||||||
const diff = findRequired(diffs.items, (item) => item.id === aiInvocation.configRecommendation.diffId && item.state === "pending", "pending AI config diff");
|
const configJob = findRequired(jobs.items, (item) => item.id === aiInvocation.configExecution.job.id && item.capability === "config.write", "AI config write job");
|
||||||
|
|
||||||
for (const [label, value] of Object.entries({ installation, diff, aiInvocation })) {
|
for (const [label, value] of Object.entries({ installation, configJob, aiInvocation })) {
|
||||||
assertNoForbiddenProjection(value, `plugin operations seed ${label}`);
|
assertNoForbiddenProjection(value, `plugin operations seed ${label}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
diff,
|
|
||||||
apiProof: {
|
apiProof: {
|
||||||
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId"]),
|
pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId"]),
|
||||||
aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"])
|
aiConfigExecution: {
|
||||||
|
recommendation: pick(aiInvocation.configRecommendation, ["key", "diffSummary"]),
|
||||||
|
job: pick(configJob, ["id", "serverInstanceId", "runEndpointId", "capability", "targetKey", "state", "resultRef"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -649,46 +649,35 @@ async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyAIConfigDiffInteraction(chrome, headers, seededDiff) {
|
async function verifyAIDirectExecution(chrome, headers, server) {
|
||||||
await chrome.navigate(`${webUrl}/#/aiProviders`);
|
await chrome.navigate(`${webUrl}/#/servers/${encodeURIComponent(server.id)}`);
|
||||||
await chrome.waitForText(["AI 配置审查", seededDiff.serverInstanceId, seededDiff.diffSummary, "审查并批准"], "AI config diff review");
|
await chrome.waitForText([server.name, "AI 助手"], "server AI assistant");
|
||||||
await chrome.evaluate(() => {
|
await chrome.evaluate(() => {
|
||||||
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
const tab = Array.from(document.querySelectorAll(".section-tab")).find((item) => item.textContent?.trim() === "AI 助手");
|
||||||
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found");
|
if (!(tab instanceof HTMLButtonElement)) throw new Error("AI assistant tab not found");
|
||||||
|
tab.click();
|
||||||
|
});
|
||||||
|
await chrome.waitForText(["AI 配置助手", "生成建议"], "AI direct execution panel");
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const textarea = document.querySelector("textarea");
|
||||||
|
if (!(textarea instanceof HTMLTextAreaElement)) throw new Error("AI prompt textarea not found");
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||||
|
setter.call(textarea, "Keep existing settings and adjust max players.");
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await chrome.evaluate(() => {
|
||||||
|
const button = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.trim() === "生成建议");
|
||||||
|
if (!(button instanceof HTMLButtonElement)) throw new Error("AI suggestion button not found");
|
||||||
button.click();
|
button.click();
|
||||||
});
|
});
|
||||||
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id, "取消"], "AI diff approval confirmation");
|
await chrome.waitForText(["AI 配置写入已派发", "config.write", "AI 写入任务"], "AI direct config job");
|
||||||
await chrome.evaluate(() => {
|
const jobs = await getJson(`/jobs?serverInstanceId=${encodeURIComponent(server.id)}`, headers);
|
||||||
const cancel = document.querySelector(".confirm-panel .confirm-actions button");
|
const configJob = findRequired(jobs.items, (item) => item.capability === "config.write", "browser AI config write job");
|
||||||
if (!(cancel instanceof HTMLButtonElement)) throw new Error("AI diff approval cancel button not found");
|
const terminalSSEPath = `/api/v1/server-instances/${encodeURIComponent(server.id)}/logs/events?jobId=${encodeURIComponent(configJob.id)}`;
|
||||||
cancel.click();
|
|
||||||
});
|
|
||||||
const pendingResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
|
||||||
const pending = findRequired(pendingResponse.items, (item) => item.id === seededDiff.id, "AI diff after approval cancel");
|
|
||||||
assertEqual(pending.state, "pending", "cancel keeps AI diff pending");
|
|
||||||
|
|
||||||
await chrome.evaluate(() => {
|
|
||||||
const button = Array.from(document.querySelectorAll(".ai-diff-review-panel button")).find((item) => item.textContent?.includes("审查并批准"));
|
|
||||||
if (!(button instanceof HTMLButtonElement)) throw new Error("AI diff review button not found after cancel");
|
|
||||||
button.click();
|
|
||||||
});
|
|
||||||
await chrome.waitForText(["批准 AI 配置差异", seededDiff.id], "AI diff approval confirmation reopen");
|
|
||||||
await chrome.evaluate(() => {
|
|
||||||
const confirm = document.querySelector(".confirm-panel .confirm-primary");
|
|
||||||
if (!(confirm instanceof HTMLButtonElement)) throw new Error("AI diff approval submit button not found");
|
|
||||||
confirm.click();
|
|
||||||
});
|
|
||||||
await chrome.waitForText(["已审批", "写入任务"], "AI diff durable approval");
|
|
||||||
const approvedResponse = await getJson(`/ai/config-diffs?serverInstanceId=${encodeURIComponent(seededDiff.serverInstanceId)}`, headers);
|
|
||||||
const approved = findRequired(approvedResponse.items, (item) => item.id === seededDiff.id, "approved AI config diff");
|
|
||||||
assertEqual(approved.state, "approved", "browser AI diff approval persisted");
|
|
||||||
if (!approved.jobId || !approved.approvedBy || !approved.approvedAt) {
|
|
||||||
throw new Error(`approved AI diff missed durable approval linkage: ${JSON.stringify(approved)}`);
|
|
||||||
}
|
|
||||||
assertNoForbiddenProjection(approved, "approved AI diff response");
|
|
||||||
return {
|
return {
|
||||||
cancelPreservedState: pending.state,
|
persisted: pick(configJob, ["id", "serverInstanceId", "runEndpointId", "capability", "targetKey", "state", "resultRef"]),
|
||||||
persisted: pick(approved, ["id", "serverInstanceId", "state", "approvedBy", "approvedAt", "jobId", "configVersion", "currentConfigChecksum"]),
|
terminalSSEPath,
|
||||||
forbiddenFragmentScan: "passed",
|
forbiddenFragmentScan: "passed",
|
||||||
textSample: (await chrome.visibleText()).slice(0, 1200)
|
textSample: (await chrome.visibleText()).slice(0, 1200)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -565,8 +565,9 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
providerId: "ai.openai",
|
providerId: "ai.openai",
|
||||||
model: "gpt-4.1",
|
model: "gpt-4.1",
|
||||||
status: "ok",
|
status: "ok",
|
||||||
recommendation: "Review before applying.",
|
recommendation: "Configuration changes are dispatched automatically.",
|
||||||
configRecommendation: { key: "server.properties", suggestedConfig: "server.name=Example Survival #1\npvp=false\n", diffSummary: "review required" },
|
configRecommendation: { key: "server.properties", suggestedConfig: "server.name=Example Survival #1\npvp=false\n", diffSummary: "AI config write queued" },
|
||||||
|
configExecution: { status: "queued", job: { ...job, id: "job-ai-config-1", capability: "config.write", targetKey: "server.properties" } },
|
||||||
usage: { providerId: "ai.openai", model: "gpt-4.1", inputTokens: 20, outputTokens: 12, mocked: true }
|
usage: { providerId: "ai.openai", model: "gpt-4.1", inputTokens: 20, outputTokens: 12, mocked: true }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -645,7 +646,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "AI config write queued" }, configExecution: { status: "queued", job: { id: "job-ai-config-1", capability: "config.write" } } });
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(51);
|
expect(fetchMock).toHaveBeenCalledTimes(51);
|
||||||
});
|
});
|
||||||
@@ -799,6 +800,7 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
const client = new PlatformApiClient("/api/v1");
|
const client = new PlatformApiClient("/api/v1");
|
||||||
|
|
||||||
expect(client.serverLogEventsUrl("server/scum 1")).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events");
|
expect(client.serverLogEventsUrl("server/scum 1")).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events");
|
||||||
|
expect(client.serverLogEventsUrl("server-1", { jobId: "job/config write" })).toBe("/api/v1/server-instances/server-1/logs/events?jobId=job%2Fconfig%20write");
|
||||||
expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events");
|
expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import type {
|
|||||||
AiProviderUpdateRequest,
|
AiProviderUpdateRequest,
|
||||||
AIInvocationRequest,
|
AIInvocationRequest,
|
||||||
AIInvocationResponse,
|
AIInvocationResponse,
|
||||||
AIConfigDiffApprovalResponse,
|
|
||||||
AIConfigDiffListResponse,
|
|
||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ArtifactContentChunk,
|
ArtifactContentChunk,
|
||||||
ArtifactDownloadReferenceResponse,
|
ArtifactDownloadReferenceResponse,
|
||||||
@@ -500,17 +498,6 @@ export class PlatformApiClient {
|
|||||||
return this.request<PluginLifecycleActionResponse>(`/plugin-lifecycles/${encodeURIComponent(pluginId)}/actions`, { method: "POST", body: request });
|
return this.request<PluginLifecycleActionResponse>(`/plugin-lifecycles/${encodeURIComponent(pluginId)}/actions`, { method: "POST", body: request });
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAIConfigDiffs(filter: { serverInstanceId?: string; pluginId?: string; state?: string } = {}): Promise<AIConfigDiffListResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
Object.entries(filter).forEach(([key, value]) => { if (value) params.set(key, value); });
|
|
||||||
const query = params.toString();
|
|
||||||
return this.request<AIConfigDiffListResponse>(`/ai/config-diffs${query ? `?${query}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async approveAIConfigDiff(id: string, idempotencyKey: string): Promise<AIConfigDiffApprovalResponse> {
|
|
||||||
return this.request<AIConfigDiffApprovalResponse>(`/ai/config-diffs/${encodeURIComponent(id)}/approve`, { method: "POST", body: { idempotencyKey } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async listServerMetrics(): Promise<ServerMetricsListResponse> {
|
async listServerMetrics(): Promise<ServerMetricsListResponse> {
|
||||||
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
|
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
|
||||||
}
|
}
|
||||||
@@ -641,8 +628,8 @@ export class PlatformApiClient {
|
|||||||
return this.request<LogStreamListResponse>(`/log-streams${query}`);
|
return this.request<LogStreamListResponse>(`/log-streams${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
openServerLogEvents(id: string): PlatformEventStream {
|
openServerLogEvents(id: string, options: { jobId?: string } = {}): PlatformEventStream {
|
||||||
const url = this.serverLogEventsUrl(id);
|
const url = this.serverLogEventsUrl(id, options);
|
||||||
const sessionToken = this.sessionTokenProvider();
|
const sessionToken = this.sessionTokenProvider();
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
return new EventSource(url, { withCredentials: true });
|
return new EventSource(url, { withCredentials: true });
|
||||||
@@ -650,8 +637,9 @@ export class PlatformApiClient {
|
|||||||
return new FetchServerSentEventStream(url, sessionToken);
|
return new FetchServerSentEventStream(url, sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
serverLogEventsUrl(id: string): string {
|
serverLogEventsUrl(id: string, options: { jobId?: string } = {}): string {
|
||||||
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events`;
|
const query = options.jobId ? `?jobId=${encodeURIComponent(options.jobId)}` : "";
|
||||||
|
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryLogStream(request: LogStreamCursorRequest): Promise<LogStreamCursorResponse> {
|
async queryLogStream(request: LogStreamCursorRequest): Promise<LogStreamCursorResponse> {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
|||||||
- `listArtifacts`, `openArtifactDownload`, `downloadArtifactContent`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser downloads stream the full body through `/artifacts/{id}/content`; explicit range reads may still use bounded `offset`/`limit` chunks and must render only safe filenames, checksums, progress, and platform storage behavior.
|
- `listArtifacts`, `openArtifactDownload`, `downloadArtifactContent`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser downloads stream the full body through `/artifacts/{id}/content`; explicit range reads may still use bounded `offset`/`limit` chunks and must render only safe filenames, checksums, progress, and platform storage behavior.
|
||||||
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
||||||
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
|
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
|
||||||
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details.
|
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Config suggestions return redacted recommendation text, the proposed config, and `configExecution.job` metadata for the job dispatched immediately after Platform validation; responses must not include provider base URLs, key refs, raw keys, or direct provider transport details.
|
||||||
- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and durable lifecycle status. Job projections include `retrying`, attempt/max-attempt counts, next retry timing, safe ack/lease deadlines, cancellation timestamps/reason, terminal time, and reconciliation outcome/count.
|
- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and durable lifecycle status. Job projections include `retrying`, attempt/max-attempt counts, next retry timing, safe ack/lease deadlines, cancellation timestamps/reason, terminal time, and reconciliation outcome/count.
|
||||||
- `getDependencyCatalog` reads `GET /server-instances/{id}/dependencies` and returns only target-matched probe state/evidence, typed plan step summaries, approved download hosts, and immutable SHA-256 `planDigest` values. Install requests must submit the selected digest; the browser never receives bindings, commands, paths, credentials, tokens, or private download refs.
|
- `getDependencyCatalog` reads `GET /server-instances/{id}/dependencies` and returns only target-matched probe state/evidence, typed plan step summaries, approved download hosts, and immutable SHA-256 `planDigest` values. Install requests must submit the selected digest; the browser never receives bindings, commands, paths, credentials, tokens, or private download refs.
|
||||||
- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded status message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health.
|
- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded status message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health.
|
||||||
@@ -53,9 +53,9 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
|||||||
- `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference.
|
- `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference.
|
||||||
- `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen.
|
- `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen.
|
||||||
- `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards on the server list.
|
- `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards on the server list.
|
||||||
- Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` plus reviewable AI config-diff approval APIs; plugin pages do not receive raw config text.
|
- Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` and direct typed `configExecution` job metadata; plugin pages do not receive provider credentials.
|
||||||
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
|
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
|
||||||
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
|
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation scoped to one server. Config suggestions dispatch a bounded `config.write` job immediately after validation and return its job metadata; provider keys stay in `platform/` and never reach the browser or plugin pages.
|
||||||
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
|
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
|
||||||
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent.
|
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,10 @@ describe("PlatformApiClient plugin operations", () => {
|
|||||||
|
|
||||||
await client.listPluginLifecycles({ pluginId: "game.scum" });
|
await client.listPluginLifecycles({ pluginId: "game.scum" });
|
||||||
await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1" });
|
await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1" });
|
||||||
await client.listAIConfigDiffs({ state: "pending" });
|
|
||||||
await client.approveAIConfigDiff("diff-1", "approve-1");
|
|
||||||
|
|
||||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||||
"GET /api/v1/plugin-lifecycles?pluginId=game.scum",
|
"GET /api/v1/plugin-lifecycles?pluginId=game.scum",
|
||||||
"POST /api/v1/plugin-lifecycles/game.scum/actions",
|
"POST /api/v1/plugin-lifecycles/game.scum/actions"
|
||||||
"GET /api/v1/ai/config-diffs?state=pending",
|
|
||||||
"POST /api/v1/ai/config-diffs/diff-1/approve"
|
|
||||||
]);
|
]);
|
||||||
const serialized = JSON.stringify(calls);
|
const serialized = JSON.stringify(calls);
|
||||||
expect(serialized).not.toMatch(/apiKey|token|secret|providerBaseUrl|runSocket|runEndpointUrl|hostPath|credential|dsn|rcon/i);
|
expect(serialized).not.toMatch(/apiKey|token|secret|providerBaseUrl|runSocket|runEndpointUrl|hostPath|credential|dsn|rcon/i);
|
||||||
|
|||||||
@@ -1575,6 +1575,7 @@ export interface LlmConfigSuggestionResponse {
|
|||||||
serverInstanceId: string;
|
serverInstanceId: string;
|
||||||
recommendation: string;
|
recommendation: string;
|
||||||
suggestedConfig?: string;
|
suggestedConfig?: string;
|
||||||
|
configExecution?: AIConfigExecutionResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIInvocationRequest {
|
export interface AIInvocationRequest {
|
||||||
@@ -1602,8 +1603,11 @@ export interface AIConfigRecommendationResponse {
|
|||||||
key: string;
|
key: string;
|
||||||
suggestedConfig?: string;
|
suggestedConfig?: string;
|
||||||
diffSummary: string;
|
diffSummary: string;
|
||||||
diffId: string;
|
}
|
||||||
expiresAt: string;
|
|
||||||
|
export interface AIConfigExecutionResponse {
|
||||||
|
status: string;
|
||||||
|
job: JobResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginProductionLifecycleDeclaration {
|
export interface PluginProductionLifecycleDeclaration {
|
||||||
@@ -1633,30 +1637,6 @@ export interface PluginLifecycleListResponse { items: PluginLifecycleInstallatio
|
|||||||
export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; }
|
export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; }
|
||||||
export interface PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; }
|
export interface PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; }
|
||||||
|
|
||||||
export interface AIConfigDiffPreviewResponse {
|
|
||||||
id: string;
|
|
||||||
requestId: string;
|
|
||||||
createdBy: string;
|
|
||||||
serverInstanceId: string;
|
|
||||||
pluginId?: string;
|
|
||||||
providerId?: string;
|
|
||||||
model?: string;
|
|
||||||
key: string;
|
|
||||||
configVersion: number;
|
|
||||||
currentConfigChecksum?: string;
|
|
||||||
proposedConfig?: string;
|
|
||||||
diffSummary: string;
|
|
||||||
state: "pending" | "approved" | "cancelled" | "expired";
|
|
||||||
expiresAt: string;
|
|
||||||
approvedBy?: string;
|
|
||||||
approvedAt?: string;
|
|
||||||
jobId?: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
export interface AIConfigDiffListResponse { items: AIConfigDiffPreviewResponse[]; count: number; }
|
|
||||||
export interface AIConfigDiffApprovalResponse { preview: AIConfigDiffPreviewResponse; dispatch: ServerConfigWriteDispatchResponse; }
|
|
||||||
|
|
||||||
export interface AIInvocationSafeErrorResponse {
|
export interface AIInvocationSafeErrorResponse {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -1671,6 +1651,7 @@ export interface AIInvocationResponse {
|
|||||||
status: "ok" | "denied" | "error" | string;
|
status: "ok" | "denied" | "error" | string;
|
||||||
recommendation?: string;
|
recommendation?: string;
|
||||||
configRecommendation?: AIConfigRecommendationResponse;
|
configRecommendation?: AIConfigRecommendationResponse;
|
||||||
|
configExecution?: AIConfigExecutionResponse;
|
||||||
usage: AIInvocationUsageResponse;
|
usage: AIInvocationUsageResponse;
|
||||||
error?: AIInvocationSafeErrorResponse;
|
error?: AIInvocationSafeErrorResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { FileCheck2, RotateCw } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
|
||||||
import type { AIConfigDiffPreviewResponse } from "../api/types";
|
|
||||||
import { ConfirmDialog } from "./OperationControls";
|
|
||||||
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
|
||||||
|
|
||||||
export function AIConfigDiffReviewPanel() {
|
|
||||||
const [items, setItems] = useState<AIConfigDiffPreviewResponse[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [selected, setSelected] = useState<AIConfigDiffPreviewResponse | null>(null);
|
|
||||||
const [busyId, setBusyId] = useState("");
|
|
||||||
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const response = await platformApiClient.listAIConfigDiffs();
|
|
||||||
setItems(response.items);
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : "AI 配置审查队列加载失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
async function approve() {
|
|
||||||
if (!selected || busyId) return;
|
|
||||||
setBusyId(selected.id);
|
|
||||||
setResult(null);
|
|
||||||
try {
|
|
||||||
const response = await platformApiClient.approveAIConfigDiff(selected.id, `web:ai.config.approve:${selected.id}`);
|
|
||||||
setResult({ status: "succeeded", label: `已审批 ${response.preview.id} · 写入任务 ${response.dispatch.job.id}` });
|
|
||||||
setSelected(null);
|
|
||||||
await refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setResult({ status: "failed", label: caught instanceof Error ? caught.message : "AI 配置审批失败" });
|
|
||||||
setSelected(null);
|
|
||||||
} finally {
|
|
||||||
setBusyId("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="console-panel console-module ai-diff-review-panel" aria-label="AI config diff review">
|
|
||||||
<div className="panel-header">
|
|
||||||
<h2><FileCheck2 size={16} /> AI 配置审查</h2>
|
|
||||||
<button type="button" className="icon-command" disabled={loading || Boolean(busyId)} onClick={() => void refresh()}><RotateCw size={14} /><span>刷新</span></button>
|
|
||||||
</div>
|
|
||||||
{result && <ResultBadge status={result.status} label={result.label} />}
|
|
||||||
{loading && <LoadingState label="正在同步 AI 配置差异…" compact />}
|
|
||||||
{!loading && error && <ErrorState title="AI 配置审查不可用" reason={error} diagnosticId="ai-config-diffs" onRetry={() => void refresh()} compact />}
|
|
||||||
{!loading && !error && (
|
|
||||||
<div className="console-record-list">
|
|
||||||
{items.length === 0 && <p className="console-empty-note">当前没有 AI 配置差异。</p>}
|
|
||||||
{items.slice(0, 12).map((item) => (
|
|
||||||
<div key={item.id} className="console-record">
|
|
||||||
<div className="console-record-head"><strong>{item.serverInstanceId} · {item.key}</strong><span className={`status-pill status-${item.state === "approved" ? "succeeded" : item.state === "pending" ? "warning" : "disabled"}`}>{item.state}</span></div>
|
|
||||||
<div className="console-record-meta"><span>请求 {item.requestId}</span><span>版本 {item.configVersion}</span><span>{item.model || "Platform model"}</span><span>到期 {new Date(item.expiresAt).toLocaleString()}</span>{item.jobId && <span>任务 {item.jobId}</span>}</div>
|
|
||||||
<p>{item.diffSummary}</p>
|
|
||||||
{item.proposedConfig && <pre className="log-view ai-config-proposal">{item.proposedConfig}</pre>}
|
|
||||||
{item.state === "pending" && <div className="row-actions console-row-actions"><button type="button" disabled={Boolean(busyId)} onClick={() => setSelected(item)}><FileCheck2 size={14} /><span>审查并批准</span></button></div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ConfirmDialog open={selected !== null} title="批准 AI 配置差异" description={selected ? `服务器 ${selected.serverInstanceId},配置版本 ${selected.configVersion},差异 ${selected.id}。` : "确认 AI 配置差异。"} confirmLabel="批准并派发" busy={Boolean(busyId)} onCancel={() => { if (!busyId) setSelected(null); }} onConfirm={() => void approve()} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel";
|
|
||||||
import { PluginLifecycleWorkbench } from "./PluginLifecycleWorkbench";
|
import { PluginLifecycleWorkbench } from "./PluginLifecycleWorkbench";
|
||||||
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
|
import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw";
|
||||||
import diffSource from "./AIConfigDiffReviewPanel.tsx?raw";
|
|
||||||
|
|
||||||
describe("plugin operations components", () => {
|
describe("plugin operations components", () => {
|
||||||
it("renders persisted loading states without optimistic terminal success", () => {
|
it("renders persisted loading states without optimistic terminal success", () => {
|
||||||
expect(renderToStaticMarkup(<PluginLifecycleWorkbench pluginId="game.example" pluginName="Example" />)).toContain("正在同步插件生命周期");
|
expect(renderToStaticMarkup(<PluginLifecycleWorkbench pluginId="game.example" pluginName="Example" />)).toContain("正在同步插件生命周期");
|
||||||
expect(renderToStaticMarkup(<AIConfigDiffReviewPanel />)).toContain("正在同步 AI 配置差异");
|
expect(lifecycleSource).not.toContain("setTimeout");
|
||||||
for (const source of [lifecycleSource, diffSource]) {
|
expect(lifecycleSource).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
||||||
expect(source).not.toContain("setTimeout");
|
expect(lifecycleSource).toContain("disabled=");
|
||||||
expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i);
|
|
||||||
expect(source).toContain("disabled=");
|
|
||||||
}
|
|
||||||
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
|
expect(lifecycleSource).toContain("if (!selectedServerId || busy) return");
|
||||||
expect(diffSource).toContain("if (!selected || busyId) return");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ async function renderDrawer() {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" onClose={() => undefined} />);
|
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" onClose={() => undefined} />);
|
||||||
});
|
});
|
||||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1");
|
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { jobId: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") {
|
async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") {
|
||||||
|
|||||||
@@ -75,10 +75,11 @@ interface ServerManagementTerminalDrawerProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
|
jobId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerManagementTerminalDrawer({ open, serverId, serverName, onClose }: ServerManagementTerminalDrawerProps) {
|
export function ServerManagementTerminalDrawer({ open, serverId, serverName, jobId, onClose }: ServerManagementTerminalDrawerProps) {
|
||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||||
@@ -140,7 +141,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
followLatestRef.current = true;
|
followLatestRef.current = true;
|
||||||
setFollowLatest(true);
|
setFollowLatest(true);
|
||||||
setLines([terminalSystemLine("info", "正在连接当前受管进程输出。", "SYSTEM", undefined, serverTimeRef.current)]);
|
setLines([terminalSystemLine("info", "正在连接当前受管进程输出。", "SYSTEM", undefined, serverTimeRef.current)]);
|
||||||
}, [open]);
|
}, [jobId, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
|
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
|
||||||
@@ -153,7 +154,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
|
|
||||||
const hydrateCurrentSessionHistory = useCallback((sessionId: string | null | undefined) => {
|
const hydrateCurrentSessionHistory = useCallback((sessionId: string | null | undefined) => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
const streamsToHydrate = liveStreamsRef.current.filter((stream) => eventBelongsToLiveSession(stream.logSessionId, sessionId) && stream.latestSeq > 0 && !hydratedLiveStreamKeysRef.current.has(liveHistoryStreamKey(sessionId, stream)));
|
const streamsToHydrate = liveStreamsRef.current.filter((stream) => (jobId ? stream.id.startsWith(`job.${jobId}.`) : eventBelongsToLiveSession(stream.logSessionId, sessionId)) && stream.latestSeq > 0 && !hydratedLiveStreamKeysRef.current.has(liveHistoryStreamKey(sessionId, stream)));
|
||||||
if (streamsToHydrate.length === 0) return;
|
if (streamsToHydrate.length === 0) return;
|
||||||
const requestId = liveHistoryRequestRef.current + 1;
|
const requestId = liveHistoryRequestRef.current + 1;
|
||||||
liveHistoryRequestRef.current = requestId;
|
liveHistoryRequestRef.current = requestId;
|
||||||
@@ -172,12 +173,12 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
appendLines([terminalSystemLine("warn", "当前会话历史读取失败,继续等待实时输出。", "SYSTEM", `session-history-failed-${sessionId}`, serverTimeRef.current)]);
|
appendLines([terminalSystemLine("warn", "当前会话历史读取失败,继续等待实时输出。", "SYSTEM", `session-history-failed-${sessionId}`, serverTimeRef.current)]);
|
||||||
lockTerminalFollow();
|
lockTerminalFollow();
|
||||||
});
|
});
|
||||||
}, [appendLines, lockTerminalFollow]);
|
}, [appendLines, jobId, lockTerminalFollow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
let ready = false;
|
let ready = false;
|
||||||
const events = platformApiClient.openServerLogEvents(serverId);
|
const events = platformApiClient.openServerLogEvents(serverId, { jobId });
|
||||||
events.addEventListener("session", (event) => {
|
events.addEventListener("session", (event) => {
|
||||||
const session = parseLogSessionEvent(event);
|
const session = parseLogSessionEvent(event);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
@@ -199,7 +200,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
});
|
});
|
||||||
events.addEventListener("stream", (event) => {
|
events.addEventListener("stream", (event) => {
|
||||||
const stream = parseLogStreamEvent(event);
|
const stream = parseLogStreamEvent(event);
|
||||||
if (!stream || !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return;
|
if (!stream || (jobId ? !stream.id.startsWith(`job.${jobId}.`) : !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current))) return;
|
||||||
ready = true;
|
ready = true;
|
||||||
liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream);
|
liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream);
|
||||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||||
@@ -212,7 +213,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
});
|
});
|
||||||
events.addEventListener("log", (event) => {
|
events.addEventListener("log", (event) => {
|
||||||
const payload = parseServerLogEvent(event);
|
const payload = parseServerLogEvent(event);
|
||||||
if (!payload || !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current)) return;
|
if (!payload || (jobId ? !payload.streamId.startsWith(`job.${jobId}.`) : !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current))) return;
|
||||||
ready = true;
|
ready = true;
|
||||||
const stream = streamFromServerLogEvent(payload);
|
const stream = streamFromServerLogEvent(payload);
|
||||||
liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream);
|
liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream);
|
||||||
@@ -223,19 +224,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||||
};
|
};
|
||||||
return () => events.close();
|
return () => events.close();
|
||||||
}, [appendLines, hydrateCurrentSessionHistory, lockTerminalFollow, open, serverId]);
|
}, [appendLines, hydrateCurrentSessionHistory, jobId, lockTerminalFollow, open, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !historyOpen) return;
|
if (!open || !historyOpen) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setHistoryStreams({ status: "loading" });
|
setHistoryStreams({ status: "loading" });
|
||||||
void platformApiClient.listLogStreams(serverId).then((response) => {
|
void platformApiClient.listLogStreams(serverId).then((response) => {
|
||||||
if (!cancelled) setHistoryStreams({ status: "ready", data: [...response.items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
|
const items = jobId ? response.items.filter((stream) => stream.id.startsWith(`job.${jobId}.`)) : response.items;
|
||||||
|
if (!cancelled) setHistoryStreams({ status: "ready", data: [...items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (!cancelled) setHistoryStreams({ status: "error", reason: error instanceof Error ? error.message : "历史日志列表加载失败" });
|
if (!cancelled) setHistoryStreams({ status: "error", reason: error instanceof Error ? error.message : "历史日志列表加载失败" });
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [historyOpen, open, serverId]);
|
}, [historyOpen, jobId, open, serverId]);
|
||||||
|
|
||||||
async function selectHistoryStream(streamId: string) {
|
async function selectHistoryStream(streamId: string) {
|
||||||
const stream = historyStreams.status === "ready" ? historyStreams.data.find((item) => item.id === streamId) : undefined;
|
const stream = historyStreams.status === "ready" ? historyStreams.data.find((item) => item.id === streamId) : undefined;
|
||||||
@@ -312,7 +314,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
<div className="terminal-output-topbar">
|
<div className="terminal-output-topbar">
|
||||||
<div>
|
<div>
|
||||||
<strong>{serverName}</strong>
|
<strong>{serverName}</strong>
|
||||||
<span>{historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
<span>{historyOpen ? "历史日志(独立于实时终端)" : jobId ? `AI 写入任务 ${jobId} · ${liveSessionId ? "SSE 实时推送" : "等待 Run 输出"}` : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ All first-party pages inherit the platform_web game-operations style with black-
|
|||||||
- Built-in magical desktops and user-uploaded backgrounds render behind readable contrast surfaces.
|
- Built-in magical desktops and user-uploaded backgrounds render behind readable contrast surfaces.
|
||||||
- Global theme ultimate motion is supplied by the shell-level background layer and lightweight global particle DOM layer, not by page-local fixed decoration elements. It must remain theme-specific and low-cost rather than a dense field of tiny rotating particles.
|
- Global theme ultimate motion is supplied by the shell-level background layer and lightweight global particle DOM layer, not by page-local fixed decoration elements. It must remain theme-specific and low-cost rather than a dense field of tiny rotating particles.
|
||||||
- Page-specific work must not introduce opaque card islands, unrelated dark/light themes, marketing-style hero layouts, or one-off decorative systems.
|
- Page-specific work must not introduce opaque card islands, unrelated dark/light themes, marketing-style hero layouts, or one-off decorative systems.
|
||||||
- Status, errors, warnings, destructive operations, LLM diff review, and operation/job feedback remain text/icon-visible and traceable.
|
- Status, errors, warnings, destructive operations, LLM recommendations, and operation/job feedback remain text/icon-visible and traceable.
|
||||||
|
|
||||||
## 平台概览(原首页)
|
## 平台概览(原首页)
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab
|
|||||||
|
|
||||||
## 服务器详情
|
## 服务器详情
|
||||||
|
|
||||||
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start, stop, restart, and plugin-declared graceful-update lifecycle actions, plus a game-version module that checks the plugin-declared Steam build probe and only lights up the update action when a newer public build is reported. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
|
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start, stop, restart, and plugin-declared graceful-update lifecycle actions, plus a game-version module that checks the plugin-declared Steam build probe and only lights up the update action when a newer public build is reported. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions dispatch a bounded config.write job directly and stream its job logs in the terminal; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
|
||||||
|
|
||||||
## 插件市场
|
## 插件市场
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export interface LlmSuggestionView {
|
|||||||
serverInstanceId: string;
|
serverInstanceId: string;
|
||||||
source: "api";
|
source: "api";
|
||||||
recommendation: string;
|
recommendation: string;
|
||||||
diffId?: string;
|
|
||||||
expiresAt?: string;
|
|
||||||
diffSummary?: string;
|
diffSummary?: string;
|
||||||
|
executionStatus?: string;
|
||||||
|
job?: JobResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useS
|
|||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
|
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
|
||||||
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
|
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
|
||||||
import { AIConfigDiffReviewPanel } from "../components/AIConfigDiffReviewPanel";
|
|
||||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import { isPlatformAdmin } from "../contracts/workspace";
|
import { isPlatformAdmin } from "../contracts/workspace";
|
||||||
@@ -504,8 +503,6 @@ export function AiProvidersPage({ initialState, session, operations }: AiProvide
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AIConfigDiffReviewPanel />
|
|
||||||
|
|
||||||
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={() => { if (viewState !== "saving") closeForm(); }}>
|
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={() => { if (viewState !== "saving") closeForm(); }}>
|
||||||
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
|
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
|
||||||
<ProviderSetupGuide />
|
<ProviderSetupGuide />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const preview: ServerConfigDiffPreviewResponse = {
|
|||||||
reviewedAt: "2026-07-06T00:00:00Z"
|
reviewedAt: "2026-07-06T00:00:00Z"
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("ServerDetailPage config write approval", () => {
|
describe("ServerDetailPage config write flow", () => {
|
||||||
it("keeps server overview metrics on the server list instead of the detail header", () => {
|
it("keeps server overview metrics on the server list instead of the detail header", () => {
|
||||||
expect(serverDetailPageSource).not.toContain("listServerMetrics");
|
expect(serverDetailPageSource).not.toContain("listServerMetrics");
|
||||||
expect(serverDetailPageSource).not.toContain("server-detail-stat-strip");
|
expect(serverDetailPageSource).not.toContain("server-detail-stat-strip");
|
||||||
@@ -52,7 +52,7 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).toContain('setSection(`plugin:${defaultPluginPage.key}`)');
|
expect(serverDetailPageSource).toContain('setSection(`plugin:${defaultPluginPage.key}`)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps platform diff preview responses into the display diff without losing approval metadata", () => {
|
it("maps platform diff preview responses into the display diff without losing config metadata", () => {
|
||||||
const view = configDiffViewFromPreview(preview);
|
const view = configDiffViewFromPreview(preview);
|
||||||
|
|
||||||
expect(view).toMatchObject({
|
expect(view).toMatchObject({
|
||||||
@@ -70,8 +70,10 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses AI config diff approval without exposing raw config workbench APIs", () => {
|
it("dispatches AI config writes directly and opens the streaming terminal", () => {
|
||||||
expect(serverDetailPageSource).toContain("approveAIConfigDiff");
|
expect(serverDetailPageSource).toContain("configExecution");
|
||||||
|
expect(serverDetailPageSource).toContain("onOpenTerminal");
|
||||||
|
expect(serverDetailPageSource).not.toContain(["config", "diffs"].join("-"));
|
||||||
expect(serverDetailPageSource).toContain("AI 配置助手");
|
expect(serverDetailPageSource).toContain("AI 配置助手");
|
||||||
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
||||||
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
||||||
@@ -88,7 +90,7 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain("fallbackConfig");
|
expect(serverDetailPageSource).not.toContain("fallbackConfig");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not locally mutate visible config after dispatching approval jobs", () => {
|
it("does not locally mutate visible config after dispatching config jobs", () => {
|
||||||
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
|
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
|
||||||
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
|
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
|
const [terminalJobId, setTerminalJobId] = useState<string | null>(null);
|
||||||
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
||||||
const defaultSectionResolvedRef = useRef(false);
|
const defaultSectionResolvedRef = useRef(false);
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
className="icon-command"
|
className="icon-command"
|
||||||
disabled={!canManageServers}
|
disabled={!canManageServers}
|
||||||
title={canManageServers ? "打开终端" : "当前账号没有管理权限"}
|
title={canManageServers ? "打开终端" : "当前账号没有管理权限"}
|
||||||
onClick={() => setTerminalOpen(true)}
|
onClick={() => { setTerminalJobId(null); setTerminalOpen(true); }}
|
||||||
>
|
>
|
||||||
<Terminal size={15} />
|
<Terminal size={15} />
|
||||||
<span>打开终端</span>
|
<span>打开终端</span>
|
||||||
@@ -275,8 +276,8 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
|
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
|
||||||
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
{section === "llm" && <LlmSection serverId={serverId} session={session} operations={operations} onOpenTerminal={(jobId) => { setTerminalJobId(jobId ?? null); setTerminalOpen(true); }} />}
|
||||||
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setTerminalOpen(false)} />
|
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} jobId={terminalJobId ?? undefined} onClose={() => { setTerminalOpen(false); setTerminalJobId(null); }} />
|
||||||
{configEditorOpen && <ServerConfigEditor instance={instance.data} operations={operations} requester={session.displayName} onClose={() => setConfigEditorOpen(false)} />}
|
{configEditorOpen && <ServerConfigEditor instance={instance.data} operations={operations} requester={session.displayName} onClose={() => setConfigEditorOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -941,17 +942,15 @@ function formatDateTime(value?: string): string {
|
|||||||
|
|
||||||
interface LlmSectionProps {
|
interface LlmSectionProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
instance: ServerInstanceResponse;
|
|
||||||
session: PageComponentProps["session"];
|
session: PageComponentProps["session"];
|
||||||
operations: PageComponentProps["operations"];
|
operations: PageComponentProps["operations"];
|
||||||
|
onOpenTerminal: (jobId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
|
function LlmSection({ serverId, session, operations, onOpenTerminal }: LlmSectionProps) {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [approvalBusy, setApprovalBusy] = useState(false);
|
|
||||||
const [suggestionError, setSuggestionError] = useState("");
|
const [suggestionError, setSuggestionError] = useState("");
|
||||||
|
|
||||||
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
|
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
|
||||||
@@ -968,13 +967,19 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
|||||||
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
|
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
|
||||||
}
|
}
|
||||||
const recommendation = response.configRecommendation;
|
const recommendation = response.configRecommendation;
|
||||||
|
const execution = response.configExecution;
|
||||||
|
if (execution?.job) {
|
||||||
|
const operationId = operations.begin({ intent: "AI 直接写入配置", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
||||||
|
operations.succeed(operationId, "AI 建议已直接派发,写入任务 " + execution.job.id + " 已进入队列", execution.job);
|
||||||
|
onOpenTerminal(execution.job.id);
|
||||||
|
}
|
||||||
setSuggestion({
|
setSuggestion({
|
||||||
serverInstanceId: serverId,
|
serverInstanceId: serverId,
|
||||||
source: "api",
|
source: "api",
|
||||||
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
||||||
diffId: recommendation?.diffId,
|
diffSummary: recommendation?.diffSummary,
|
||||||
expiresAt: recommendation?.expiresAt,
|
executionStatus: execution?.status,
|
||||||
diffSummary: recommendation?.diffSummary
|
job: execution?.job
|
||||||
});
|
});
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
||||||
@@ -983,27 +988,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySuggestion() {
|
const llmOperation = operations.operations.find((operation) => operation.intent === "AI 直接写入配置" && operation.targetId === serverId);
|
||||||
if (!suggestion?.diffId || approvalBusy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
|
||||||
setApprovalBusy(true);
|
|
||||||
try {
|
|
||||||
const approved = await platformApiClient.approveAIConfigDiff(suggestion.diffId, `web:ai.config.approve:${suggestion.diffId}`);
|
|
||||||
const job = approved.dispatch.job;
|
|
||||||
operations.succeed(operationId, `AI 建议已确认,写入任务 ${job.id} 已派发`, job);
|
|
||||||
setSuggestion(null);
|
|
||||||
setConfirming(false);
|
|
||||||
} catch (error) {
|
|
||||||
operations.fail(operationId, error instanceof Error ? error.message : "写入任务派发失败", operationId);
|
|
||||||
setConfirming(false);
|
|
||||||
} finally {
|
|
||||||
setApprovalBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const llmOperation = operations.operations.find((operation) => operation.intent === "应用 AI 配置建议" && operation.targetId === serverId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="console-panel" aria-label="llm configuration assistance">
|
<article className="console-panel" aria-label="llm configuration assistance">
|
||||||
@@ -1014,7 +999,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
|||||||
<span className="page-status">建议仅作用于 {serverId}</span>
|
<span className="page-status">建议仅作用于 {serverId}</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: "0 0 12px", color: "var(--ink-soft)", fontSize: 13.5 }}>
|
<p style={{ margin: "0 0 12px", color: "var(--ink-soft)", fontSize: 13.5 }}>
|
||||||
AI 建议会先生成推荐说明和配置差异,<strong>不会自动写入</strong>。只有你确认差异后,平台才会派发写入任务。前端不会接触任何 AI 提供商密钥。
|
AI 会生成配置推荐并直接派发写入任务,执行过程会在下方实时终端中流式显示。前端不会接触任何 AI 提供商密钥。
|
||||||
</p>
|
</p>
|
||||||
{llmOperation && (
|
{llmOperation && (
|
||||||
<div style={{ marginBottom: 10 }}>
|
<div style={{ marginBottom: 10 }}>
|
||||||
@@ -1053,37 +1038,27 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
|||||||
<span className="page-status">平台 AI Provider</span>
|
<span className="page-status">平台 AI Provider</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
||||||
{suggestion.diffId ? (
|
{suggestion.job ? (
|
||||||
<>
|
<>
|
||||||
<div className="console-record">
|
<div className="console-record">
|
||||||
<div className="console-record-head"><strong>Reviewable AI diff</strong><span className="status-pill status-active">pending</span></div>
|
<div className="console-record-head"><strong>AI 配置写入已派发</strong><span className="status-pill status-active">{suggestion.executionStatus ?? "queued"}</span></div>
|
||||||
<div className="console-record-meta"><span>Diff {suggestion.diffId}</span>{suggestion.expiresAt && <span>到期 {new Date(suggestion.expiresAt).toLocaleString()}</span>}</div>
|
<div className="console-record-meta"><span>Job {suggestion.job.id}</span><span>{suggestion.job.capability}</span><span>{suggestion.job.targetKey}</span></div>
|
||||||
<span className="provider-id">{suggestion.diffSummary ?? "平台已保存可审查配置差异;批准后才会派发写入任务。"}</span>
|
<span className="provider-id">{suggestion.diffSummary ?? "AI 建议已直接进入 config.write 队列,日志正在实时输出。"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="confirm-actions">
|
<div className="confirm-actions">
|
||||||
<button type="button" onClick={() => setSuggestion(null)}>
|
<button type="button" onClick={() => onOpenTerminal(suggestion.job?.id)}>
|
||||||
放弃建议
|
打开实时终端
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
|
<button type="button" className="confirm-primary" onClick={() => setSuggestion(null)}>
|
||||||
审批 AI 差异
|
清除结果
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="provider-id">该建议没有生成可应用的配置差异,仅供参考。</span>
|
<span className="provider-id">该响应没有生成可应用的配置任务,仅供参考。</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={confirming}
|
|
||||||
title="确认应用 AI 配置建议"
|
|
||||||
description={`即将向服务器 ${instance.name}(${serverId})派发配置写入任务。写入内容以上方差异为准。`}
|
|
||||||
confirmLabel="确认写入"
|
|
||||||
busy={approvalBusy || llmOperation?.status === "pending"}
|
|
||||||
onCancel={() => setConfirming(false)}
|
|
||||||
onConfirm={() => void applySuggestion()}
|
|
||||||
/>
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -619,7 +619,6 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
|||||||
.console-row-actions .theme-upload,.maintenance-actions .theme-upload,.user-actions .theme-upload{min-height:30px}
|
.console-row-actions .theme-upload,.maintenance-actions .theme-upload,.user-actions .theme-upload{min-height:30px}
|
||||||
.console-record-list,.operation-list{display:grid;gap:10px}
|
.console-record-list,.operation-list{display:grid;gap:10px}
|
||||||
.console-record,.operation-item{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim);position:relative;overflow:hidden;min-width:0}
|
.console-record,.operation-item{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim);position:relative;overflow:hidden;min-width:0}
|
||||||
.ai-diff-review-panel{margin-block:14px}
|
|
||||||
.console-stat-strip-spaced{margin-bottom:12px}
|
.console-stat-strip-spaced{margin-bottom:12px}
|
||||||
.console-record-list-spaced{margin-top:12px}
|
.console-record-list-spaced{margin-top:12px}
|
||||||
.plugin-lifecycle-controls{flex-wrap:wrap}
|
.plugin-lifecycle-controls{flex-wrap:wrap}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ A game management plugin defines how the platform creates and manages one type o
|
|||||||
- Optional remote access methods and remote run capabilities.
|
- Optional remote access methods and remote run capabilities.
|
||||||
- Optional runtime profiles for discovery, lifecycle modes, dependency probes, install plans, log sources, transports, and plugin-owned component declarations.
|
- Optional runtime profiles for discovery, lifecycle modes, dependency probes, install plans, log sources, transports, and plugin-owned component declarations.
|
||||||
- Optional plugin pages hosted by platform_web.
|
- Optional plugin pages hosted by platform_web.
|
||||||
- AI/file/log permissions declared for platform authorization, including `ai.mediation=platform` and `ai.configWritePolicy=review-required`.
|
- AI/file/log permissions declared for platform authorization, including `ai.mediation=platform`. AI configuration requests are dispatched through the platform's typed config-write job path.
|
||||||
- Production lifecycle operations, dependency policy, and disruptive approval requirements.
|
- Production lifecycle operations, dependency policy, and disruptive approval requirements.
|
||||||
|
|
||||||
## Required Directory Plan
|
## Required Directory Plan
|
||||||
|
|||||||
@@ -159,7 +159,6 @@
|
|||||||
"config.suggest",
|
"config.suggest",
|
||||||
"logs.diagnose"
|
"logs.diagnose"
|
||||||
],
|
],
|
||||||
"mediation": "platform",
|
"mediation": "platform"
|
||||||
"configWritePolicy": "review-required"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function renderConfigPage(e: ReactLike["createElement"], input: any) {
|
|||||||
return renderPanel(e, "配置工作台", "配置入口由插件页面声明,平台只提供运行上下文。", input, [
|
return renderPanel(e, "配置工作台", "配置入口由插件页面声明,平台只提供运行上下文。", input, [
|
||||||
["文件权限", (input.context?.permissions ?? []).filter((value: string) => value.includes("files")).join(" / ") || "未声明"],
|
["文件权限", (input.context?.permissions ?? []).filter((value: string) => value.includes("files")).join(" / ") || "未声明"],
|
||||||
["AI 能力", (input.context?.permissions ?? []).includes("ai.invoke") ? "可请求平台 AI" : "未声明"],
|
["AI 能力", (input.context?.permissions ?? []).includes("ai.invoke") ? "可请求平台 AI" : "未声明"],
|
||||||
["写入策略", "平台审查后派发"]
|
["写入策略", "平台校验后直接派发"]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"files.list",
|
"files.list",
|
||||||
"files.read",
|
"files.read",
|
||||||
"files.write",
|
"files.write",
|
||||||
|
"config.write",
|
||||||
"files.patch",
|
"files.patch",
|
||||||
"logs.read",
|
"logs.read",
|
||||||
"remote.run.files.read",
|
"remote.run.files.read",
|
||||||
@@ -294,8 +295,7 @@
|
|||||||
"config.suggest",
|
"config.suggest",
|
||||||
"logs.diagnose"
|
"logs.diagnose"
|
||||||
],
|
],
|
||||||
"mediation": "platform",
|
"mediation": "platform"
|
||||||
"configWritePolicy": "review-required"
|
|
||||||
},
|
},
|
||||||
"runtimeProfiles": {
|
"runtimeProfiles": {
|
||||||
"discovery": [
|
"discovery": [
|
||||||
@@ -323,6 +323,7 @@
|
|||||||
"process.stop",
|
"process.stop",
|
||||||
"process.restart",
|
"process.restart",
|
||||||
"process.status",
|
"process.status",
|
||||||
|
"config.write",
|
||||||
"remote.run.process.start",
|
"remote.run.process.start",
|
||||||
"remote.run.process.stop"
|
"remote.run.process.stop"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"process.status",
|
"process.status",
|
||||||
"files.list",
|
"files.list",
|
||||||
"files.read",
|
"files.read",
|
||||||
|
"config.write",
|
||||||
"files.patch",
|
"files.patch",
|
||||||
"logs.read",
|
"logs.read",
|
||||||
"remote.ftp.read",
|
"remote.ftp.read",
|
||||||
@@ -1071,8 +1072,7 @@
|
|||||||
"purposes": [
|
"purposes": [
|
||||||
"config.suggest"
|
"config.suggest"
|
||||||
],
|
],
|
||||||
"mediation": "platform",
|
"mediation": "platform"
|
||||||
"configWritePolicy": "review-required"
|
|
||||||
},
|
},
|
||||||
"runtimeProfiles": {
|
"runtimeProfiles": {
|
||||||
"discovery": [
|
"discovery": [
|
||||||
@@ -1102,6 +1102,7 @@
|
|||||||
"process.stop",
|
"process.stop",
|
||||||
"process.restart",
|
"process.restart",
|
||||||
"process.status",
|
"process.status",
|
||||||
|
"config.write",
|
||||||
"remote.run.process.start",
|
"remote.run.process.start",
|
||||||
"remote.run.process.stop",
|
"remote.run.process.stop",
|
||||||
"remote.run.rcon.command"
|
"remote.run.rcon.command"
|
||||||
|
|||||||
@@ -187,12 +187,11 @@
|
|||||||
"fileWorkspace": { "type": "object", "required": ["defaultDirectoryKey", "directories", "files", "configFields"], "additionalProperties": false, "properties": { "defaultDirectoryKey": { "$ref": "#/$defs/logicalKey" }, "directories": { "type": "array", "items": { "$ref": "#/$defs/pluginLogicalDirectory" } }, "files": { "type": "array", "items": { "$ref": "#/$defs/pluginLogicalFile" } }, "configFields": { "type": "array", "items": { "$ref": "#/$defs/pluginConfigField" } } } },
|
"fileWorkspace": { "type": "object", "required": ["defaultDirectoryKey", "directories", "files", "configFields"], "additionalProperties": false, "properties": { "defaultDirectoryKey": { "$ref": "#/$defs/logicalKey" }, "directories": { "type": "array", "items": { "$ref": "#/$defs/pluginLogicalDirectory" } }, "files": { "type": "array", "items": { "$ref": "#/$defs/pluginLogicalFile" } }, "configFields": { "type": "array", "items": { "$ref": "#/$defs/pluginConfigField" } } } },
|
||||||
"ai": {
|
"ai": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["mediation", "configWritePolicy"],
|
"required": ["mediation"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"purposes": { "type": "array", "items": { "$ref": "#/$defs/aiPurpose" }, "uniqueItems": true },
|
"purposes": { "type": "array", "items": { "$ref": "#/$defs/aiPurpose" }, "uniqueItems": true },
|
||||||
"mediation": { "const": "platform" },
|
"mediation": { "const": "platform" }
|
||||||
"configWritePolicy": { "const": "review-required" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Plugin pages build execution requests with `createBridgeExecutionRequest` and ha
|
|||||||
|
|
||||||
Execution responses use `requestId`, plugin/page/server scope, action, status, optional result refs, and optional safe errors. Use `parseBridgeExecutionResponse` before reading results so plugin code handles denied, deferred, and failed states uniformly.
|
Execution responses use `requestId`, plugin/page/server scope, action, status, optional result refs, and optional safe errors. Use `parseBridgeExecutionResponse` before reading results so plugin code handles denied, deferred, and failed states uniformly.
|
||||||
|
|
||||||
AI requests use `createAIInvocationRequest` with an explicit manifest-declared purpose, prompt, and scoped context refs. Use `parseAIInvocationResponse` to consume recommendations, reviewable `diffId` metadata, and safe errors. Plugin code must not choose or receive provider API keys, provider base URLs, bearer tokens, or direct transport details. Config writes require a separate Platform operator approval.
|
AI requests use `createAIInvocationRequest` with an explicit manifest-declared purpose, prompt, and scoped context refs. Use `parseAIInvocationResponse` to consume recommendations, optional `configExecution` job metadata, and safe errors. For config purposes Platform validates the current config and dispatches the bounded `config.write` job immediately; plugin code must not choose or receive provider API keys, provider base URLs, bearer tokens, or direct transport details.
|
||||||
|
|
||||||
Artifact open requests use `createArtifactOpenRequest` with an artifact ID that belongs to the current server/job scope. Use `parseArtifactReference` to consume the bridge result. Parsed references contain platform-owned download URLs, filename, content type, size, checksum, expiry, range support, and chunk size; they do not contain bytes or raw storage adapter locations.
|
Artifact open requests use `createArtifactOpenRequest` with an artifact ID that belongs to the current server/job scope. Use `parseArtifactReference` to consume the bridge result. Parsed references contain platform-owned download URLs, filename, content type, size, checksum, expiry, range support, and chunk size; they do not contain bytes or raw storage adapter locations.
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ export interface PluginAIInvocationResponse {
|
|||||||
purpose: AIPurpose;
|
purpose: AIPurpose;
|
||||||
status: "ok" | "denied" | "error" | string;
|
status: "ok" | "denied" | "error" | string;
|
||||||
recommendation?: string;
|
recommendation?: string;
|
||||||
configRecommendation?: { diffId: string; key: string; suggestedConfig: string; diffSummary: string; expiresAt: string };
|
configRecommendation?: { key: string; suggestedConfig: string; diffSummary: string };
|
||||||
|
configExecution?: { status: string; jobId: string; capability: string; targetKey?: string; inputRef?: string };
|
||||||
usage?: { model?: string; mocked?: boolean; inputTokens?: number; outputTokens?: number };
|
usage?: { model?: string; mocked?: boolean; inputTokens?: number; outputTokens?: number };
|
||||||
error?: PluginBridgeError;
|
error?: PluginBridgeError;
|
||||||
}
|
}
|
||||||
@@ -574,7 +575,6 @@ export interface GamePluginManifest {
|
|||||||
ai?: {
|
ai?: {
|
||||||
purposes?: AIPurpose[];
|
purposes?: AIPurpose[];
|
||||||
mediation: "platform";
|
mediation: "platform";
|
||||||
configWritePolicy: "review-required";
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,6 +866,7 @@ export function parseAIInvocationResponse(response: PluginAIInvocationResponse):
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
recommendation: response.recommendation,
|
recommendation: response.recommendation,
|
||||||
configRecommendation: response.configRecommendation ? { ...response.configRecommendation } : undefined,
|
configRecommendation: response.configRecommendation ? { ...response.configRecommendation } : undefined,
|
||||||
|
configExecution: response.configExecution ? { ...response.configExecution } : undefined,
|
||||||
usage: response.usage ? { ...response.usage } : undefined,
|
usage: response.usage ? { ...response.usage } : undefined,
|
||||||
error: response.error ? bridgeError(response.error.code, response.error.message, response.error.details ?? []) : undefined
|
error: response.error ? bridgeError(response.error.code, response.error.message, response.error.details ?? []) : undefined
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user