Files
54027ba763 MUL-6639: fix(skills): metadata-only skill listings and stall-based timeouts (#7574)
* 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]>
2026-08-26 15:45:06 +08:00

273 lines
9.4 KiB
Go

package cli
import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
// Transfer-stall detection.
//
// 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 bundle
// arriving steadily over a slow link is killed at 30s mid-body, while a
// genuinely dead connection is held open for the same 30s (GH #7498). The
// honest question is not "has this taken too long?" but "is it still making
// progress?".
//
// So the client below demotes the wall clock to a backstop and makes progress
// the primary test: bounded connect and response-header phases, a body read
// that must keep producing bytes, and a ceiling loose enough that no healthy
// transfer reaches it. Slow-but-alive transfers finish; dead ones fail sooner
// than they used to.
//
// The mechanism is general and lives here for any caller. Adoption starts with
// the skill commands (see newSkillAPIClient) because that is where the failure
// was reported. Graduation is deliberately one edit: point NewAPIClient at
// NewStallAwareHTTPClient, fold StallAwareContext into APIContext, and delete
// the skill-local constructor. A permanent second HTTP client would leave the
// CLI with two timeout personalities, which is worse than either one alone.
const (
// defaultStallTimeout is how long a transfer may produce no bytes at all
// before it is considered dead. Tighter than the 30s total timeout it
// replaces: with progress as the test, there is no reason to wait as long
// as a whole slow download might legitimately take.
defaultStallTimeout = 15 * time.Second
// defaultTransferCeiling bounds a command that keeps making progress
// forever (a server trickling bytes indefinitely). It is a backstop, not
// the primary failure test, so it is loose enough that no healthy transfer
// reaches it — but it is never absent.
defaultTransferCeiling = 10 * time.Minute
)
// StallTimeout returns the no-progress budget for a single read.
//
// MULTICA_HTTP_STALL_TIMEOUT sets it directly. Failing that, an explicitly set
// MULTICA_HTTP_TIMEOUT is honored: on this path it keeps its plain-language
// meaning — the longest the user is willing to wait with nothing arriving —
// rather than silently becoming a total-elapsed limit again. With neither set,
// defaultStallTimeout applies.
func StallTimeout() time.Duration {
if d, ok := durationFromEnv("MULTICA_HTTP_STALL_TIMEOUT"); ok {
return d
}
if _, ok := durationFromEnv("MULTICA_HTTP_TIMEOUT"); ok {
return httpTimeout()
}
return defaultStallTimeout
}
// TransferCeiling returns the hard upper bound for a stall-aware command. It
// never drops below a multiple of the stall budget, so raising the stall
// threshold cannot produce a ceiling that fires first and hides it.
func TransferCeiling() time.Duration {
ceiling := defaultTransferCeiling
if floor := 4 * StallTimeout(); floor > ceiling {
ceiling = floor
}
return ceiling
}
// durationFromEnv parses a duration env var, accepting either a Go duration
// string ("45s", "2m") or a plain integer number of seconds ("45"), matching
// httpTimeout. It reports false for unset, empty, invalid, or non-positive
// values so callers can distinguish "user chose this" from "fall back".
func durationFromEnv(name string) (time.Duration, bool) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return 0, false
}
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d, true
}
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second, true
}
return 0, false
}
// StallError reports a transfer that stopped producing bytes. It is distinct
// from a deadline error on purpose: "no data for 15s after 200KB" and "the
// whole command hit its ceiling" point at different causes and different
// fixes, and collapsing them into one "request timed out" is what made #7498
// hard to diagnose from the client side.
type StallError struct {
// Idle is the no-progress budget that was exceeded.
Idle time.Duration
// BytesRead is how much of the body had arrived before it stalled. Zero
// means the body never started, which usually means the connection or the
// server died rather than the link being slow.
BytesRead int64
Op string // e.g. "GET /api/skills/abc" — shown only in --debug
Err error // the underlying cancellation, for --debug
}
func (e *StallError) Error() string {
msg := fmt.Sprintf("transfer stalled: no data received for %s after %d bytes", e.Idle, e.BytesRead)
if e.Op != "" {
return e.Op + ": " + msg
}
return msg
}
func (e *StallError) Unwrap() error { return e.Err }
// NewStallAwareHTTPClient builds the HTTP client described at the top of this
// file: bounded connect/TLS/header phases, a response body that fails when it
// stops making progress, and a loose whole-request ceiling behind both.
func NewStallAwareHTTPClient() *http.Client {
idle := StallTimeout()
base := &http.Transport{
// ProxyFromEnvironment matches http.DefaultTransport. Dropping it
// would silently break every user behind a corporate proxy, which is
// a population that overlaps heavily with the slow links this change
// exists for.
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: idle, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: idle,
ExpectContinueTimeout: 1 * time.Second,
// Covers the phase the body guard cannot see: connected, request
// sent, server never replies.
ResponseHeaderTimeout: idle,
}
return &http.Client{
Transport: &stallGuardTransport{base: base, idle: idle},
// The wall clock stays, but demoted from primary failure test to
// backstop, and set loose enough that no healthy transfer reaches it.
// Dropping it to 0 would have left a stalled request *upload* — which
// the response-body guard cannot see — with no deadline at all, so
// removing one wall clock would have quietly removed two.
Timeout: TransferCeiling(),
}
}
// StallAwareContext derives a command context for a client built by
// NewStallAwareHTTPClient. Like APIContext it leaves the transport's own
// deadline the one that fires, so the failure arrives as a classifiable
// network error rather than a bare context cancellation.
func StallAwareContext(parent context.Context) (context.Context, context.CancelFunc) {
if parent == nil {
parent = context.Background()
}
return context.WithTimeout(parent, TransferCeiling()+apiContextGrace)
}
// stallGuardTransport wraps every response body in a progress check.
type stallGuardTransport struct {
base http.RoundTripper
idle time.Duration
}
func (t *stallGuardTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Canceling this context is what actually interrupts a blocked body read;
// closing the body from another goroutine would race with the reader.
// req.WithContext copies the request, so the caller's is untouched as the
// RoundTripper contract requires.
ctx, cancel := context.WithCancel(req.Context())
resp, err := t.base.RoundTrip(req.WithContext(ctx))
if err != nil {
cancel()
return nil, err
}
op := ""
if req.URL != nil {
op = req.Method + " " + req.URL.Path
}
resp.Body = newStallGuardBody(resp.Body, t.idle, cancel, op)
return resp, nil
}
// stallGuardBody fails a read that has produced no bytes for idle.
//
// The timer re-checks rather than being reset on every Read: a Reset on the
// hot path races with the timer firing, and could kill a transfer that just
// delivered data. Here the timer wakes up, re-reads progress under the same
// lock that Read writes it, and either re-arms for the remaining time or
// trips. Cost is at most one extra wakeup per idle window.
type stallGuardBody struct {
rc io.ReadCloser
idle time.Duration
cancel context.CancelFunc
op string
timer *time.Timer
mu sync.Mutex
last time.Time
read int64
tripped bool
closed bool
}
func newStallGuardBody(rc io.ReadCloser, idle time.Duration, cancel context.CancelFunc, op string) *stallGuardBody {
b := &stallGuardBody{rc: rc, idle: idle, cancel: cancel, op: op, last: time.Now()}
b.timer = time.AfterFunc(idle, b.check)
return b
}
func (b *stallGuardBody) check() {
b.mu.Lock()
if b.closed || b.tripped {
b.mu.Unlock()
return
}
if remaining := b.idle - time.Since(b.last); remaining > 0 {
b.timer.Reset(remaining)
b.mu.Unlock()
return
}
b.tripped = true
b.mu.Unlock()
b.cancel()
}
func (b *stallGuardBody) Read(p []byte) (int, error) {
n, err := b.rc.Read(p)
if n > 0 {
b.mu.Lock()
b.read += int64(n)
b.last = time.Now()
b.mu.Unlock()
}
if err != nil && err != io.EOF {
if stalled := b.stallError(err); stalled != nil {
return n, stalled
}
}
return n, err
}
func (b *stallGuardBody) Close() error {
b.mu.Lock()
b.closed = true
b.mu.Unlock()
b.timer.Stop()
err := b.rc.Close()
// Release the derived context only after the body is done with it;
// canceling earlier would abort the read this guard is protecting.
b.cancel()
return err
}
// stallError translates the cancellation the guard caused into the reason for
// it. Errors from any other cause pass through untouched.
func (b *stallGuardBody) stallError(cause error) *StallError {
b.mu.Lock()
defer b.mu.Unlock()
if !b.tripped {
return nil
}
return &StallError{Idle: b.idle, BytesRead: b.read, Op: b.op, Err: cause}
}