ci / vet-and-test (pull_request) Successful in 54s
Why: U6 extends pages from conversations into the task/project
workspace - surfaces aggregate across conversations under the owner's
project scope (project resolved authoritatively from the conversation
row, never self-reported by the model), and the catalog guidance now
organizes pages by task phase (requirements/execution/acceptance)
while stating that task facts come only from real task records.
Evidence: build/vet clean; surfacesvc suite green incl. the new
project-aggregation test (owner isolation, cross-owner zero, history
retained post-disable); genui/store suites green.
Impact: New endpoint GET /api/projects/{id}/surfaces; Submit resolves
project from conversation; catalog text guidance. Existing endpoints
unchanged.
Refs: proposal r2 U6; Dev task feature/generative-ui-u6
336 lines
11 KiB
Go
336 lines
11 KiB
Go
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)
|
||
}
|
||
|
||
func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
|
||
if s.Surfaces == nil {
|
||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||
return
|
||
}
|
||
// 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道
|
||
// (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。
|
||
var body struct {
|
||
ActionID string `json:"actionId"`
|
||
Revision int `json:"revision"`
|
||
RequestID string `json:"requestId"`
|
||
Data map[string]any `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ActionID == "" || body.RequestID == "" {
|
||
http.Error(w, "actionId and requestId required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
res, err := s.Surfaces.SubmitAction(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{
|
||
SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID,
|
||
Revision: body.Revision, RequestID: body.RequestID, Data: body.Data,
|
||
})
|
||
if err != nil {
|
||
s.writeSurfaceError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, res)
|
||
}
|
||
|
||
// 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.Is(err, surfacesvc.ErrUnknownAction):
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
case errors.Is(err, store.ErrIdempotencyConflict):
|
||
http.Error(w, err.Error(), http.StatusConflict)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// listProjectSurfaces 项目级跨会话页面聚合(U6/R8):owner 授权作用域。
|
||
func (s *Server) listProjectSurfaces(w http.ResponseWriter, r *http.Request) {
|
||
if s.Surfaces == nil {
|
||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||
return
|
||
}
|
||
metas, err := s.Surfaces.ListByProject(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
|
||
if err != nil {
|
||
s.writeSurfaceError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, orEmptySlice(metas))
|
||
}
|