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]>
809 lines
26 KiB
Go
809 lines
26 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ClientVersion is the CLI version sent on every request as X-Client-Version.
|
|
// Set by the multica binary at init() so the package doesn't depend on the
|
|
// concrete cmd package. Defaults to "dev" when running unset (e.g. tests).
|
|
var ClientVersion = "dev"
|
|
|
|
// ClientPlatform identifies this client to the server. Override for tests
|
|
// or alternative entry points; defaults to "cli".
|
|
var ClientPlatform = "cli"
|
|
|
|
// ClientOS is the normalized operating system string sent as X-Client-OS.
|
|
// Computed once from runtime.GOOS so the server doesn't need to reverse-map
|
|
// Go's os names ("darwin"/"windows"/"linux") into the protocol vocabulary.
|
|
var ClientOS = normalizeGOOS(runtime.GOOS)
|
|
|
|
func normalizeGOOS(goos string) string {
|
|
switch goos {
|
|
case "darwin":
|
|
return "macos"
|
|
case "windows":
|
|
return "windows"
|
|
case "linux":
|
|
return "linux"
|
|
default:
|
|
return goos
|
|
}
|
|
}
|
|
|
|
// APIClient is a REST client for the Multica server API.
|
|
// Used by ctrl subcommands (agent, runtime, status, etc.). Requests
|
|
// automatically include auth and execution context headers when configured.
|
|
type APIClient struct {
|
|
BaseURL string
|
|
WorkspaceID string
|
|
Token string
|
|
AgentID string // When set, requests are attributed to this agent instead of the user.
|
|
TaskID string // When set, sent as X-Task-ID for agent-task validation.
|
|
HTTPClient *http.Client
|
|
|
|
// Identity overrides. Empty values fall back to the package-level
|
|
// ClientPlatform / ClientVersion / ClientOS.
|
|
Platform string
|
|
Version string
|
|
OS string
|
|
}
|
|
|
|
// TaskTokenPrefix marks a task-scoped agent token, as minted by
|
|
// internal/auth.NewAgentTaskToken. A 401 on one is not the "your login
|
|
// expired" that a 401 on a member credential is: the token belongs to a single
|
|
// run and there is no sign-in that brings it back. Why it was rejected is not
|
|
// something the CLI can see — a terminal task is the usual cause, but a
|
|
// malformed token, one sent to the wrong server, and one dropped by an
|
|
// unrelated cleanup all look identical from here.
|
|
const TaskTokenPrefix = "mat_"
|
|
|
|
type HTTPError struct {
|
|
Method string
|
|
Path string
|
|
StatusCode int
|
|
Body string
|
|
// TaskScoped records that the failing request actually carried a
|
|
// task-scoped `mat_` token. It changes nothing about the request; it only
|
|
// lets FormatError tell a 401 worth signing in again for from one where
|
|
// the right move is to stop (GH #7522).
|
|
TaskScoped bool
|
|
}
|
|
|
|
func (e *HTTPError) Error() string {
|
|
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, strings.TrimSpace(e.Body))
|
|
}
|
|
|
|
// newHTTPError builds a *HTTPError from an error response (status >= 400),
|
|
// reading a capped slice of the body. Every Multica API helper funnels its
|
|
// >= 400 responses through this so the top-level FormatError / ExitCodeFor can
|
|
// classify the failure via errors.As(err, **HTTPError) regardless of which
|
|
// HTTP verb the command used.
|
|
func newHTTPError(method, path string, resp *http.Response) *HTTPError {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return &HTTPError{
|
|
Method: method,
|
|
Path: path,
|
|
StatusCode: resp.StatusCode,
|
|
Body: strings.TrimSpace(string(data)),
|
|
TaskScoped: requestUsedTaskToken(resp),
|
|
}
|
|
}
|
|
|
|
// requestUsedTaskToken reports whether the request that produced resp actually
|
|
// sent a task token.
|
|
//
|
|
// Reading the client's own Token field would be wrong twice over. DownloadFile
|
|
// deliberately sends no Authorization header for an absolute signed URL, so a
|
|
// 401 from object storage would be reported as a rejected task token purely
|
|
// because the client happened to hold a `mat_` token. And Go strips Authorization
|
|
// across a cross-host redirect, so the request that was sent is not always the
|
|
// one the caller built. resp.Request is the request that actually went out,
|
|
// after redirects, which is the only thing this claim can honestly rest on.
|
|
func requestUsedTaskToken(resp *http.Response) bool {
|
|
if resp == nil || resp.Request == nil {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(resp.Request.Header.Get("Authorization"), "Bearer "+TaskTokenPrefix)
|
|
}
|
|
|
|
// defaultHTTPTimeout is the per-request timeout for the CLI's HTTP client.
|
|
// It can be overridden with the MULTICA_HTTP_TIMEOUT environment variable
|
|
// (see httpTimeout). 30s is chosen over the historical 15s because complex
|
|
// networks (notably in mainland China) routinely need more than 15s to
|
|
// complete the TLS handshake plus request round-trip, which surfaced as an
|
|
// opaque "context deadline exceeded" to users.
|
|
const defaultHTTPTimeout = 30 * time.Second
|
|
|
|
// httpTimeout returns the HTTP client timeout, honoring MULTICA_HTTP_TIMEOUT.
|
|
// The value may be a Go duration string ("45s", "2m") or a plain integer
|
|
// number of seconds ("45"). Invalid or non-positive values fall back to the
|
|
// default.
|
|
func httpTimeout() time.Duration {
|
|
v := strings.TrimSpace(os.Getenv("MULTICA_HTTP_TIMEOUT"))
|
|
if v == "" {
|
|
return defaultHTTPTimeout
|
|
}
|
|
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
|
return d
|
|
}
|
|
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
return defaultHTTPTimeout
|
|
}
|
|
|
|
// apiContextGrace is added on top of the HTTP transport timeout when deriving
|
|
// a command-level context deadline, so the transport timeout (which produces a
|
|
// clean, classifiable "request timed out" error) is the one that fires rather
|
|
// than the outer context being canceled first.
|
|
const apiContextGrace = 5 * time.Second
|
|
|
|
// APITimeout returns the deadline budget for a single CLI API command. It is
|
|
// always at least the configured HTTP transport timeout (see httpTimeout,
|
|
// which honors MULTICA_HTTP_TIMEOUT) plus a small grace margin, so a
|
|
// command-level context never truncates an in-flight request below the timeout
|
|
// the user configured. This is the fix for command contexts that previously
|
|
// hardcoded a 15s deadline shorter than the 30s/env transport timeout.
|
|
func APITimeout() time.Duration {
|
|
return AtLeastAPITimeout(0)
|
|
}
|
|
|
|
// AtLeastAPITimeout returns max(min, APITimeout()). Use it for commands that
|
|
// need a larger floor than usual (for example file uploads, which historically
|
|
// used a 60s budget).
|
|
func AtLeastAPITimeout(min time.Duration) time.Duration {
|
|
budget := httpTimeout() + apiContextGrace
|
|
if min > budget {
|
|
return min
|
|
}
|
|
return budget
|
|
}
|
|
|
|
// APIContext derives a command-scoped context whose deadline is APITimeout().
|
|
// The returned cancel func must be called (typically via defer) to release
|
|
// resources. Commands should use this instead of context.WithTimeout with a
|
|
// hardcoded duration so the deadline always respects MULTICA_HTTP_TIMEOUT.
|
|
func APIContext(parent context.Context) (context.Context, context.CancelFunc) {
|
|
if parent == nil {
|
|
parent = context.Background()
|
|
}
|
|
return context.WithTimeout(parent, APITimeout())
|
|
}
|
|
|
|
// NewAPIClient creates a new API client for ctrl commands.
|
|
func NewAPIClient(baseURL, workspaceID, token string) *APIClient {
|
|
return &APIClient{
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
WorkspaceID: workspaceID,
|
|
Token: token,
|
|
HTTPClient: &http.Client{Timeout: httpTimeout()},
|
|
}
|
|
}
|
|
|
|
// clientCapabilities is the X-Client-Capabilities value every CLI request
|
|
// advertises. It is a protocol detail, not a user-visible flag: the CLI always
|
|
// knows how to handle these response shapes, so there is nothing for a caller
|
|
// to opt into.
|
|
//
|
|
// stable_attachment_urls asks bulk responses to return the stable
|
|
// /api/attachments/{id}/download path instead of a ~800-char CloudFront
|
|
// signature that is re-minted on every request (MUL-5372 / GitHub #5999). The
|
|
// CLI never hands an attachment URL to a native loader — `multica attachment
|
|
// download <id>` fetches a fresh signature from the single-attachment endpoint,
|
|
// which keeps signing regardless of this capability — so the signature in list
|
|
// payloads was pure cost: raw bytes, a per-attachment RSA sign, and bytes that
|
|
// differ on every read and therefore defeat agent prompt caching.
|
|
const clientCapabilities = "stable_attachment_urls"
|
|
|
|
func (c *APIClient) setHeaders(req *http.Request) {
|
|
req.Header.Set("X-Client-Capabilities", clientCapabilities)
|
|
if c.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
}
|
|
if c.WorkspaceID != "" {
|
|
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
|
}
|
|
if c.AgentID != "" {
|
|
req.Header.Set("X-Agent-ID", c.AgentID)
|
|
}
|
|
if c.TaskID != "" {
|
|
req.Header.Set("X-Task-ID", c.TaskID)
|
|
}
|
|
|
|
platform := c.Platform
|
|
if platform == "" {
|
|
platform = ClientPlatform
|
|
}
|
|
if platform != "" {
|
|
req.Header.Set("X-Client-Platform", platform)
|
|
}
|
|
version := c.Version
|
|
if version == "" {
|
|
version = ClientVersion
|
|
}
|
|
if version != "" {
|
|
req.Header.Set("X-Client-Version", version)
|
|
}
|
|
osName := c.OS
|
|
if osName == "" {
|
|
osName = ClientOS
|
|
}
|
|
if osName != "" {
|
|
req.Header.Set("X-Client-OS", osName)
|
|
}
|
|
}
|
|
|
|
// GetJSON performs a GET request and decodes the JSON response.
|
|
//
|
|
// On an HTTP error response (status >= 400) the returned error is a
|
|
// *HTTPError so callers can use errors.As to inspect the status code
|
|
// (for example to recognize a 404 from a server that does not expose a
|
|
// given endpoint and degrade gracefully). The error string format
|
|
// ("GET <path> returned <code>: <body>") is preserved by HTTPError.Error().
|
|
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return newHTTPError(http.MethodGet, path, resp)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
|
|
}
|
|
|
|
// GetJSONWithHeaders performs a GET request, decodes the JSON response, and
|
|
// returns the response headers. Useful when callers need header values like
|
|
// X-Total-Count for pagination.
|
|
func (c *APIClient) GetJSONWithHeaders(ctx context.Context, path string, out any) (http.Header, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, newHTTPError(http.MethodGet, path, resp)
|
|
}
|
|
if out != nil {
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return resp.Header, wrapBodyRead(req, err)
|
|
}
|
|
}
|
|
return resp.Header, nil
|
|
}
|
|
|
|
// DeleteJSON performs a DELETE request.
|
|
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
|
|
return c.DeleteJSONResponse(ctx, path, nil)
|
|
}
|
|
|
|
// DeleteJSONResponse performs a DELETE request and optionally decodes the JSON response.
|
|
func (c *APIClient) DeleteJSONResponse(ctx context.Context, path string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return newHTTPError(http.MethodDelete, path, resp)
|
|
}
|
|
if out != nil {
|
|
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteJSONWithBody performs a DELETE request with a JSON body.
|
|
func (c *APIClient) DeleteJSONWithBody(ctx context.Context, path string, body any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return newHTTPError(http.MethodDelete, path, resp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PostJSON performs a POST request with a JSON body.
|
|
func (c *APIClient) PostJSON(ctx context.Context, path string, body any, out any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return newHTTPError(http.MethodPost, path, resp)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
|
|
}
|
|
|
|
// PutJSON performs a PUT request with a JSON body.
|
|
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return newHTTPError(http.MethodPut, path, resp)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
|
|
}
|
|
|
|
// PatchJSON performs a PATCH request with a JSON body.
|
|
func (c *APIClient) PatchJSON(ctx context.Context, path string, body any, out any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return newHTTPError(http.MethodPatch, path, resp)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
|
|
}
|
|
|
|
// AttachmentResponse mirrors the server's upload-file response.
|
|
type AttachmentResponse struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
DownloadURL string `json:"download_url"`
|
|
// MarkdownURL is the durable, persistable URL to embed in markdown bodies
|
|
// (chat replies, comments). Unlike DownloadURL it never carries a TTL.
|
|
MarkdownURL string `json:"markdown_url"`
|
|
Filename string `json:"filename"`
|
|
ContentType string `json:"content_type"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// UploadFile uploads a file via multipart form to /api/upload-file.
|
|
// It returns the attachment ID from the server response.
|
|
func (c *APIClient) UploadFile(ctx context.Context, fileData []byte, filename string, issueID string) (string, error) {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
part, err := writer.CreateFormFile("file", filepath.Base(filename))
|
|
if err != nil {
|
|
return "", fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(fileData); err != nil {
|
|
return "", fmt.Errorf("write file data: %w", err)
|
|
}
|
|
|
|
if issueID != "" {
|
|
if err := writer.WriteField("issue_id", issueID); err != nil {
|
|
return "", fmt.Errorf("write issue_id field: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return "", fmt.Errorf("close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return "", newHTTPError(http.MethodPost, "/api/upload-file", resp)
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("decode upload response: %w", err)
|
|
}
|
|
|
|
id, _ := result["id"].(string)
|
|
if id == "" {
|
|
return "", fmt.Errorf("upload response missing attachment id")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// UploadChatAttachment uploads a file via multipart form to /api/upload-file
|
|
// tagged with a chat task (task_id). The server binds the row to the assistant
|
|
// reply that task produces on completion. Returns the full AttachmentResponse
|
|
// (id + markdown_url) so the agent can embed the image inline in its reply.
|
|
func (c *APIClient) UploadChatAttachment(ctx context.Context, fileData []byte, filename, taskID string) (AttachmentResponse, error) {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
part, err := writer.CreateFormFile("file", filepath.Base(filename))
|
|
if err != nil {
|
|
return AttachmentResponse{}, fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(fileData); err != nil {
|
|
return AttachmentResponse{}, fmt.Errorf("write file data: %w", err)
|
|
}
|
|
if taskID != "" {
|
|
if err := writer.WriteField("task_id", taskID); err != nil {
|
|
return AttachmentResponse{}, fmt.Errorf("write task_id field: %w", err)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return AttachmentResponse{}, fmt.Errorf("close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
|
|
if err != nil {
|
|
return AttachmentResponse{}, err
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
c.setHeaders(req)
|
|
|
|
// Honor a longer context deadline for large images, same as UploadFileWithURL.
|
|
httpClient := c.HTTPClient
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
remaining := time.Until(deadline)
|
|
if remaining > httpClient.Timeout {
|
|
clientCopy := *httpClient
|
|
clientCopy.Timeout = remaining
|
|
httpClient = &clientCopy
|
|
}
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return AttachmentResponse{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return AttachmentResponse{}, newHTTPError(http.MethodPost, "/api/upload-file", resp)
|
|
}
|
|
|
|
var result AttachmentResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return AttachmentResponse{}, fmt.Errorf("decode upload response: %w", err)
|
|
}
|
|
if result.ID == "" {
|
|
return AttachmentResponse{}, fmt.Errorf("upload response missing attachment id")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UploadFileWithURL uploads a file via multipart form to /api/upload-file
|
|
// without associating it with an issue or comment. It decodes the full
|
|
// AttachmentResponse and returns the attachment ID and URL.
|
|
func (c *APIClient) UploadFileWithURL(ctx context.Context, fileData []byte, filename string) (string, string, error) {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
part, err := writer.CreateFormFile("file", filepath.Base(filename))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(fileData); err != nil {
|
|
return "", "", fmt.Errorf("write file data: %w", err)
|
|
}
|
|
|
|
if err := writer.Close(); err != nil {
|
|
return "", "", fmt.Errorf("close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/upload-file", &body)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
c.setHeaders(req)
|
|
|
|
// Use a client that respects the context deadline for slow uploads
|
|
// (e.g. avatar uploads with 5MB files). The default HTTP client timeout
|
|
// shadows any longer context deadline.
|
|
httpClient := c.HTTPClient
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
remaining := time.Until(deadline)
|
|
if remaining > httpClient.Timeout {
|
|
clientCopy := *httpClient
|
|
clientCopy.Timeout = remaining
|
|
httpClient = &clientCopy
|
|
}
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return "", "", newHTTPError(http.MethodPost, "/api/upload-file", resp)
|
|
}
|
|
|
|
var result AttachmentResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", "", fmt.Errorf("decode upload response: %w", err)
|
|
}
|
|
if result.URL == "" {
|
|
return "", "", fmt.Errorf("upload response missing attachment url")
|
|
}
|
|
// Allow empty ID: the server returns id="" in the fallback path where
|
|
// S3 upload succeeded but the attachment DB record failed. The file
|
|
// is still usable via its URL.
|
|
return result.ID, result.URL, nil
|
|
}
|
|
|
|
// ImportSkillFile imports a skill from a local archive (.skill / .zip) by
|
|
// POSTing it as multipart/form-data to /api/skills/import, alongside the
|
|
// on_conflict strategy. The structured import result is decoded into out.
|
|
func (c *APIClient) ImportSkillFile(ctx context.Context, fileData []byte, filename, onConflict string, out any) error {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
part, err := writer.CreateFormFile("file", filepath.Base(filename))
|
|
if err != nil {
|
|
return fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(fileData); err != nil {
|
|
return fmt.Errorf("write file data: %w", err)
|
|
}
|
|
if onConflict != "" {
|
|
if err := writer.WriteField("on_conflict", onConflict); err != nil {
|
|
return fmt.Errorf("write on_conflict field: %w", err)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return fmt.Errorf("close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/skills/import", &body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
c.setHeaders(req)
|
|
|
|
// Respect a longer context deadline for slow uploads, mirroring
|
|
// UploadFileWithURL: the default client timeout would otherwise shadow it.
|
|
httpClient := c.HTTPClient
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
remaining := time.Until(deadline)
|
|
if remaining > httpClient.Timeout {
|
|
clientCopy := *httpClient
|
|
clientCopy.Timeout = remaining
|
|
httpClient = &clientCopy
|
|
}
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return newHTTPError(http.MethodPost, "/api/skills/import", resp)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
|
|
}
|
|
|
|
// UploadPrivatePlugin installs workspace-private Plugin archive bytes. The
|
|
// filename is transport metadata only; the Server derives source identity from
|
|
// validated manifest content and digests.
|
|
func (c *APIClient) UploadPrivatePlugin(ctx context.Context, path string, archive []byte, filename string, out any) error {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("artifact", filepath.Base(filename))
|
|
if err != nil {
|
|
return fmt.Errorf("create Plugin artifact form file: %w", err)
|
|
}
|
|
if _, err := part.Write(archive); err != nil {
|
|
return fmt.Errorf("write Plugin artifact: %w", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return fmt.Errorf("close Plugin artifact upload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, &body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
c.setHeaders(req)
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return newHTTPError(http.MethodPost, path, resp)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return wrapBodyRead(req, json.NewDecoder(resp.Body).Decode(out))
|
|
}
|
|
|
|
// DownloadFile downloads a file from the given URL and returns the response body.
|
|
// This is used for downloading attachments via their signed download_url.
|
|
// Downloads are limited to 100 MB to match the upload size limit.
|
|
//
|
|
// The URL may be absolute (a signed CloudFront/S3 URL) or relative
|
|
// (a server-relative path like "/api/attachments/{id}/download" or
|
|
// "/uploads/...") depending on how the
|
|
// server is configured. Relative URLs are resolved against the client's
|
|
// BaseURL and sent with the standard auth headers; absolute URLs are
|
|
// used as-is so that their query-string signatures are not disturbed.
|
|
func (c *APIClient) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
|
|
isRelative := !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://")
|
|
if isRelative {
|
|
if c.BaseURL == "" {
|
|
return nil, fmt.Errorf("download URL %q is relative but client has no BaseURL", downloadURL)
|
|
}
|
|
downloadURL = c.BaseURL + downloadURL
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if isRelative {
|
|
c.setHeaders(req)
|
|
}
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, newHTTPError(http.MethodGet, downloadURL, resp)
|
|
}
|
|
|
|
const maxDownloadSize = 100 << 20 // 100 MB
|
|
return io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize))
|
|
}
|
|
|
|
// HealthCheck hits the /health endpoint and returns the response body.
|
|
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := c.HTTPClient.Do(req)
|
|
err = wrapTransport(req, err)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode >= 400 {
|
|
return "", &HTTPError{
|
|
Method: http.MethodGet,
|
|
Path: "/health",
|
|
StatusCode: resp.StatusCode,
|
|
Body: strings.TrimSpace(string(data)),
|
|
TaskScoped: requestUsedTaskToken(resp),
|
|
}
|
|
}
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|