Files
kandev/apps/backend/internal/agent/runtime/agentctl/client_attachments.go
T
2026-08-04 22:11:13 +01:00

104 lines
3.3 KiB
Go

package client
import (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"strings"
"github.com/kandev/kandev/internal/task/models"
)
// MaterializedAttachment is the agentctl-side filename selected for a
// descriptor. The name is relative to the session attachment directory.
type MaterializedAttachment struct {
Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"`
}
// MaterializeAttachment streams one claimed attachment to the agentctl
// session. The request body is multipart and remains bounded by the backend's
// descriptor limit; the client never base64-encodes the file.
func (c *Client) MaterializeAttachment(
ctx context.Context,
sessionID, attachmentID, name, mimeType string,
sizeBytes int64,
content io.Reader,
) (*MaterializedAttachment, error) {
if strings.TrimSpace(sessionID) == "" || strings.TrimSpace(attachmentID) == "" || content == nil {
return nil, fmt.Errorf("attachment session, id, and content are required")
}
if sizeBytes < 0 || sizeBytes > models.MaxMessageAttachmentBytes {
return nil, fmt.Errorf("attachment size exceeds maximum")
}
pipeReader, pipeWriter := io.Pipe()
multipartWriter := multipart.NewWriter(pipeWriter)
go func() {
if err := writeMaterializedAttachmentMultipart(multipartWriter, sessionID, attachmentID, name, mimeType, sizeBytes, content); err != nil {
_ = pipeWriter.CloseWithError(err)
return
}
_ = pipeWriter.Close()
}()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/attachments/materialize", pipeReader)
if err != nil {
_ = pipeReader.Close()
return nil, fmt.Errorf("create attachment materialization request: %w", err)
}
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
resp, err := c.longRunningHTTPClient.Do(req)
if err != nil {
_ = pipeReader.Close()
return nil, fmt.Errorf("materialize attachment: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024))
return nil, fmt.Errorf("materialize attachment failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result MaterializedAttachment
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode materialized attachment: %w", err)
}
if strings.TrimSpace(result.Name) == "" || result.SizeBytes != sizeBytes {
return nil, fmt.Errorf("materialize attachment returned invalid metadata")
}
return &result, nil
}
func writeMaterializedAttachmentMultipart(
writer *multipart.Writer,
sessionID, attachmentID, name, mimeType string,
sizeBytes int64,
content io.Reader,
) error {
fields := [...]struct {
key string
value string
}{
{key: "session_id", value: sessionID},
{key: "attachment_id", value: attachmentID},
{key: "name", value: name},
{key: "mime_type", value: mimeType},
{key: "size_bytes", value: strconv.FormatInt(sizeBytes, 10)},
}
for _, field := range fields {
if err := writer.WriteField(field.key, field.value); err != nil {
return err
}
}
part, err := writer.CreateFormFile("file", name)
if err != nil {
return err
}
if _, err := io.Copy(part, content); err != nil {
return err
}
return writer.Close()
}