mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* MUL-6639: feat(skills): list skill files as metadata, not content `multica skill get` and `multica skill files list` both downloaded every file body to render output that shows none of them — `skill get`'s table view prints four columns, and the file listing prints paths and timestamps. Past a few hundred KB that stopped being waste and became a hard failure: a 599KB skill could not be fetched at all, and the listing that would have shown which file was oversized required downloading all of them first, so the diagnostic died with the thing it was meant to diagnose (GH #7498). Both endpoints now take `?include=content|metadata`. The metadata shape carries path, byte size and content hash per file, plus the size and hash of the SKILL.md body — computed in Postgres, so the bodies never leave the database. Response size now tracks file count, not content size. `GET /api/skills/{id}/files` defaults to metadata: it is a list endpoint, the CLI is its only caller, and `GET /api/skills` already dropped content for this exact reason (GH #2174). `GET /api/skills/{id}` keeps returning content by default — installed web and desktop builds call it for the skill editor and cannot be retrofitted with a query parameter. The CLI asks for metadata on both and takes `--with-content` when the bodies are actually wanted. The file listing gains a SIZE column, which is what makes an oversized skill diagnosable without downloading it. Co-authored-by: multica-agent <[email protected]> * MUL-6639: fix(cli): fail skill transfers on stall, not elapsed time `http.Client.Timeout` is a wall clock on the whole request, body included, so it punishes exactly the transfer that is working: a 599KB skill arriving steadily over a slow link was cut off at 30s mid-body, while a genuinely dead connection was held open for the same 30s (GH #7498). Raising the constant moves the cliff without removing it — the honest test is not "has this taken too long?" but "is it still making progress?". internal/cli/stall.go adds a client that asks the second question: bounded connect, TLS and response-header phases, and a response body that fails after 15s with no new bytes. The wall clock stays as a loose 10-minute backstop — demoted, not deleted, because dropping it to 0 would have left a stalled request upload with no deadline at all. Failures now say which thing went wrong. A new KindNetworkStalled reports "no data received for 15s after N bytes" rather than a timeout, because only one of those is fixed by waiting longer. Body-read errors are classified at all now: wrapTransport only ever saw errors from http.Client.Do, which returns once the headers arrive, so the failure #7498 actually reports reached users as a raw "context deadline exceeded ... while reading body" out of the JSON decoder. MULTICA_HTTP_STALL_TIMEOUT sets the no-progress budget. An explicitly set MULTICA_HTTP_TIMEOUT still applies on this path, keeping its plain meaning — the longest you will wait for a server — instead of silently reverting to a total-elapsed limit. Adoption starts with the skill commands, where the failure was reported; the mechanism itself is not skill-specific. Graduation is one edit: point NewAPIClient at NewStallAwareHTTPClient, fold StallAwareContext into APIContext, drop newSkillAPIClient. A permanent second client would leave the CLI with two timeout personalities, which is worse than either alone. Co-authored-by: multica-agent <[email protected]> * MUL-6639: fix(skills): hash raw UTF-8, keep the no-include default at content Review found two problems with the previous two commits. **The file hash was not over the file's bytes.** `sha256(content::bytea)` runs the bytea *input* parser over the text rather than taking its bytes, so it reads backslash escapes: a file containing `\x41` hashed as the single byte `A`, disagreeing silently with the Go-side hash of the SKILL.md body. Worse than the mismatch, and not caught earlier because the first check used accented text with no backslash in it: `content::bytea` *errors* on a bare backslash. `C:\Users\...`, a regex `\d+`, a LaTeX snippet — ordinary contents of a skill's supporting files — fail with "invalid input syntax for type bytea", making the listing 500 on the skills most likely to need it. `convert_to(content, 'UTF8')` takes the bytes, and matches Go exactly. **`/api/skills/{id}/files` must not change what a bare request returns.** The previous commit flipped its default to metadata on the grounds that the CLI is its only caller. That was the wrong reading of "only caller": the CLI is installed software too, and an older `skill files list --output json` reads `content`. It is the same argument that kept the detail endpoint's default — applied inconsistently to a client that upgrades separately from the server. Both endpoints now return content when `?include=` is absent. The shrink travels with the caller instead: the CLI sends `include=metadata` itself, so GH #7498 is fixed with no synchronized release, and the default can flip once clients that send `include=content` have aged in. The default is now uniform, so resolveSkillInclude no longer takes a per-endpoint parameter. Tests: TestSkillFileHashCoversRawUTF8Bytes covers `\x41`, invalid hex, bare backslashes, Unicode and empty bodies against the Go hash — verified to fail on the previous SQL. TestSkillEndpointsWithoutIncludeStillReturnContent pins the compatibility guarantee on both endpoints. Not addressed, and noted as follow-up: the metadata path still loads the full SKILL.md into Go, because loadSkillForUser needs the row for its tenant check. The HTTP response no longer grows with content; the database read still does. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
321 lines
10 KiB
Go
321 lines
10 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// trickleServer streams a JSON array in chunks, pausing between them, then
|
|
// optionally goes silent forever mid-body. It stands in for the link in GH
|
|
// #7498: bytes keep arriving, just not fast enough for a wall clock.
|
|
func trickleServer(t *testing.T, chunks int, pause time.Duration, stallAfter int) *httptest.Server {
|
|
t.Helper()
|
|
blocked := make(chan struct{})
|
|
t.Cleanup(func() { close(blocked) })
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
t.Error("ResponseWriter is not a Flusher")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, `{"items":[`)
|
|
flusher.Flush()
|
|
|
|
for i := range chunks {
|
|
if stallAfter >= 0 && i == stallAfter {
|
|
// Hold the response open without writing: the transfer is
|
|
// alive at the socket level and producing nothing.
|
|
select {
|
|
case <-blocked:
|
|
case <-r.Context().Done():
|
|
}
|
|
return
|
|
}
|
|
if i > 0 {
|
|
fmt.Fprint(w, ",")
|
|
}
|
|
fmt.Fprintf(w, `"%s"`, strings.Repeat("x", 512))
|
|
flusher.Flush()
|
|
time.Sleep(pause)
|
|
}
|
|
fmt.Fprint(w, `]}`)
|
|
flusher.Flush()
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
type trickleBody struct {
|
|
Items []string `json:"items"`
|
|
}
|
|
|
|
// The regression #7498 reports: a transfer that is working the whole time,
|
|
// killed for taking too long in total. The stall-aware client completes the
|
|
// same download the total-elapsed client cannot.
|
|
func TestStallAwareClientCompletesSlowButSteadyTransfer(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "400ms")
|
|
const (
|
|
chunks = 12
|
|
pause = 40 * time.Millisecond
|
|
)
|
|
|
|
t.Run("total-elapsed deadline kills a healthy transfer", func(t *testing.T) {
|
|
srv := trickleServer(t, chunks, pause, -1)
|
|
client := &APIClient{
|
|
BaseURL: srv.URL,
|
|
HTTPClient: &http.Client{Timeout: 200 * time.Millisecond},
|
|
}
|
|
var out trickleBody
|
|
err := client.GetJSON(context.Background(), "/slow", &out)
|
|
if err == nil {
|
|
t.Fatal("expected the total-elapsed client to fail; the fixture is not slow enough to be meaningful")
|
|
}
|
|
})
|
|
|
|
t.Run("stall detection lets it finish", func(t *testing.T) {
|
|
srv := trickleServer(t, chunks, pause, -1)
|
|
client := &APIClient{BaseURL: srv.URL, HTTPClient: NewStallAwareHTTPClient()}
|
|
|
|
var out trickleBody
|
|
if err := client.GetJSON(context.Background(), "/slow", &out); err != nil {
|
|
t.Fatalf("GetJSON: %v", err)
|
|
}
|
|
if len(out.Items) != chunks {
|
|
t.Fatalf("got %d items, want %d", len(out.Items), chunks)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestStallAwareClientFailsOnStalledBody(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "150ms")
|
|
srv := trickleServer(t, 20, 10*time.Millisecond, 4)
|
|
|
|
client := &APIClient{BaseURL: srv.URL, HTTPClient: NewStallAwareHTTPClient()}
|
|
|
|
start := time.Now()
|
|
var out trickleBody
|
|
err := client.GetJSON(context.Background(), "/stalls", &out)
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected a stalled transfer to fail")
|
|
}
|
|
|
|
var stalled *StallError
|
|
if !errors.As(err, &stalled) {
|
|
t.Fatalf("error is not a *StallError: %#v", err)
|
|
}
|
|
// Bytes did arrive before the silence — this is the signal that separates
|
|
// "slow link" from "server never answered", and the diagnostic #7498 asks
|
|
// for.
|
|
if stalled.BytesRead <= 0 {
|
|
t.Errorf("BytesRead = %d, want > 0 (some of the body arrived)", stalled.BytesRead)
|
|
}
|
|
if stalled.Op != "GET /stalls" {
|
|
t.Errorf("Op = %q, want %q", stalled.Op, "GET /stalls")
|
|
}
|
|
// It must fail on the stall budget, not on the whole-request ceiling.
|
|
if elapsed > 3*time.Second {
|
|
t.Errorf("took %s to notice a stall with a 150ms budget", elapsed)
|
|
}
|
|
}
|
|
|
|
// A stall must reach the user as a stall. It used to surface as a raw
|
|
// "context deadline exceeded ... while reading body" out of the JSON decoder,
|
|
// because only errors from http.Client.Do were ever classified.
|
|
func TestStalledTransferIsClassifiedAsNetworkError(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "150ms")
|
|
srv := trickleServer(t, 20, 10*time.Millisecond, 4)
|
|
|
|
client := &APIClient{BaseURL: srv.URL, HTTPClient: NewStallAwareHTTPClient()}
|
|
var out trickleBody
|
|
err := client.GetJSON(context.Background(), "/stalls", &out)
|
|
if err == nil {
|
|
t.Fatal("expected a stalled transfer to fail")
|
|
}
|
|
|
|
var netErr *NetworkError
|
|
if !errors.As(err, &netErr) {
|
|
t.Fatalf("error is not a *NetworkError: %#v", err)
|
|
}
|
|
if netErr.Kind != KindNetworkStalled {
|
|
t.Errorf("Kind = %v, want KindNetworkStalled", netErr.Kind)
|
|
}
|
|
if got := ExitCodeFor(err); got != ExitNetwork {
|
|
t.Errorf("ExitCodeFor = %d, want %d", got, ExitNetwork)
|
|
}
|
|
msg := FormatError(err, false)
|
|
if !strings.Contains(msg, "MULTICA_HTTP_STALL_TIMEOUT") {
|
|
t.Errorf("user message does not name the knob that fixes it: %q", msg)
|
|
}
|
|
if strings.Contains(msg, "context deadline exceeded") {
|
|
t.Errorf("user message leaked the raw transport error: %q", msg)
|
|
}
|
|
}
|
|
|
|
// wrapBodyRead must not turn every decode failure into a network error: a
|
|
// server that answers 200 with malformed JSON is a server bug, and calling it
|
|
// a connection problem sends the user to look at their network.
|
|
func TestMalformedJSONIsNotReportedAsNetworkFailure(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"items": [ not json`)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
client := &APIClient{BaseURL: srv.URL, HTTPClient: NewStallAwareHTTPClient()}
|
|
var out trickleBody
|
|
err := client.GetJSON(context.Background(), "/broken", &out)
|
|
if err == nil {
|
|
t.Fatal("expected a decode failure")
|
|
}
|
|
|
|
var netErr *NetworkError
|
|
if errors.As(err, &netErr) {
|
|
t.Fatalf("malformed JSON was classified as a network error: %v", netErr)
|
|
}
|
|
var syntaxErr *json.SyntaxError
|
|
if !errors.As(err, &syntaxErr) {
|
|
t.Fatalf("expected a *json.SyntaxError, got %#v", err)
|
|
}
|
|
}
|
|
|
|
func TestStallTimeoutEnvPrecedence(t *testing.T) {
|
|
t.Run("default when nothing is set", func(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "")
|
|
t.Setenv("MULTICA_HTTP_TIMEOUT", "")
|
|
if got := StallTimeout(); got != defaultStallTimeout {
|
|
t.Errorf("StallTimeout = %v, want %v", got, defaultStallTimeout)
|
|
}
|
|
})
|
|
|
|
// An explicitly set MULTICA_HTTP_TIMEOUT keeps meaning "how long am I
|
|
// willing to wait for this server" on the stall-aware path, rather than
|
|
// silently reverting to a total-elapsed limit.
|
|
t.Run("legacy timeout var still has effect", func(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "")
|
|
t.Setenv("MULTICA_HTTP_TIMEOUT", "90s")
|
|
if got, want := StallTimeout(), 90*time.Second; got != want {
|
|
t.Errorf("StallTimeout = %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("dedicated var wins", func(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "5s")
|
|
t.Setenv("MULTICA_HTTP_TIMEOUT", "90s")
|
|
if got, want := StallTimeout(), 5*time.Second; got != want {
|
|
t.Errorf("StallTimeout = %v, want %v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("bare seconds and invalid values", func(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_TIMEOUT", "")
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "45")
|
|
if got, want := StallTimeout(), 45*time.Second; got != want {
|
|
t.Errorf("StallTimeout = %v, want %v", got, want)
|
|
}
|
|
for _, bad := range []string{"nonsense", "0", "-5s"} {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", bad)
|
|
if got := StallTimeout(); got != defaultStallTimeout {
|
|
t.Errorf("StallTimeout with %q = %v, want the default %v", bad, got, defaultStallTimeout)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// The ceiling is a backstop, so it must never be the thing that fires first —
|
|
// including when someone sets a very generous stall budget.
|
|
func TestTransferCeilingStaysAboveStallBudget(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_TIMEOUT", "")
|
|
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "")
|
|
if got := TransferCeiling(); got != defaultTransferCeiling {
|
|
t.Errorf("TransferCeiling = %v, want %v", got, defaultTransferCeiling)
|
|
}
|
|
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "20m")
|
|
if got, want := TransferCeiling(), 80*time.Minute; got != want {
|
|
t.Errorf("TransferCeiling = %v, want %v", got, want)
|
|
}
|
|
|
|
// And it is never absent: an unbounded command is not an improvement over
|
|
// one that gives up too early.
|
|
if TransferCeiling() <= 0 {
|
|
t.Error("TransferCeiling must always be positive")
|
|
}
|
|
}
|
|
|
|
func TestStallAwareContextLeavesTransportDeadlineFirst(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "")
|
|
t.Setenv("MULTICA_HTTP_TIMEOUT", "")
|
|
|
|
ctx, cancel := StallAwareContext(context.Background())
|
|
defer cancel()
|
|
deadline, ok := ctx.Deadline()
|
|
if !ok {
|
|
t.Fatal("StallAwareContext must set a deadline")
|
|
}
|
|
if budget := time.Until(deadline); budget <= TransferCeiling() {
|
|
t.Errorf("context budget %v must exceed the transport ceiling %v", budget, TransferCeiling())
|
|
}
|
|
}
|
|
|
|
// Closing a body early must not leave the guard's timer or context alive.
|
|
func TestStallGuardBodyCloseReleasesGuard(t *testing.T) {
|
|
t.Setenv("MULTICA_HTTP_STALL_TIMEOUT", "50ms")
|
|
srv := trickleServer(t, 20, 10*time.Millisecond, 4)
|
|
|
|
client := NewStallAwareHTTPClient()
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL+"/stalls", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest: %v", err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Do: %v", err)
|
|
}
|
|
if err := resp.Body.Close(); err != nil {
|
|
t.Errorf("Close: %v", err)
|
|
}
|
|
// Closing twice must not panic on the already-stopped timer.
|
|
_ = resp.Body.Close()
|
|
}
|
|
|
|
// A body that simply stops mid-JSON is a dropped connection far more often
|
|
// than it is a server emitting truncated JSON, so it is classified as a
|
|
// network failure. This test pins that choice rather than leaving it to
|
|
// whichever error the decoder happens to return.
|
|
func TestTruncatedBodyIsReportedAsNetworkFailure(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Length", "64")
|
|
fmt.Fprint(w, `{"items": ["abc`)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
client := &APIClient{BaseURL: srv.URL, HTTPClient: NewStallAwareHTTPClient()}
|
|
var out trickleBody
|
|
err := client.GetJSON(context.Background(), "/truncated", &out)
|
|
if err == nil {
|
|
t.Fatal("expected a truncated body to fail")
|
|
}
|
|
|
|
var netErr *NetworkError
|
|
if !errors.As(err, &netErr) {
|
|
t.Fatalf("truncated body was not classified as a network error: %#v", err)
|
|
}
|
|
if got := ExitCodeFor(err); got != ExitNetwork {
|
|
t.Errorf("ExitCodeFor = %d, want %d", got, ExitNetwork)
|
|
}
|
|
}
|