Files
Agent-System-Backend/internal/server/platform_api.go
T
weichen 75829fa442
ci / vet-and-test (pull_request) Successful in 40s
Add the Surface store, activation guard, and EXT-17 host capability
Why: Generative UI proposal U2 needs the host side of the contract:
owner-isolated surface storage with revision CAS, an execution-level
enable/disable ledger (default-off, epoch bumped on every transition so
in-flight submits after a disable are rejected), a single-choke-point
service that validates specs (strict decode + catalog rules) before
saving, event publish with pending recovery, the optional SurfaceHost
extension capability, and direct API endpoints that share the same
guard (no bypass by entrance).

Evidence: go build/vet clean; full go test ./... passes (SQLite);
surface store tests also pass under AGENT_PG_TEST_DSN (PostgreSQL);
surface.txt regenerated via the AST snapshot test.

Impact: Additive migration v22 (new tables only); existing endpoints and
plugin_grants semantics untouched; old consumers unaffected. U3+ (data
binding, actions, frontend) deliberately out of scope.

Refs: Documents proposal 2026-10-10-generative-ui-plugin.md (r2 U2);
Core PR #25; Dev task feature/generative-ui-u2
2026-10-11 08:26:24 +00:00

290 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
core "agent-system-core"
)
func (s *Server) createBackgroundTask(w http.ResponseWriter, r *http.Request) {
var body struct {
Kind string `json:"kind"`
Title string `json:"title"`
ConversationID string `json:"conversationId"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Kind == "" {
http.Error(w, "kind required", http.StatusBadRequest)
return
}
task, err := s.st.CreateBackgroundTask(s.owner(r), body.Kind, body.Title, body.ConversationID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, task)
}
func (s *Server) listBackgroundTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := s.st.ListBackgroundTasks(s.owner(r))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(tasks))
}
func (s *Server) getBackgroundTask(w http.ResponseWriter, r *http.Request) {
task, err := s.st.GetBackgroundTask(s.owner(r), r.PathValue("id"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) updateBackgroundTask(w http.ResponseWriter, r *http.Request) {
var body struct {
Status string `json:"status"`
Progress int `json:"progress"`
Error string `json:"error"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Status == "" {
http.Error(w, "status required", http.StatusBadRequest)
return
}
if err := s.st.UpdateBackgroundTask(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
s.getBackgroundTask(w, r)
}
func (s *Server) putMemory(w http.ResponseWriter, r *http.Request) {
var e core.MemoryEntry
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || strings.TrimSpace(e.Content) == "" {
http.Error(w, "content required", http.StatusBadRequest)
return
}
if e.Scope == "" {
e.Scope = "user"
}
if e.Type == "" {
e.Type = "fact"
}
if e.Source == "" {
e.Source = "manual"
}
saved, err := s.st.PutMemory(s.owner(r), e)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, saved)
}
func (s *Server) listMemory(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
list, err := s.st.ListMemory(s.owner(r), q.Get("scope"), q.Get("projectId"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(list))
}
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
evs, err := s.st.ListAuditEvents(s.owner(r), limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(evs))
}
func (s *Server) postAudit(w http.ResponseWriter, r *http.Request) {
var body struct {
Action string `json:"action"`
Target string `json:"target"`
Evidence string `json:"evidence"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Action == "" {
http.Error(w, "action required", http.StatusBadRequest)
return
}
if err := s.st.AppendAuditEvent(store.AuditEvent{
Owner: s.owner(r), Actor: s.owner(r), Action: body.Action, Target: body.Target,
Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"),
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) setPluginEnabled(w http.ResponseWriter, r *http.Request) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := s.st.SetPluginEnabled(s.owner(r), r.PathValue("id"), body.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) llmBudget(w http.ResponseWriter, r *http.Request) {
budget := int(s.set.Effective(s.owner(r), "").GetNumber("llm.dailyTokenBudget", 0))
status, err := s.st.AccountTokens(s.owner(r), 0, budget)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"total": status.Total, "budget": budget, "over": status.Over})
}
func (s *Server) exportConversation(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
id := r.PathValue("id")
conv, err := s.st.GetConversation(owner, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
msgs, err := s.st.ListMessages(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.URL.Query().Get("format") == "md" {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
fmt.Fprintf(w, "# %s\n\n", conv.Title)
for _, m := range msgs {
fmt.Fprintf(w, "## %s\n\n%s\n\n", m.Role, messageText(m))
}
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conv, "messages": msgs})
}
func messageText(m core.Message) string {
var b strings.Builder
for _, p := range m.Parts {
if p.Type == core.PartText {
b.WriteString(p.Text)
}
}
return b.String()
}
// ---- Generative UI Surface(提案 U2;EXT-17)。直接 API 与插件命令共用
// surfacesvc.Submit 单一咽喉:禁用/epoch/校验/CAS 守卫不因入口不同而绕过。----
func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
var body struct {
PluginID string `json:"pluginId"`
ConversationID string `json:"conversationId"`
SurfaceID string `json:"surfaceId"`
BaseRevision int `json:"baseRevision"`
ObservedEpoch int64 `json:"observedEpoch"`
ContextVersion string `json:"contextVersion"`
SemanticText string `json:"semanticText"`
Spec json.RawMessage `json:"spec"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PluginID == "" || len(body.Spec) == 0 {
http.Error(w, "pluginId and spec required", http.StatusBadRequest)
return
}
snap, err := s.Surfaces.Submit(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID),
BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch,
ContextVersion: body.ContextVersion, SemanticText: body.SemanticText, Spec: body.Spec,
})
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusCreated, snap)
}
func (s *Server) getSurface(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, snap)
}
func (s *Server) listSurfaces(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
metas, err := s.Surfaces.List(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, orEmptySlice(metas))
}
func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "enabled required", http.StatusBadRequest)
return
}
row, err := s.Surfaces.SetActivation(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled)
if err != nil {
s.writeSurfaceError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// writeSurfaceError 把服务层确定错误映射到状态码:禁用/epoch=409 语义(准入
// 截止),规格违规=400 带字段清单,冲突=409 带当前版本,NotFound=404。
func (s *Server) writeSurfaceError(w http.ResponseWriter, err error) {
var invalid *surfacesvc.ErrInvalidSpec
var conflict *store.ErrRevisionConflict
switch {
case errors.Is(err, surfacesvc.ErrDisabled), errors.Is(err, surfacesvc.ErrStaleEpoch),
errors.Is(err, surfacesvc.ErrUnavailable):
http.Error(w, err.Error(), http.StatusForbidden)
case errors.As(err, &invalid):
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid spec", "details": invalid.Errs})
case errors.As(err, &conflict):
writeJSON(w, http.StatusConflict, map[string]any{"error": "revision conflict", "current": conflict.Current})
case errors.Is(err, store.ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}