Files
Agent-System-Backend/internal/server/platform_api.go
T
weichen 7cf8f02399 Add project lifecycle delegation and private member work
Why:
Shared project reads previously doubled as write permission, project mutations
bypassed the domain lifecycle, and coarse retrieval shared private resources.

Evidence:
SELF red-before-implementation logs cover projection, lifecycle, private work,
resource filtering, canonical retrieval scope, publication source and namespace.
SELF go test ./..., isolated PostgreSQL go test ./internal/store -count=1,
and CGO_ENABLED=0 go build ./cmd/agent-system passed; task owner owns replay/CI.

Impact:
Add optional actor-scoped projection/lifecycle capabilities; registered providers
control public lifecycle and live work. Preserve owned conversation history and
ordinary unregistered behavior. Authorize knowledge by stored ID/source, map
current host scopes through trusted service metadata, and filter artifact lists.

Refs:
Dev project-member-access implementation plan r1 Task 1; approved design r0.1;
2026-10-13 trusted publication Source hardening ruling
2026-10-13 08:43:02 +00:00

350 lines
11 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)
}
func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
if s.Surfaces == nil {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
if s.projectLifecycle != nil {
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
if snap.Identity.ProjectID != "" {
if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil {
writeProjectErr(w, err)
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))
}