Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

177 lines
4.8 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"go.kenn.io/agentsview/internal/config"
)
type daemonPushRequest struct {
Full bool `json:"full"`
Projects []string `json:"projects,omitempty"`
ExcludeProjects []string `json:"exclude_projects,omitempty"`
PG *config.PGConfig `json:"pg,omitempty"`
DuckDB *config.DuckDBConfig `json:"duckdb,omitempty"`
SyncStateTarget string `json:"sync_state_target,omitempty"`
MigrateLegacySyncState bool `json:"migrate_legacy_sync_state,omitempty"`
// NoVectors mirrors the CLI --no-vectors flag into the daemon: it has no
// per-invocation flag of its own, so the gate must travel in the request.
NoVectors bool `json:"no_vectors,omitempty"`
}
// postDaemonPush delegates a push to the local daemon. It negotiates an SSE
// response so the daemon can stream per-phase progress while the push runs;
// each progress event is decoded as P and handed to onProgress (which may be
// nil). A plain JSON response — the daemon streams only when it can flush —
// is decoded directly as the result T.
func postDaemonPush[T, P any](
ctx context.Context,
tr transport,
authToken string,
path string,
body daemonPushRequest,
onProgress func(P),
) (T, error) {
var zero T
data, err := json.Marshal(body)
if err != nil {
return zero, err
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, strings.TrimSuffix(tr.URL, "/")+path,
bytes.NewReader(data),
)
if err != nil {
return zero, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Origin", tr.URL)
if authToken != "" {
req.Header.Set("Authorization", "Bearer "+authToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return zero, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return zero, daemonPushError(resp.StatusCode, msg)
}
if strings.HasPrefix(
resp.Header.Get("Content-Type"), "text/event-stream",
) {
return parseDaemonPushSSE[T](resp.Body, onProgress)
}
var out T
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return zero, err
}
return out, nil
}
// daemonPushError renders a non-200 daemon response, preferring the API's
// {"error": ...} body over the raw payload.
func daemonPushError(status int, body []byte) error {
var apiErr struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Error != "" {
return errors.New(apiErr.Error)
}
return fmt.Errorf("HTTP %d: %s", status, strings.TrimSpace(string(body)))
}
// parseDaemonPushSSE consumes the daemon push event stream: "progress" events
// decode as P and feed onProgress, a "done" event decodes as the result T,
// and an "error" event (an {"error": ...} body) fails the push. A stream that
// ends without a done event is an error — the daemon died mid-push.
func parseDaemonPushSSE[T, P any](
r io.Reader, onProgress func(P),
) (T, error) {
var zero T
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
var event string
var data strings.Builder
var done bool
var result T
var pushErr error
dispatch := func() error {
if data.Len() == 0 {
return nil
}
switch event {
case "done":
if err := json.Unmarshal([]byte(data.String()), &result); err != nil {
return fmt.Errorf("decoding daemon push result: %w", err)
}
done = true
case "progress":
if onProgress == nil {
return nil
}
var p P
if err := json.Unmarshal([]byte(data.String()), &p); err != nil {
return fmt.Errorf("decoding daemon push progress: %w", err)
}
onProgress(p)
default:
var apiErr struct {
Error string `json:"error"`
}
raw := data.String()
if err := json.Unmarshal([]byte(raw), &apiErr); err == nil &&
apiErr.Error != "" {
pushErr = errors.New(apiErr.Error)
} else {
pushErr = fmt.Errorf("daemon push error: %s", raw)
}
}
return nil
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if err := dispatch(); err != nil {
return zero, err
}
event = ""
data.Reset()
continue
}
if value, ok := strings.CutPrefix(line, "event: "); ok {
event = value
continue
}
if value, ok := strings.CutPrefix(line, "data: "); ok {
if data.Len() > 0 {
data.WriteByte('\n')
}
data.WriteString(value)
}
}
if err := scanner.Err(); err != nil {
return zero, err
}
if err := dispatch(); err != nil {
return zero, err
}
if pushErr != nil {
return zero, pushErr
}
if !done {
return zero, errors.New("daemon push response missing done event")
}
return result, nil
}