First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/artex/sidequestion"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
)
|
||||
|
||||
// User-facing side-question (곁질문) messages surfaced through the HTTP API and
|
||||
// the exchange status, localized to Korean (BRIEF 현지화 방침). "Worker" and
|
||||
// "메인 에이전트"/"메인 대화" match the UI terms; "[btw]" log lines stay as-is.
|
||||
const (
|
||||
sideErrCtxNotSaved = "곁질문 컨텍스트가 아직 저장되지 않았습니다. 다시 시도해 주세요."
|
||||
sideErrModelConfigChanged = "모델 설정이 삭제되었거나 변경되었습니다. 먼저 메인 에이전트를 실행해 컨텍스트를 갱신해 주세요."
|
||||
sideErrServiceUnavailable = "곁질문 서비스를 사용할 수 없습니다."
|
||||
sideErrTaskArchived = "작업이 보관되었거나 삭제되는 중입니다."
|
||||
sideErrWorkerDeleted = "Worker 가 삭제되었습니다."
|
||||
sideErrBadQuestion = "질문은 1–4000자여야 하며, 유효한 요청 ID 를 함께 보내야 합니다."
|
||||
sideErrTaskArchiving = "작업을 보관하거나 삭제하는 중입니다."
|
||||
sideErrRequestIDReused = "같은 요청 ID 를 서로 다른 질문에 사용할 수 없습니다."
|
||||
sideErrNoSnapshot = "아직 컨텍스트 스냅샷이 없습니다. 먼저 메인 에이전트를 실행해 주세요."
|
||||
sideErrConcurrencyLimit = "곁질문 요청이 동시 실행 상한에 도달했습니다. 잠시 후 다시 시도해 주세요."
|
||||
sideErrAnswerStopped = "답변이 중지되었습니다."
|
||||
sideErrAnswerTimeout = "곁질문 답변이 120초를 초과하여 중지되었습니다."
|
||||
)
|
||||
|
||||
type sideRun struct {
|
||||
key string
|
||||
parent sidequestion.Parent
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
type sideQuestionState struct {
|
||||
mu sync.Mutex
|
||||
commands sync.Mutex // short admission / clear operations only, never inference
|
||||
pending map[string]sidequestion.Snapshot
|
||||
latest map[string]sidequestion.Snapshot
|
||||
seen map[string][2]int64
|
||||
runs map[string]sideRun
|
||||
done chan struct{}
|
||||
outputTokens int
|
||||
}
|
||||
|
||||
func sideModel(cfg agent.Config, id int64, name string) sidequestion.Model {
|
||||
b, _ := json.Marshal([]any{cfg.Format, cfg.BaseURL, cfg.Model, cfg.ThinkingType, cfg.ReasoningEffort, cfg.Stream, cfg.MaxTokensField, cfg.SessionHeaderKey})
|
||||
h := sha256.Sum256(b)
|
||||
return sidequestion.Model{ProfileID: id, Name: name, Format: string(cfg.Format), Model: cfg.Model, Identity: hex.EncodeToString(h[:]), Streaming: cfg.Stream, WindowTokens: cfg.CompactionWindow()}
|
||||
}
|
||||
|
||||
func bindSideProvider(p llm.Provider, cfg agent.Config, id int64, name string) llm.Provider {
|
||||
return sidequestion.Bind(p, sideModel(cfg, id, name))
|
||||
}
|
||||
|
||||
func (s *Server) initSideQuestions() {
|
||||
s.side = &sideQuestionState{pending: map[string]sidequestion.Snapshot{}, latest: map[string]sidequestion.Snapshot{}, seen: map[string][2]int64{}, runs: map[string]sideRun{}, done: make(chan struct{})}
|
||||
if n, err := strconv.Atoi(os.Getenv("ARTEX_BTW_MAX_OUTPUT_TOKENS")); err == nil && n >= 256 && n <= 32768 {
|
||||
s.side.outputTokens = n
|
||||
}
|
||||
if s.m.pg == nil {
|
||||
close(s.side.done)
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.InterruptSideRequests(s.ctx); err != nil {
|
||||
log.Printf("[btw] recover: %v", err)
|
||||
}
|
||||
s.ctx = sidequestion.WithPublisher(s.ctx, func(snap sidequestion.Snapshot) {
|
||||
s.side.mu.Lock()
|
||||
defer s.side.mu.Unlock()
|
||||
key := snap.Parent.Key()
|
||||
old, ok := s.side.seen[key]
|
||||
if ok && (old[0] > snap.RunID || old[0] == snap.RunID && old[1] >= snap.Version) {
|
||||
return
|
||||
}
|
||||
s.side.latest[key] = snap
|
||||
s.side.seen[key] = [2]int64{snap.RunID, snap.Version}
|
||||
s.side.pending[key] = snap
|
||||
})
|
||||
go func() {
|
||||
defer close(s.side.done)
|
||||
tick := time.NewTicker(250 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
s.flushSideSnapshots()
|
||||
case <-s.ctx.Done():
|
||||
s.flushSideSnapshots()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) flushSideSnapshots() {
|
||||
if s.side == nil || s.m.pg == nil {
|
||||
return
|
||||
}
|
||||
s.side.commands.Lock()
|
||||
defer s.side.commands.Unlock()
|
||||
s.side.mu.Lock()
|
||||
pending := s.side.pending
|
||||
s.side.pending = map[string]sidequestion.Snapshot{}
|
||||
s.side.mu.Unlock()
|
||||
for key, snap := range pending {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err := s.m.pg.SaveSideSnapshot(ctx, snap)
|
||||
cancel()
|
||||
s.side.mu.Lock()
|
||||
if err == nil || errors.Is(err, db.ErrSideParentGone) {
|
||||
if cur, ok := s.side.latest[key]; ok && cur.RunID == snap.RunID && cur.Version == snap.Version {
|
||||
delete(s.side.latest, key)
|
||||
}
|
||||
} else if newer, ok := s.side.pending[key]; !ok || newer.RunID < snap.RunID || newer.RunID == snap.RunID && newer.Version < snap.Version {
|
||||
s.side.pending[key] = snap
|
||||
}
|
||||
s.side.mu.Unlock()
|
||||
if err != nil && !errors.Is(err, db.ErrSideParentGone) {
|
||||
log.Printf("[btw] checkpoint persistence: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sideSnapshot(ctx context.Context, p sidequestion.Parent) (*sidequestion.Snapshot, error) {
|
||||
s.side.mu.Lock()
|
||||
snap, ok := s.side.latest[p.Key()]
|
||||
s.side.mu.Unlock()
|
||||
if ok {
|
||||
return &snap, nil
|
||||
}
|
||||
return s.m.pg.SideSnapshot(ctx, p.Key())
|
||||
}
|
||||
|
||||
func (s *Server) cancelSideWhere(match func(sidequestion.Parent) bool) []<-chan struct{} {
|
||||
if s.side == nil {
|
||||
return nil
|
||||
}
|
||||
s.side.mu.Lock()
|
||||
defer s.side.mu.Unlock()
|
||||
var done []<-chan struct{}
|
||||
for _, run := range s.side.runs {
|
||||
if match(run.parent) {
|
||||
run.cancel()
|
||||
done = append(done, run.done)
|
||||
}
|
||||
}
|
||||
return done
|
||||
}
|
||||
|
||||
// Called after the task admission barrier closes and the main loops drain.
|
||||
// Wait for final answer/usage writes before taking the archive snapshot.
|
||||
func (s *Server) drainTaskSideQuestions(ctx context.Context, taskID string) error {
|
||||
if s.side == nil {
|
||||
return nil
|
||||
}
|
||||
id, _ := strconv.ParseInt(taskID, 10, 64)
|
||||
s.side.commands.Lock()
|
||||
done := s.cancelSideWhere(func(p sidequestion.Parent) bool { return p.TaskID == id })
|
||||
s.side.commands.Unlock()
|
||||
for _, ch := range done {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
s.flushSideSnapshots()
|
||||
// A failed flush must not silently leave the latest checkpoint out of archive.
|
||||
s.side.mu.Lock()
|
||||
defer s.side.mu.Unlock()
|
||||
for _, snap := range s.side.pending {
|
||||
if snap.Parent.TaskID == id {
|
||||
return errors.New(sideErrCtxNotSaved)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) cancelWorkerSide(taskID string, expID, iid int64) {
|
||||
if s.side == nil {
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseInt(taskID, 10, 64)
|
||||
p := sidequestion.Parent{TaskID: id, ExplorationID: expID, IntentID: iid}
|
||||
s.side.commands.Lock()
|
||||
defer s.side.commands.Unlock()
|
||||
s.cancelSideWhere(func(parent sidequestion.Parent) bool { return parent == p })
|
||||
}
|
||||
|
||||
func (s *Server) sideProvider(model sidequestion.Model) (llm.Provider, error) {
|
||||
var p llm.Provider
|
||||
var cfg agent.Config
|
||||
var ok bool
|
||||
if model.ProfileID > 0 {
|
||||
// Validate the persisted reference even if a previous provider is cached.
|
||||
current, exists := s.loadProfileConfig(model.ProfileID)
|
||||
if !exists || sideModel(current, model.ProfileID, model.Name).Identity != model.Identity {
|
||||
return nil, errors.New(sideErrModelConfigChanged)
|
||||
}
|
||||
p, cfg, ok = s.providerForProfile(model.ProfileID)
|
||||
} else {
|
||||
s.cfgMu.Lock()
|
||||
p, cfg, ok = s.llmDirect, s.llmCfg, s.llmOn
|
||||
s.cfgMu.Unlock()
|
||||
}
|
||||
if !ok || p == nil || sideModel(cfg, model.ProfileID, model.Name).Identity != model.Identity {
|
||||
return nil, errors.New(sideErrModelConfigChanged)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Server) sideParent(w http.ResponseWriter, r *http.Request, kind string) (sidequestion.Parent, bool) {
|
||||
p := sidequestion.Parent{}
|
||||
if s.side == nil || s.m.pg == nil {
|
||||
writeErr(w, 503, sideErrServiceUnavailable)
|
||||
return p, false
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeErr(w, 400, "bad id")
|
||||
return p, false
|
||||
}
|
||||
if kind == "conversation" {
|
||||
c, err := s.m.pg.GetConversation(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return p, false
|
||||
}
|
||||
if c == nil {
|
||||
writeErr(w, 404, "conversation not found")
|
||||
return p, false
|
||||
}
|
||||
p.ConversationID = id
|
||||
return p, true
|
||||
}
|
||||
t, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, 404, "task not found")
|
||||
return p, false
|
||||
}
|
||||
pt, err := s.m.pg.GetTask(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return p, false
|
||||
}
|
||||
if pt == nil || s.engine.IsDeleting(t.ID) {
|
||||
writeErr(w, 409, sideErrTaskArchived)
|
||||
return p, false
|
||||
}
|
||||
p.TaskID, p.ExplorationID = id, t.ExpID
|
||||
if kind == "worker" {
|
||||
iid, err := strconv.ParseInt(r.PathValue("iid"), 10, 64)
|
||||
if err != nil || iid <= 0 {
|
||||
writeErr(w, 400, "bad intent id")
|
||||
return p, false
|
||||
}
|
||||
n, err := t.Store.GetNode(iid)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return p, false
|
||||
}
|
||||
if n == nil || n.Kind != db.KindIntent {
|
||||
writeErr(w, 404, "intent not found")
|
||||
return p, false
|
||||
}
|
||||
if n.State == "stopped" {
|
||||
writeErr(w, 409, sideErrWorkerDeleted)
|
||||
return p, false
|
||||
}
|
||||
p.IntentID = iid
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func (s *Server) registerSideRoutes(mux *http.ServeMux) {
|
||||
for _, route := range []struct{ path, kind string }{{"/api/conversations/{id}", "conversation"}, {"/api/tasks/{id}/chat", "main"}, {"/api/tasks/{id}/intents/{iid}", "worker"}} {
|
||||
for _, method := range []string{"GET", "POST", "DELETE"} {
|
||||
mux.HandleFunc(method+" "+route.path+"/side-questions", func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.sideParent(w, r, route.kind)
|
||||
if ok {
|
||||
s.handleSideQuestions(w, r, p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
mux.HandleFunc("GET /api/side-questions/{requestID}/events", s.sideEvents)
|
||||
mux.HandleFunc("POST /api/side-questions/{requestID}/cancel", s.cancelSideRequest)
|
||||
}
|
||||
|
||||
func (s *Server) handleSideQuestions(w http.ResponseWriter, r *http.Request, p sidequestion.Parent) {
|
||||
key := p.Key()
|
||||
if r.Method == "DELETE" {
|
||||
s.side.commands.Lock()
|
||||
defer s.side.commands.Unlock()
|
||||
s.side.mu.Lock()
|
||||
for _, run := range s.side.runs {
|
||||
if run.key == key {
|
||||
run.cancel()
|
||||
}
|
||||
}
|
||||
s.side.mu.Unlock()
|
||||
if err := s.m.pg.ClearSideHistory(r.Context(), key); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"cleared": true})
|
||||
return
|
||||
}
|
||||
snap, err := s.sideSnapshot(r.Context(), p)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if r.Method == "GET" {
|
||||
before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
|
||||
items, err := s.m.pg.SideHistory(r.Context(), key, before, 21)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
var next int64
|
||||
if len(items) > 20 {
|
||||
items = items[:20]
|
||||
next = items[len(items)-1].Ordinal
|
||||
}
|
||||
current, err := s.m.pg.CurrentSideRequest(r.Context(), key)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
var meta any
|
||||
if snap != nil {
|
||||
_, modelErr := s.sideProvider(snap.Model)
|
||||
reason := ""
|
||||
if modelErr != nil {
|
||||
reason = modelErr.Error()
|
||||
}
|
||||
meta = map[string]any{"captured_at": snap.CapturedAt, "model": snap.Model, "available": modelErr == nil, "reason": reason}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "current": current, "next_cursor": next, "snapshot": meta})
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||
var in struct {
|
||||
Question string `json:"question"`
|
||||
ClientID string `json:"client_request_id"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&in) != nil {
|
||||
writeErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
in.Question = strings.TrimSpace(in.Question)
|
||||
if in.Question == "" || len([]rune(in.Question)) > 4000 || !validWorkerMessageRequestID(in.ClientID) {
|
||||
writeErr(w, 400, sideErrBadQuestion)
|
||||
return
|
||||
}
|
||||
s.side.commands.Lock()
|
||||
defer s.side.commands.Unlock()
|
||||
if p.TaskID > 0 && s.engine.IsDeleting(strconv.FormatInt(p.TaskID, 10)) {
|
||||
writeErr(w, 409, sideErrTaskArchiving)
|
||||
return
|
||||
}
|
||||
if existing, err := s.m.pg.ExistingSideRequest(r.Context(), key, in.ClientID); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
} else if existing != nil {
|
||||
if existing.Question != in.Question {
|
||||
writeErr(w, 409, sideErrRequestIDReused)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, existing)
|
||||
return
|
||||
}
|
||||
if snap == nil {
|
||||
writeErr(w, 409, sideErrNoSnapshot)
|
||||
return
|
||||
}
|
||||
provider, err := s.sideProvider(snap.Model)
|
||||
if err != nil {
|
||||
writeErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
s.side.mu.Lock()
|
||||
full := len(s.side.runs) >= 4
|
||||
busy := false
|
||||
for _, run := range s.side.runs {
|
||||
if run.key == key {
|
||||
busy = true
|
||||
}
|
||||
}
|
||||
s.side.mu.Unlock()
|
||||
if busy {
|
||||
writeErr(w, 409, db.ErrSideBusy.Error())
|
||||
return
|
||||
}
|
||||
if full {
|
||||
writeErr(w, 429, sideErrConcurrencyLimit)
|
||||
return
|
||||
}
|
||||
agentQuestion, ok := s.prepareChatMentionMessage(w, in.Question)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err = s.m.pg.SaveSideSnapshot(r.Context(), *snap); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
e, created, err := s.m.pg.StartSideRequest(r.Context(), *snap, in.ClientID, in.Question)
|
||||
if err != nil {
|
||||
writeErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
if created {
|
||||
ctx, cancel := context.WithTimeout(s.ctx, 120*time.Second)
|
||||
s.side.mu.Lock()
|
||||
s.side.runs[e.ID] = sideRun{key: key, parent: p, cancel: cancel, done: make(chan struct{})}
|
||||
s.side.mu.Unlock()
|
||||
go s.runSide(ctx, cancel, *e, p, provider, *snap, agentQuestion)
|
||||
}
|
||||
writeJSON(w, 202, e)
|
||||
}
|
||||
|
||||
func (s *Server) runSide(ctx context.Context, cancel context.CancelFunc, e sidequestion.Exchange, parent sidequestion.Parent, provider llm.Provider, snapshot sidequestion.Snapshot, agentQuestion string) {
|
||||
defer cancel()
|
||||
defer func() {
|
||||
s.side.mu.Lock()
|
||||
if run, ok := s.side.runs[e.ID]; ok {
|
||||
close(run.done)
|
||||
}
|
||||
delete(s.side.runs, e.ID)
|
||||
s.side.mu.Unlock()
|
||||
}()
|
||||
ctx = transcript.WithSessionID(ctx, fmt.Sprintf("exp%d-btw-%s", parent.ExplorationID, e.ID))
|
||||
if parent.TaskID > 0 {
|
||||
ctx = llmrec.WithTaskID(ctx, strconv.FormatInt(parent.TaskID, 10))
|
||||
}
|
||||
// Deletions can race between request insertion and registration. Observe the
|
||||
// persisted lifecycle even while a provider is blocked without emitting text.
|
||||
go func() {
|
||||
tick := time.NewTicker(500 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
row, err := s.m.pg.SideRequest(ctx, e.ID)
|
||||
if err == nil && (row == nil || !row.Running()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
lastSave := time.Now()
|
||||
persist := func() (bool, error) {
|
||||
e.Sequence++
|
||||
writeCtx, stop := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer stop()
|
||||
ok, err := s.m.pg.UpdateSideRequest(writeCtx, e)
|
||||
if err != nil {
|
||||
log.Printf("[btw] answer persistence: %v", err)
|
||||
return true, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
memory, runErr := s.m.pg.SideMemory(ctx, e)
|
||||
var answer sidequestion.Answer
|
||||
if runErr == nil {
|
||||
answer, e.Context, runErr = (sidequestion.SideQuestionService{Provider: provider}).Respond(ctx, snapshot, agentQuestion, sidequestion.Replay{
|
||||
Memory: memory,
|
||||
Load: func(ctx context.Context, after int64) ([]sidequestion.Exchange, error) {
|
||||
return s.m.pg.SideReplayPage(ctx, e, after)
|
||||
},
|
||||
Save: func(ctx context.Context, memory sidequestion.Memory) error {
|
||||
return s.m.pg.SaveSideMemory(ctx, e, memory)
|
||||
},
|
||||
}, sidequestion.ContextOptions{OutputTokens: s.side.outputTokens}, func(a sidequestion.Answer, info sidequestion.ContextInfo) {
|
||||
phaseChanged := e.Context.Phase != info.Phase
|
||||
e.Answer, e.Usage, e.Context = a.Text, a.Usage, info
|
||||
if phaseChanged || time.Since(lastSave) >= 250*time.Millisecond {
|
||||
lastSave = time.Now()
|
||||
if ok, _ := persist(); !ok {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
e.Answer, e.Usage = answer.Text, answer.Usage
|
||||
e.Status = "completed"
|
||||
if ctx.Err() != nil {
|
||||
e.Status = "cancelled"
|
||||
e.Error = sideErrAnswerStopped
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
e.Status = "failed"
|
||||
e.Error = sideErrAnswerTimeout
|
||||
}
|
||||
} else if runErr != nil {
|
||||
e.Status = "failed"
|
||||
e.Error = runErr.Error()
|
||||
}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if _, err := persist(); err == nil {
|
||||
break
|
||||
}
|
||||
if attempt < 2 {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) cancelSideRequest(w http.ResponseWriter, r *http.Request) {
|
||||
e, err := s.m.pg.SideRequest(r.Context(), r.PathValue("requestID"))
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if e == nil {
|
||||
writeErr(w, 404, "side question not found")
|
||||
return
|
||||
}
|
||||
s.side.mu.Lock()
|
||||
if run, ok := s.side.runs[e.ID]; ok {
|
||||
run.cancel()
|
||||
}
|
||||
s.side.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]bool{"cancelled": true})
|
||||
}
|
||||
|
||||
func (s *Server) sideEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeErr(w, 500, "streaming unavailable")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("requestID")
|
||||
first, err := s.m.pg.SideRequest(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if first == nil {
|
||||
writeErr(w, 404, "side question not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
seq := int64(-1)
|
||||
tick := time.NewTicker(250 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
e := first
|
||||
first = nil
|
||||
if e == nil {
|
||||
e, err = s.m.pg.SideRequest(r.Context(), id)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if e == nil {
|
||||
fmt.Fprint(w, "event: cleared\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
if e.Sequence > seq {
|
||||
b, _ := json.Marshal(e)
|
||||
fmt.Fprintf(w, "id: %d\nevent: snapshot\ndata: %s\n\n", e.Sequence, b)
|
||||
flusher.Flush()
|
||||
seq = e.Sequence
|
||||
}
|
||||
if !e.Running() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user