package daemon
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
)
type DeletionResult struct {
OK bool `json:"ok"`
Deleted int `json:"deleted"`
}
type PreviewItem struct {
Type string `json:"type"`
Preview string `json:"preview"`
}
type SessionPreview struct {
Items []PreviewItem `json:"items"`
Total int `json:"total"`
}
type CreateSessionRequest struct {
Workspace string `json:"workspace"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
}
type ForkRequest struct {
Checkpoint int `json:"checkpoint"`
}
type Session struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
LastAssistantAt *int64 `json:"last_assistant_at,omitempty"`
Workspace string `json:"workspace"`
Location *Location `json:"location,omitempty"`
Model string `json:"model"`
Effort string `json:"effort,omitempty"`
Protocol string `json:"protocol"`
Provider string `json:"provider"`
}
// Location is a session's workspace as the daemon parsed it. Host and User
// are nil for a local one; Label is how the host reads, with a user ssh
// would pick anyway left out.
type Location struct {
Host *string `json:"host"`
User *string `json:"user"`
Path string `json:"path"`
Label *string `json:"label"`
}
// SplitLocation reads a workspace as the daemon stores it: a plain path is
// local, and [user@]host:/abs/path is on that host. A remote path is always
// absolute and a host never holds a slash, so the first ":/" before any
// slash ends the host.
func SplitLocation(workspace string) (host, path string) {
if workspace == "" || workspace[0] == '/' || workspace[0] == '~' {
return "", workspace
}
i := strings.Index(workspace, ":/")
if i <= 0 || strings.Contains(workspace[:i], "/") {
return "", workspace
}
return workspace[:i], workspace[i+1:]
}
func CreateSession(ctx context.Context, conn *Connection, body CreateSessionRequest) (Session, error) {
handle, err := NewCreation(body)
if err != nil {
return Session{}, err
}
return CreateSessionOperation(ctx, conn, handle)
}
func CreateSessionOperation(ctx context.Context, conn *Connection, handle *OperationHandle) (Session, error) {
var session Session
err := executeReceipt(ctx, conn, handle, []int{http.StatusCreated}, func(body []byte, _ int) error {
fields, err := object(body)
if err != nil {
return err
}
var operationID string
if err := required(fields, "operationId", &operationID); err != nil {
return err
}
if operationID != handle.ID() {
return fieldError("operationId")
}
session, err = decodeSession(body)
return err
})
return session, err
}
func RenameSession(ctx context.Context, conn *Connection, id, name string) (Session, error) {
return mutateSession(ctx, conn, operation{Name: "rename session", Method: http.MethodPatch, Path: sessionPath(id, ""), Body: map[string]string{"name": name}, Policy: authRecovery}, http.StatusOK)
}
func ForkSession(ctx context.Context, conn *Connection, id string, body ForkRequest) (Session, error) {
return mutateSession(ctx, conn, operation{Name: "fork session", Method: http.MethodPost, Path: sessionPath(id, "/fork"), Body: body, Policy: authRecovery}, http.StatusCreated)
}
func DeleteSession(ctx context.Context, conn *Connection, id string, tree bool) (DeletionResult, error) {
path := sessionPath(id, "")
if tree {
path += "?tree=1"
}
var result DeletionResult
err := executeMutation(ctx, conn, operation{Name: "delete session", Method: http.MethodDelete, Path: path, Policy: authRecovery}, []int{200}, func(body []byte, _ int) error {
var err error
result, err = decodeDeletion(body)
return err
})
return result, err
}
func ListSessions(ctx context.Context, conn *Connection) ([]Session, error) {
var result []Session
err := executeRead(ctx, conn, operation{Name: "list sessions", Method: http.MethodGet, Path: "/sessions", Policy: readRecovery}, func(data []byte) error {
var rows []json.RawMessage
if err := json.Unmarshal(data, &rows); err != nil {
return err
}
if rows == nil {
return errors.New("expected a session array")
}
result = make([]Session, 0, len(rows))
for _, row := range rows {
session, err := decodeSession(row)
if err != nil {
return err
}
result = append(result, session)
}
return nil
})
return result, err
}
func GetSessionPreview(ctx context.Context, conn *Connection, session string, limit int) (SessionPreview, error) {
var result SessionPreview
err := executeRead(ctx, conn, operation{Name: "get session preview", Method: http.MethodGet, Path: sessionPath(session, fmt.Sprintf("/preview?limit=%d", limit)), Policy: readRecovery}, func(data []byte) error {
fields, err := object(data)
if err != nil {
return err
}
var rows []json.RawMessage
if err = required(fields, "items", &rows); err != nil {
return err
}
if err = required(fields, "total", &result.Total); err != nil {
return err
}
if result.Total < 0 {
return fieldError("total")
}
result.Items = make([]PreviewItem, 0, len(rows))
for _, row := range rows {
itemFields, err := object(row)
if err != nil {
return err
}
var item PreviewItem
if err = required(itemFields, "type", &item.Type); err != nil {
return err
}
if err = required(itemFields, "preview", &item.Preview); err != nil {
return err
}
result.Items = append(result.Items, item)
}
return nil
})
return result, err
}
func decodeSession(data []byte) (Session, error) {
var wire struct {
ID *string `json:"id"`
Title *string `json:"title"`
Workspace *string `json:"workspace"`
Location *Location `json:"location"`
Model *string `json:"model"`
Protocol *string `json:"protocol"`
Provider *string `json:"provider"`
Effort json.RawMessage `json:"effort"`
LastAssistantAt json.RawMessage `json:"last_assistant_at"`
}
if err := json.Unmarshal(data, &wire); err != nil {
return Session{}, err
}
for _, field := range []struct {
value *string
name string
}{{wire.ID, "id"}, {wire.Title, "title"}, {wire.Workspace, "workspace"}, {wire.Model, "model"}, {wire.Protocol, "protocol"}, {wire.Provider, "provider"}} {
if field.value == nil {
return Session{}, fieldError(field.name)
}
}
if wire.Effort == nil {
return Session{}, fieldError("effort")
}
if wire.LastAssistantAt == nil {
return Session{}, fieldError("last_assistant_at")
}
var effort *string
if !bytes.Equal(bytes.TrimSpace(wire.Effort), []byte("null")) {
if err := json.Unmarshal(wire.Effort, &effort); err != nil {
return Session{}, &responseFieldError{field: "effort", cause: err}
}
}
session := Session{ID: *wire.ID, Title: *wire.Title, Workspace: *wire.Workspace, Location: wire.Location, Model: *wire.Model, Protocol: *wire.Protocol, Provider: *wire.Provider}
if effort != nil {
session.Effort = *effort
}
if !bytes.Equal(bytes.TrimSpace(wire.LastAssistantAt), []byte("null")) {
if err := json.Unmarshal(wire.LastAssistantAt, &session.LastAssistantAt); err != nil {
return Session{}, &responseFieldError{field: "last_assistant_at", cause: err}
}
}
return session, nil
}
func mutateSession(ctx context.Context, conn *Connection, operation operation, status int) (Session, error) {
var session Session
err := executeMutation(ctx, conn, operation, []int{status}, func(body []byte, _ int) error {
var err error
session, err = decodeSession(body)
return err
})
return session, err
}
func decodeDeletion(data []byte) (DeletionResult, error) {
var wire struct {
OK *bool `json:"ok"`
Deleted *int `json:"deleted"`
}
if err := json.Unmarshal(data, &wire); err != nil {
return DeletionResult{}, err
}
if wire.OK == nil || !*wire.OK {
return DeletionResult{}, fieldError("ok")
}
if wire.Deleted == nil || *wire.Deleted < 1 {
return DeletionResult{}, fieldError("deleted")
}
return DeletionResult{OK: *wire.OK, Deleted: *wire.Deleted}, nil
}