mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Comments, doc comments, UI labels, log and error messages, LLM prompt strings, markdown docs, test data and assertions. Identifiers, enum keys, routes, placeholders and code structure are unchanged. Notes: - docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md - tests that exercised CJK specific handling (GBK zip names, Chinese full text search) now use ASCII equivalents; go build passes and the test suite matches the pre translation baseline (remaining failures need a PostgreSQL DSN and fail identically on the original) - frontend mention syntax now uses English kinds (finding, asset, ...) matching the backend, chat mentions tests pass
147 lines
4.8 KiB
Go
147 lines
4.8 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
)
|
|
|
|
const maxWorkerMessageBytes = 64 << 10
|
|
|
|
func validWorkerMessageRequestID(id string) bool {
|
|
if id == "" || len(id) > 128 {
|
|
return false
|
|
}
|
|
for _, r := range id {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
|
(r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' || r == ':' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sendWorkerMessage continues a paused Worker intent with a human-authored message.
|
|
// The message is injected as the next turn's input through the same
|
|
// resume-from-transcript path the worker uses on a normal resume (ExecuteWithMessage),
|
|
// so this reuses the existing pause/resume machinery rather than a bespoke protocol.
|
|
// The run happens in a dedicated goroutine outside the worker pool (runDetachedIntent),
|
|
// so the message is picked up immediately even when every pool slot is busy — the same
|
|
// way the main-agent chat handler starts its run directly. In-memory only: a process
|
|
// restart re-runs the intent from its transcript without the message, which is
|
|
// acceptable for this rare interrupt-then-continue action.
|
|
func (s *Server) sendWorkerMessage(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.m.Task(r.PathValue("id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusNotFound, "task not found")
|
|
return
|
|
}
|
|
iid, err := strconv.ParseInt(r.PathValue("iid"), 10, 64)
|
|
if err != nil || iid <= 0 {
|
|
writeErr(w, http.StatusBadRequest, "bad intent id")
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxWorkerMessageBytes)
|
|
var req struct {
|
|
Message string `json:"message"`
|
|
RequestID string `json:"request_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "Request body too large")
|
|
return
|
|
}
|
|
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
|
return
|
|
}
|
|
message := strings.TrimSpace(req.Message)
|
|
requestID := strings.TrimSpace(req.RequestID)
|
|
if message == "" {
|
|
writeErr(w, http.StatusBadRequest, "Message cannot be empty")
|
|
return
|
|
}
|
|
if len([]rune(message)) > 4000 {
|
|
writeErr(w, http.StatusBadRequest, "Message must not exceed 4000 characters")
|
|
return
|
|
}
|
|
if !validWorkerMessageRequestID(requestID) {
|
|
writeErr(w, http.StatusBadRequest, "request_id must be 1-128 characters of letters, digits, -, _, . or :")
|
|
return
|
|
}
|
|
|
|
// Reject non-runnable task lifecycles up front so the caller gets a clear reason
|
|
// instead of a silent no-op. The intent itself must be paused: the UI flow is
|
|
// interrupt (pause) first, then send.
|
|
if s.engine.IsDeleting(t.ID) {
|
|
writeErr(w, http.StatusConflict, "Task is being deleted; cannot send a message to the Worker")
|
|
return
|
|
}
|
|
lifecycle := t.lifecycleSnapshot()
|
|
switch {
|
|
case lifecycle.Paused || s.engine.IsPaused(t.ID):
|
|
writeErr(w, http.StatusConflict, "Task is paused; resume the task before sending a message to the Worker")
|
|
return
|
|
case lifecycle.Queued:
|
|
writeErr(w, http.StatusConflict, "A queued task cannot receive Worker messages")
|
|
return
|
|
case isTerminalStatus(lifecycle.Status):
|
|
writeErr(w, http.StatusConflict, "A terminal task cannot receive Worker messages")
|
|
return
|
|
case s.engine.isSettling(t.ID):
|
|
writeErr(w, http.StatusConflict, "Task is settling; cannot send a message to the Worker")
|
|
return
|
|
}
|
|
|
|
node, err := t.Store.GetNode(iid)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if node == nil {
|
|
if inherited, sourceErr := t.Store.GetNodeWithSources(iid); sourceErr == nil && inherited != nil && inherited.Inherited {
|
|
writeErr(w, http.StatusConflict, "Inherited intents are read-only and cannot receive Worker messages")
|
|
return
|
|
}
|
|
writeErr(w, http.StatusNotFound, "intent not found")
|
|
return
|
|
}
|
|
if node.Kind != db.KindIntent {
|
|
writeErr(w, http.StatusConflict, "node is not an intent")
|
|
return
|
|
}
|
|
if node.State != "paused" {
|
|
writeErr(w, http.StatusConflict, "Only a paused Worker accepts messages; pause it first")
|
|
return
|
|
}
|
|
agentMessage, ok := s.prepareChatMentionMessage(w, message)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// runDetachedIntent transitions paused->running, emits the user turn and starts a
|
|
// dedicated run. Root the run at s.ctx so a disconnected browser cannot strand it
|
|
// while task pause/delete/shutdown still stop it.
|
|
if err := s.engine.runDetachedIntent(s.ctx, t, iid, requestID, message, agentMessage); err != nil {
|
|
switch {
|
|
case errors.Is(err, db.ErrIntentStateConflict):
|
|
writeErr(w, http.StatusConflict, err.Error())
|
|
default:
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"id": iid,
|
|
"state": "running",
|
|
"accepted": true,
|
|
"request_id": requestID,
|
|
})
|
|
}
|