mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +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]>
285 lines
9.5 KiB
Go
285 lines
9.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
)
|
|
|
|
// newLargeSkillFixture builds a skill whose file bodies dwarf its metadata,
|
|
// which is the condition GH #7498 reports: the response was large because it
|
|
// inlined every body, and the command that would have said so was the one that
|
|
// could not finish.
|
|
func newLargeSkillFixture(t *testing.T) (skillID string, bodies map[string]string) {
|
|
t.Helper()
|
|
|
|
skillID = dbfx.Insert(t, "skill", testutil.Cols{
|
|
"workspace_id": testWorkspaceID,
|
|
"name": "skill-metadata-fixture",
|
|
"description": "fixture for include= tests",
|
|
"content": strings.Repeat("s", 4096),
|
|
"created_by": testUserID,
|
|
})
|
|
|
|
bodies = map[string]string{
|
|
"reference.md": strings.Repeat("r", 60_000),
|
|
"scripts/setup.sh": strings.Repeat("x", 128),
|
|
}
|
|
for path, body := range bodies {
|
|
dbfx.Insert(t, "skill_file", testutil.Cols{
|
|
"skill_id": skillID,
|
|
"path": path,
|
|
"content": body,
|
|
})
|
|
}
|
|
return skillID, bodies
|
|
}
|
|
|
|
func skillRequest(t *testing.T, skillID, path string) *http.Request {
|
|
t.Helper()
|
|
return withURLParam(newRequest(http.MethodGet, path, nil), "id", skillID)
|
|
}
|
|
|
|
func TestListSkillFilesMetadataOmitsBodies(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("no database available")
|
|
}
|
|
skillID, bodies := newLargeSkillFixture(t)
|
|
|
|
req := skillRequest(t, skillID, "/api/skills/"+skillID+"/files?include=metadata")
|
|
var resp []SkillFileMetadataResponse
|
|
res := testutil.Call(t, testHandler.ListSkillFiles, req).Want(http.StatusOK).JSON(&resp)
|
|
|
|
if len(resp) != len(bodies) {
|
|
t.Fatalf("got %d files, want %d", len(resp), len(bodies))
|
|
}
|
|
for _, f := range resp {
|
|
want, ok := bodies[f.Path]
|
|
if !ok {
|
|
t.Fatalf("unexpected file %q in response", f.Path)
|
|
}
|
|
if f.Size != int64(len(want)) {
|
|
t.Errorf("%s: size = %d, want %d", f.Path, f.Size, len(want))
|
|
}
|
|
if expect := contentHash(want); f.ContentHash != expect {
|
|
t.Errorf("%s: content_hash = %q, want %q", f.Path, f.ContentHash, expect)
|
|
}
|
|
}
|
|
|
|
// Asserting on the response bytes rather than on a missing struct field is
|
|
// deliberate: a body that reappeared under a different key would satisfy a
|
|
// field-name check and still reproduce the bug.
|
|
if strings.Contains(res.Text(), strings.Repeat("r", 1000)) {
|
|
t.Error("response inlined a file body")
|
|
}
|
|
if got, limit := res.Body.Len(), 4096; got > limit {
|
|
t.Errorf("metadata listing is %d bytes, want <= %d", got, limit)
|
|
}
|
|
}
|
|
|
|
func TestListSkillFilesIncludeContentReturnsBodies(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("no database available")
|
|
}
|
|
skillID, bodies := newLargeSkillFixture(t)
|
|
|
|
req := skillRequest(t, skillID, "/api/skills/"+skillID+"/files?include=content")
|
|
resp := testutil.Decode[[]SkillFileResponse](t, testHandler.ListSkillFiles, req, http.StatusOK)
|
|
|
|
if len(resp) != len(bodies) {
|
|
t.Fatalf("got %d files, want %d", len(resp), len(bodies))
|
|
}
|
|
for _, f := range resp {
|
|
if want := bodies[f.Path]; f.Content != want {
|
|
t.Errorf("%s: content length = %d, want %d", f.Path, len(f.Content), len(want))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Neither endpoint may change what a request without `?include=` receives.
|
|
// Their callers are installed software — desktop builds, and older CLI
|
|
// versions whose `skill files list --output json` scripts read `content` — so
|
|
// a server deploy that flipped a default would silently take content away from
|
|
// clients that have no way to ask for it back.
|
|
func TestSkillEndpointsWithoutIncludeStillReturnContent(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("no database available")
|
|
}
|
|
skillID, bodies := newLargeSkillFixture(t)
|
|
|
|
t.Run("files listing", func(t *testing.T) {
|
|
req := skillRequest(t, skillID, "/api/skills/"+skillID+"/files")
|
|
resp := testutil.Decode[[]SkillFileResponse](t, testHandler.ListSkillFiles, req, http.StatusOK)
|
|
if len(resp) != len(bodies) {
|
|
t.Fatalf("got %d files, want %d", len(resp), len(bodies))
|
|
}
|
|
for _, f := range resp {
|
|
if want := bodies[f.Path]; f.Content != want {
|
|
t.Errorf("%s: content length = %d, want %d", f.Path, len(f.Content), len(want))
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("skill detail", func(t *testing.T) {
|
|
req := skillRequest(t, skillID, "/api/skills/"+skillID)
|
|
resp := testutil.Decode[SkillWithFilesResponse](t, testHandler.GetSkill, req, http.StatusOK)
|
|
if len(resp.Content) != 4096 {
|
|
t.Errorf("content length = %d, want 4096", len(resp.Content))
|
|
}
|
|
for _, f := range resp.Files {
|
|
if want := bodies[f.Path]; f.Content != want {
|
|
t.Errorf("%s: content length = %d, want %d", f.Path, len(f.Content), len(want))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetSkillIncludeMetadataDropsBodies(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("no database available")
|
|
}
|
|
skillID, bodies := newLargeSkillFixture(t)
|
|
|
|
req := skillRequest(t, skillID, "/api/skills/"+skillID+"?include=metadata")
|
|
var resp SkillWithFileMetadataResponse
|
|
res := testutil.Call(t, testHandler.GetSkill, req).Want(http.StatusOK).JSON(&resp)
|
|
|
|
if resp.ID != skillID {
|
|
t.Errorf("id = %q, want %q", resp.ID, skillID)
|
|
}
|
|
if resp.Name != "skill-metadata-fixture" {
|
|
t.Errorf("name = %q, want the fixture name", resp.Name)
|
|
}
|
|
if resp.ContentSize != 4096 {
|
|
t.Errorf("content_size = %d, want 4096", resp.ContentSize)
|
|
}
|
|
if want := contentHash(strings.Repeat("s", 4096)); resp.ContentHash != want {
|
|
t.Errorf("content_hash = %q, want %q", resp.ContentHash, want)
|
|
}
|
|
if len(resp.Files) != len(bodies) {
|
|
t.Fatalf("got %d files, want %d", len(resp.Files), len(bodies))
|
|
}
|
|
|
|
if strings.Contains(res.Text(), strings.Repeat("s", 1000)) {
|
|
t.Error("response inlined the SKILL.md body")
|
|
}
|
|
if strings.Contains(res.Text(), strings.Repeat("r", 1000)) {
|
|
t.Error("response inlined a file body")
|
|
}
|
|
if got, limit := res.Body.Len(), 4096; got > limit {
|
|
t.Errorf("metadata response is %d bytes, want <= %d", got, limit)
|
|
}
|
|
}
|
|
|
|
// A metadata response must stay small no matter how big the skill gets — that
|
|
// is the property the fix is actually about. Ten more files of 60KB each add
|
|
// rows, not megabytes.
|
|
func TestSkillMetadataSizeDoesNotTrackContentSize(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("no database available")
|
|
}
|
|
skillID, _ := newLargeSkillFixture(t)
|
|
|
|
measure := func() int {
|
|
req := skillRequest(t, skillID, "/api/skills/"+skillID+"/files?include=metadata")
|
|
return testutil.Call(t, testHandler.ListSkillFiles, req).Want(http.StatusOK).Body.Len()
|
|
}
|
|
|
|
before := measure()
|
|
for i := range 10 {
|
|
dbfx.Insert(t, "skill_file", testutil.Cols{
|
|
"skill_id": skillID,
|
|
"path": "bulk/" + string(rune('a'+i)) + ".md",
|
|
"content": strings.Repeat("b", 60_000),
|
|
})
|
|
}
|
|
after := measure()
|
|
|
|
// 600KB of new content; the listing may only grow by its 10 new rows.
|
|
if grew := after - before; grew > 3000 {
|
|
t.Errorf("listing grew by %d bytes after adding 600KB of content, want a per-row increase", grew)
|
|
}
|
|
}
|
|
|
|
// The size and hash are computed in SQL, so they must agree with Go's view of
|
|
// the same bytes for every body a skill can legally hold.
|
|
//
|
|
// `sha256(content::bytea)` did not: that cast runs the bytea *input* parser
|
|
// over the text, so `\x41` hashed as the single byte `A`, and any bare
|
|
// backslash — a regex, a Windows path — failed the whole request with "invalid
|
|
// input syntax for type bytea". Skill files are full of both.
|
|
func TestSkillFileHashCoversRawUTF8Bytes(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("no database available")
|
|
}
|
|
|
|
const skillBody = `SKILL.md with \x41 and C:\path`
|
|
bodies := map[string]string{
|
|
"hex-escape.md": `\x41`,
|
|
"invalid-hex.md": `\xzz`,
|
|
"bare-backslash.md": `C:\Users\skill`,
|
|
"regex.md": `^\d+\s*$`,
|
|
"unicode.md": "héllo 世界 🌍",
|
|
"empty.md": "",
|
|
}
|
|
|
|
skillID := dbfx.Insert(t, "skill", testutil.Cols{
|
|
"workspace_id": testWorkspaceID,
|
|
"name": "skill-hash-fixture",
|
|
"description": "bodies that break a bytea cast",
|
|
"content": skillBody,
|
|
"created_by": testUserID,
|
|
})
|
|
for path, body := range bodies {
|
|
dbfx.Insert(t, "skill_file", testutil.Cols{
|
|
"skill_id": skillID,
|
|
"path": path,
|
|
"content": body,
|
|
})
|
|
}
|
|
|
|
req := skillRequest(t, skillID, "/api/skills/"+skillID+"/files?include=metadata")
|
|
resp := testutil.Decode[[]SkillFileMetadataResponse](t, testHandler.ListSkillFiles, req, http.StatusOK)
|
|
|
|
if len(resp) != len(bodies) {
|
|
t.Fatalf("got %d files, want %d", len(resp), len(bodies))
|
|
}
|
|
for _, f := range resp {
|
|
want := bodies[f.Path]
|
|
if f.Size != int64(len(want)) {
|
|
t.Errorf("%s: size = %d, want %d", f.Path, f.Size, len(want))
|
|
}
|
|
if expect := contentHash(want); f.ContentHash != expect {
|
|
t.Errorf("%s (%q): content_hash = %q, want %q", f.Path, want, f.ContentHash, expect)
|
|
}
|
|
}
|
|
|
|
// Same bytes, same rule, for the SKILL.md body — that one is hashed in Go,
|
|
// so this pins the SQL and Go implementations to each other.
|
|
detail := testutil.Decode[SkillWithFileMetadataResponse](t,
|
|
testHandler.GetSkill, skillRequest(t, skillID, "/api/skills/"+skillID+"?include=metadata"), http.StatusOK)
|
|
if detail.ContentSize != int64(len(skillBody)) {
|
|
t.Errorf("content_size = %d, want %d", detail.ContentSize, len(skillBody))
|
|
}
|
|
if want := contentHash(skillBody); detail.ContentHash != want {
|
|
t.Errorf("content_hash = %q, want %q", detail.ContentHash, want)
|
|
}
|
|
}
|
|
|
|
func TestSkillIncludeRejectsUnknownValue(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("no database available")
|
|
}
|
|
skillID, _ := newLargeSkillFixture(t)
|
|
|
|
testutil.Call(t, testHandler.GetSkill,
|
|
skillRequest(t, skillID, "/api/skills/"+skillID+"?include=everything")).
|
|
Want(http.StatusBadRequest)
|
|
|
|
testutil.Call(t, testHandler.ListSkillFiles,
|
|
skillRequest(t, skillID, "/api/skills/"+skillID+"/files?include=everything")).
|
|
Want(http.StatusBadRequest)
|
|
}
|