mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
This PR introduces screen recording of the computer use agent using the virtual desktop. - Screen recording is triggered by a `wait_agent` tool call. Recording is stopped by a successful `wait_agent` tool call or when there hasn't been any desktop activity for 10 minutes. - Recordings are handled by the `portabledesktop` cli via the `record` command. The videos are sped up in periods of inactivity. - Recordings are saved to the database to the `chat_files` table. There's a hard limit of 100MB per recording. Larger recordings are dropped. - A successful `wait_agent` on a computer use subagent tool call returns a `recording_file_id`, later allowing the frontend to display the corresponding video.
108 lines
3.0 KiB
Go
108 lines
3.0 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"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/workspacesdk"
|
|
)
|
|
|
|
// stopAndStoreRecording stops the desktop recording, downloads the
|
|
// MP4, and stores it in chat_files. Only called when the subagent
|
|
// completed successfully. Returns the file ID on success, empty
|
|
// string 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,
|
|
) string {
|
|
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 ""
|
|
}
|
|
|
|
body, err := conn.StopDesktopRecording(ctx,
|
|
workspacesdk.StopDesktopRecordingRequest{RecordingID: recordingID})
|
|
if err != nil {
|
|
p.logger.Warn(ctx, "failed to stop desktop recording",
|
|
slog.Error(err))
|
|
return ""
|
|
}
|
|
type readResult struct {
|
|
data []byte
|
|
err error
|
|
}
|
|
ch := make(chan readResult, 1)
|
|
go func() {
|
|
data, err := io.ReadAll(io.LimitReader(body, workspacesdk.MaxRecordingSize+1))
|
|
ch <- readResult{data, err}
|
|
}()
|
|
|
|
var data []byte
|
|
select {
|
|
case res := <-ch:
|
|
body.Close()
|
|
data = res.data
|
|
if res.err != nil {
|
|
p.logger.Warn(ctx, "failed to read recording data", slog.Error(res.err))
|
|
return ""
|
|
}
|
|
case <-ctx.Done():
|
|
body.Close()
|
|
p.logger.Warn(ctx, "context canceled while reading recording data", slog.Error(ctx.Err()))
|
|
return ""
|
|
}
|
|
if len(data) > workspacesdk.MaxRecordingSize {
|
|
p.logger.Warn(ctx, "recording data exceeds maximum size, skipping store",
|
|
slog.F("size", len(data)),
|
|
slog.F("max_size", workspacesdk.MaxRecordingSize))
|
|
return ""
|
|
}
|
|
if len(data) == 0 {
|
|
p.logger.Warn(ctx, "recording data is empty, skipping store")
|
|
return ""
|
|
}
|
|
|
|
if !workspaceID.Valid {
|
|
p.logger.Warn(ctx, "chat has no workspace, cannot store recording")
|
|
return ""
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
|
|
//nolint:gocritic // AsChatd is required to insert chat files from the recording pipeline.
|
|
row, err := p.db.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: data,
|
|
})
|
|
if err != nil {
|
|
p.logger.Warn(ctx, "failed to store recording in database",
|
|
slog.Error(err))
|
|
return ""
|
|
}
|
|
return row.ID.String()
|
|
}
|