mirror of
https://github.com/coder/coder.git
synced 2026-06-04 21:48:22 +00:00
db8191277b
Fixes [CODAGT-195](https://linear.app/codercom/issue/CODAGT-195/agent-uploaded-recordings-are-missing-chat-file-links-entries).
244 lines
7.6 KiB
Go
244 lines
7.6 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
type recordingResult struct {
|
|
recordingFileID string
|
|
thumbnailFileID string
|
|
}
|
|
|
|
// stopAndStoreRecording stops the desktop recording, downloads the
|
|
// multipart response containing the MP4 and optional thumbnail, and
|
|
// stores them in chat_files. Only called when the subagent completed
|
|
// successfully. Returns file IDs on success, empty fields on any
|
|
// failure. All errors are logged but not propagated; recording is
|
|
// best-effort.
|
|
func (p *Server) stopAndStoreRecording(
|
|
ctx context.Context,
|
|
conn workspacesdk.AgentConn,
|
|
recordingID string,
|
|
ownerID uuid.UUID,
|
|
workspaceID uuid.NullUUID,
|
|
chatID uuid.UUID,
|
|
) recordingResult {
|
|
var result recordingResult
|
|
|
|
select {
|
|
case p.recordingSem <- struct{}{}:
|
|
defer func() { <-p.recordingSem }()
|
|
case <-ctx.Done():
|
|
p.logger.Warn(ctx, "context canceled waiting for recording semaphore", slog.Error(ctx.Err()))
|
|
return result
|
|
}
|
|
|
|
resp, err := conn.StopDesktopRecording(ctx,
|
|
workspacesdk.StopDesktopRecordingRequest{RecordingID: recordingID})
|
|
if err != nil {
|
|
p.logger.Warn(ctx, "failed to stop desktop recording",
|
|
slog.Error(err))
|
|
return result
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
_, params, err := mime.ParseMediaType(resp.ContentType)
|
|
if err != nil {
|
|
p.logger.Warn(ctx, "failed to parse content type from recording response",
|
|
slog.F("content_type", resp.ContentType),
|
|
slog.Error(err))
|
|
return result
|
|
}
|
|
boundary := params["boundary"]
|
|
if boundary == "" {
|
|
p.logger.Warn(ctx, "missing boundary in recording response content type",
|
|
slog.F("content_type", resp.ContentType))
|
|
return result
|
|
}
|
|
|
|
if !workspaceID.Valid {
|
|
p.logger.Warn(ctx, "chat has no workspace, cannot store recording")
|
|
return result
|
|
}
|
|
|
|
// The chatd actor is used here because the recording is stored on
|
|
// behalf of the chat system, not a specific user request.
|
|
//nolint:gocritic // AsChatd is required to read the workspace for org lookup.
|
|
ws, err := p.db.GetWorkspaceByID(dbauthz.AsChatd(ctx), workspaceID.UUID)
|
|
if err != nil {
|
|
p.logger.Warn(ctx, "failed to resolve workspace for recording",
|
|
slog.Error(err))
|
|
return result
|
|
}
|
|
|
|
mr := multipart.NewReader(resp.Body, boundary)
|
|
// Context cancellation is checked between parts. Within a
|
|
// part read, cancellation relies on Go's HTTP transport closing
|
|
// the underlying connection when the context is done, which
|
|
// interrupts the blocked io.ReadAll.
|
|
// First pass: parse all multipart parts into memory.
|
|
// The agent sends at most two parts: one video/mp4 and one
|
|
// optional image/jpeg thumbnail. Cap the number of parts to
|
|
// prevent a malicious or broken agent from forcing the server
|
|
// into an unbounded parsing loop.
|
|
const maxParts = 2
|
|
var videoData, thumbnailData []byte
|
|
for range maxParts {
|
|
if ctx.Err() != nil {
|
|
p.logger.Warn(ctx, "context canceled while reading recording parts", slog.Error(ctx.Err()))
|
|
break
|
|
}
|
|
|
|
part, err := mr.NextPart()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
p.logger.Warn(ctx, "error reading next multipart part", slog.Error(err))
|
|
break
|
|
}
|
|
|
|
contentType := part.Header.Get("Content-Type")
|
|
|
|
// Select the read limit based on content type so that
|
|
// thumbnails (image/jpeg) do not allocate up to
|
|
// MaxRecordingSize (100 MB) before the size check rejects
|
|
// them. Unknown types use a small default since they are
|
|
// discarded below.
|
|
maxSize := int64(1 << 20) // 1 MB default for unknown types
|
|
switch contentType {
|
|
case "video/mp4":
|
|
maxSize = int64(workspacesdk.MaxRecordingSize)
|
|
case "image/jpeg":
|
|
maxSize = int64(workspacesdk.MaxThumbnailSize)
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(part, maxSize+1))
|
|
if err != nil {
|
|
p.logger.Warn(ctx, "failed to read recording part data",
|
|
slog.F("content_type", contentType),
|
|
slog.Error(err))
|
|
continue
|
|
}
|
|
if int64(len(data)) > maxSize {
|
|
p.logger.Warn(ctx, "recording part exceeds maximum size, skipping",
|
|
slog.F("content_type", contentType),
|
|
slog.F("size", len(data)),
|
|
slog.F("max_size", maxSize))
|
|
continue
|
|
}
|
|
if len(data) == 0 {
|
|
p.logger.Warn(ctx, "recording part is empty, skipping",
|
|
slog.F("content_type", contentType))
|
|
continue
|
|
}
|
|
|
|
switch contentType {
|
|
case "video/mp4":
|
|
if videoData != nil {
|
|
p.logger.Warn(ctx, "duplicate video/mp4 part in recording response, skipping")
|
|
continue
|
|
}
|
|
videoData = data
|
|
case "image/jpeg":
|
|
if thumbnailData != nil {
|
|
p.logger.Warn(ctx, "duplicate image/jpeg part in recording response, skipping")
|
|
continue
|
|
}
|
|
thumbnailData = data
|
|
default:
|
|
p.logger.Debug(ctx, "skipping unknown part content type",
|
|
slog.F("content_type", contentType))
|
|
}
|
|
}
|
|
|
|
// Second pass: store the collected data in the database and
|
|
// link it to the parent chat atomically. Insert + link must
|
|
// happen in the same transaction so that we never end up with
|
|
// chat_files rows that lack chat_file_links entries (which the
|
|
// purge job would treat as orphans and which users cannot view).
|
|
// Errors inside the transaction cause both inserts to be rolled
|
|
// back, so recording remains best-effort end-to-end.
|
|
txResult := struct {
|
|
recordingFileID string
|
|
thumbnailFileID string
|
|
}{}
|
|
//nolint:gocritic // AsChatd is required to insert and link chat files from the recording pipeline.
|
|
txErr := p.db.InTx(func(tx database.Store) error {
|
|
var fileIDs []uuid.UUID
|
|
if videoData != nil {
|
|
row, err := tx.InsertChatFile(dbauthz.AsChatd(ctx), database.InsertChatFileParams{
|
|
OwnerID: ownerID,
|
|
OrganizationID: ws.OrganizationID,
|
|
Name: fmt.Sprintf("recording-%s.mp4", p.clock.Now().UTC().Format("2006-01-02T15-04-05Z")),
|
|
Mimetype: "video/mp4",
|
|
Data: videoData,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert recording: %w", err)
|
|
}
|
|
txResult.recordingFileID = row.ID.String()
|
|
fileIDs = append(fileIDs, row.ID)
|
|
}
|
|
if thumbnailData != nil && txResult.recordingFileID != "" {
|
|
row, err := tx.InsertChatFile(dbauthz.AsChatd(ctx), database.InsertChatFileParams{
|
|
OwnerID: ownerID,
|
|
OrganizationID: ws.OrganizationID,
|
|
Name: fmt.Sprintf("thumbnail-%s.jpg", p.clock.Now().UTC().Format("2006-01-02T15-04-05Z")),
|
|
Mimetype: "image/jpeg",
|
|
Data: thumbnailData,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert thumbnail: %w", err)
|
|
}
|
|
txResult.thumbnailFileID = row.ID.String()
|
|
fileIDs = append(fileIDs, row.ID)
|
|
}
|
|
if len(fileIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
rejected, err := tx.LinkChatFiles(dbauthz.AsChatd(ctx), database.LinkChatFilesParams{
|
|
ChatID: chatID,
|
|
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
|
|
FileIds: fileIDs,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("link recording files: %w", err)
|
|
}
|
|
if rejected > 0 {
|
|
// The cap would be exceeded. Rolling back ensures the
|
|
// files are not persisted as orphans. MaxChatFileIDs is
|
|
// 20 today; hitting the cap with a 1-2 file batch means
|
|
// the chat is already saturated, and silently dropping
|
|
// the recording is preferable to leaving an unreachable
|
|
// blob.
|
|
return xerrors.Errorf("chat file link cap exceeded: %d file(s) rejected", rejected)
|
|
}
|
|
return nil
|
|
}, nil)
|
|
if txErr != nil {
|
|
p.logger.Warn(ctx, "failed to store and link recording",
|
|
slog.F("chat_id", chatID),
|
|
slog.Error(txErr))
|
|
return result
|
|
}
|
|
result.recordingFileID = txResult.recordingFileID
|
|
result.thumbnailFileID = txResult.thumbnailFileID
|
|
return result
|
|
}
|