You've already forked agentic-coding-workflow
启动项目逻辑
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
ws "github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||
)
|
||||
|
||||
const defaultLogsLimit = 500
|
||||
|
||||
// AdminLookup resolves whether a user is an admin(mirrors acp/workspace pattern)。
|
||||
type AdminLookup interface {
|
||||
IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
// WSConfig controls WebSocket heartbeat and buffering for the log stream.
|
||||
type WSConfig struct {
|
||||
PingInterval time.Duration
|
||||
PongTimeout time.Duration
|
||||
SendBuffer int
|
||||
}
|
||||
|
||||
// Handler 暴露 run profiles 的 HTTP/WS 端点。
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
resolver middleware.SessionResolver
|
||||
adminLookup AdminLookup
|
||||
cfg WSConfig
|
||||
}
|
||||
|
||||
// NewHandler 构造 run.Handler。
|
||||
func NewHandler(svc *Service, resolver middleware.SessionResolver, al AdminLookup, cfg WSConfig) *Handler {
|
||||
return &Handler{svc: svc, resolver: resolver, adminLookup: al, cfg: cfg}
|
||||
}
|
||||
|
||||
// Mount 注册 run profiles 路由。分两组:workspace 作用域(列表/创建)与
|
||||
// id 作用域(按 id 操作,内部经 profile→workspace 解析做 ACL)。
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
r.Route("/api/v1/workspaces/{wsID}/run-profiles", func(r chi.Router) {
|
||||
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
||||
r.Get("/", h.list)
|
||||
r.Post("/", h.create)
|
||||
})
|
||||
r.Route("/api/v1/run-profiles", func(r chi.Router) {
|
||||
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
||||
r.Patch("/{id}", h.update)
|
||||
r.Delete("/{id}", h.remove)
|
||||
r.Post("/{id}/start", h.start)
|
||||
r.Post("/{id}/stop", h.stop)
|
||||
r.Post("/{id}/restart", h.restart)
|
||||
r.Get("/{id}/status", h.status)
|
||||
r.Get("/{id}/logs", h.logs)
|
||||
r.Get("/{id}/logs/stream", h.logsStream)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) caller(r *http.Request) (workspace.Caller, error) {
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
return workspace.Caller{}, errs.New(errs.CodeUnauthorized, "not authenticated")
|
||||
}
|
||||
admin, err := h.adminLookup.IsAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
return workspace.Caller{}, err
|
||||
}
|
||||
return workspace.Caller{UserID: uid, IsAdmin: admin}, nil
|
||||
}
|
||||
|
||||
func uuidParam(r *http.Request, key string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, key))
|
||||
if err != nil {
|
||||
return uuid.Nil, errs.New(errs.CodeInvalidInput, "bad "+key)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
wsID, err := uuidParam(r, "wsID")
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
out, err := h.svc.List(r.Context(), c, wsID)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
wsID, err := uuidParam(r, "wsID")
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
var req CreateRunProfileReq
|
||||
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Create(r.Context(), c, wsID, req)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := uuidParam(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
var req UpdateRunProfileReq
|
||||
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
|
||||
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json"))
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Update(r.Context(), c, id, req)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := uuidParam(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(r.Context(), c, id); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) start(w http.ResponseWriter, r *http.Request) { h.lifecycle(w, r, h.svc.Start) }
|
||||
func (h *Handler) stop(w http.ResponseWriter, r *http.Request) { h.lifecycle(w, r, h.svc.Stop) }
|
||||
func (h *Handler) restart(w http.ResponseWriter, r *http.Request) { h.lifecycle(w, r, h.svc.Restart) }
|
||||
func (h *Handler) status(w http.ResponseWriter, r *http.Request) { h.lifecycle(w, r, h.svc.Status) }
|
||||
|
||||
// lifecycle 复用 start/stop/restart/status 的公共骨架(鉴权 + 解析 id + 调 svc + 写 RunStatusResp)。
|
||||
func (h *Handler) lifecycle(w http.ResponseWriter, r *http.Request,
|
||||
fn func(context.Context, workspace.Caller, uuid.UUID) (RunStatusResp, error)) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := uuidParam(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
out, err := fn(r.Context(), c, id)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *Handler) logs(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := uuidParam(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
since := parseInt64(r.URL.Query().Get("since"), 0)
|
||||
limit := int(parseInt64(r.URL.Query().Get("limit"), defaultLogsLimit))
|
||||
out, err := h.svc.Logs(r.Context(), c, id, since, limit)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// logsStream 升级为 WebSocket,推送内存日志(含 since 续传)与实时新行。只读流,
|
||||
// 不接收客户端消息。鉴权经 middleware.Auth(WS 用 ?token= 传 token)。
|
||||
func (h *Handler) logsStream(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := h.caller(r)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
id, err := uuidParam(r, "id")
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
since := parseInt64(r.URL.Query().Get("since"), 0)
|
||||
|
||||
sub, relay, running, err := h.svc.SubscribeLogs(r.Context(), c, id, c.UserID)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
conn, aerr := ws.Accept(w, r, &ws.AcceptOptions{})
|
||||
if aerr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close(ws.StatusInternalError, "internal")
|
||||
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
|
||||
// 初始 state 帧。
|
||||
status := StatusStopped
|
||||
if running {
|
||||
status = StatusRunning
|
||||
}
|
||||
if werr := wsjson.Write(ctx, conn, LogEnvelope{Kind: "state", Status: string(status), TS: time.Now()}); werr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// profile 未运行:无 live relay,推完即关。
|
||||
if !running || relay == nil {
|
||||
conn.Close(ws.StatusNormalClosure, "not running")
|
||||
return
|
||||
}
|
||||
defer relay.Unsubscribe(sub.ID)
|
||||
|
||||
// 推送历史缓冲(ID > since)。
|
||||
lastSent := since
|
||||
for _, e := range relay.since(since, 0) {
|
||||
if werr := wsjson.Write(ctx, conn, e); werr != nil {
|
||||
return
|
||||
}
|
||||
lastSent = e.ID
|
||||
}
|
||||
|
||||
// 心跳。
|
||||
pingTicker := time.NewTicker(h.cfg.PingInterval)
|
||||
defer pingTicker.Stop()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-pingTicker.C:
|
||||
pingCtx, pcancel := context.WithTimeout(ctx, h.cfg.PongTimeout)
|
||||
perr := conn.Ping(pingCtx)
|
||||
pcancel()
|
||||
if perr != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case env, ok := <-sub.Send:
|
||||
if !ok {
|
||||
conn.Close(ws.StatusNormalClosure, "relay closed")
|
||||
return
|
||||
}
|
||||
// 历史已推到 lastSent,避免重复推送同 ID 的日志行(控制帧 ID=0 放行)。
|
||||
if env.Kind == "log" && env.ID <= lastSent && env.ID != 0 {
|
||||
continue
|
||||
}
|
||||
if werr := wsjson.Write(ctx, conn, env); werr != nil {
|
||||
return
|
||||
}
|
||||
if env.ID > lastSent {
|
||||
lastSent = env.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64(s string, def int64) int64 {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
}
|
||||
Reference in New Issue
Block a user