package daemon
import (
"context"
"encoding/json"
"net/http"
"net/url"
"time"
)
// The folder browser's routes, as robot-docs/workspaces.md describes them.
type FolderList struct {
Path string `json:"path"`
Home string `json:"home"`
Entries []FolderEntry `json:"entries"`
Truncated bool `json:"truncated"`
}
type FolderEntry struct {
Name string `json:"name"`
// VCS is "jj", "git", or empty when the folder is no repository root.
VCS string `json:"vcs"`
Modified int64 `json:"modified"`
Hidden bool `json:"hidden"`
}
// Repo is the repository a folder is in. Fields whose command failed are
// empty; Kind and Root are always there.
type Repo struct {
Bookmark *Bookmark `json:"bookmark"`
Changed *int `json:"changed"`
Touched *int64 `json:"touched"`
Kind string `json:"kind"`
Root string `json:"root"`
Branch string `json:"branch"`
Commit string `json:"commit"`
Change string `json:"change"`
}
type Bookmark struct {
Name string `json:"name"`
Ahead int `json:"ahead"`
}
type FolderPreview struct {
Path string `json:"path"`
Repo *Repo `json:"repo"`
Languages []LanguageShare `json:"languages"`
Tree []FolderNode `json:"tree"`
More int `json:"more"`
}
type LanguageShare struct {
Name string `json:"name"`
Color string `json:"color"`
Share float64 `json:"share"`
}
type FolderNode struct {
Name string `json:"name"`
Language string `json:"language"`
Children []FolderNode `json:"children"`
Changed int `json:"changed"`
More int `json:"more"`
Dir bool `json:"dir"`
}
func ListFolders(ctx context.Context, conn *Connection, path string) (FolderList, error) {
var result FolderList
err := executeRead(ctx, conn, operation{Name: "list folders", Method: http.MethodGet, Path: "/fs/list?path=" + url.QueryEscape(path), Policy: readRecovery}, func(data []byte) error {
fields, err := object(data)
if err != nil {
return err
}
if err := required(fields, "path", &result.Path); err != nil {
return err
}
if err := required(fields, "home", &result.Home); err != nil {
return err
}
if err := required(fields, "truncated", &result.Truncated); err != nil {
return err
}
var entries []json.RawMessage
if err := required(fields, "entries", &entries); err != nil {
return err
}
result.Entries = make([]FolderEntry, 0, len(entries))
for _, raw := range entries {
entry, err := object(raw)
if err != nil {
return err
}
var item FolderEntry
if err := required(entry, "name", &item.Name); err != nil {
return err
}
if err := required(entry, "modified", &item.Modified); err != nil {
return err
}
if err := required(entry, "hidden", &item.Hidden); err != nil {
return err
}
if err := nullable(entry, "vcs", &item.VCS); err != nil {
return err
}
result.Entries = append(result.Entries, item)
}
return nil
})
return result, err
}
func FolderRepo(ctx context.Context, conn *Connection, path string) (*Repo, error) {
var result *Repo
err := executeRead(ctx, conn, operation{Name: "read repository", Method: http.MethodGet, Path: "/fs/repo?path=" + url.QueryEscape(path), Policy: readRecovery}, func(data []byte) error {
fields, err := object(data)
if err != nil {
return err
}
result, err = decodeFolderRepo(fields)
return err
})
return result, err
}
func PreviewFolder(ctx context.Context, conn *Connection, path string) (FolderPreview, error) {
var result FolderPreview
err := executeRead(ctx, conn, operation{Name: "preview folder", Method: http.MethodGet, Path: "/fs/preview?path=" + url.QueryEscape(path), Policy: readRecovery}, func(data []byte) error {
fields, err := object(data)
if err != nil {
return err
}
if err := required(fields, "path", &result.Path); err != nil {
return err
}
if err := required(fields, "more", &result.More); err != nil {
return err
}
if result.More < 0 {
return fieldError("more")
}
result.Repo, err = decodeFolderRepo(fields)
if err != nil {
return err
}
var languages, nodes []json.RawMessage
if err := required(fields, "languages", &languages); err != nil {
return err
}
result.Languages = make([]LanguageShare, 0, len(languages))
for _, raw := range languages {
language, err := object(raw)
if err != nil {
return err
}
var share LanguageShare
if err := required(language, "name", &share.Name); err != nil {
return err
}
if err := nullable(language, "color", &share.Color); err != nil {
return err
}
if err := required(language, "share", &share.Share); err != nil {
return err
}
if share.Share < 0 || share.Share > 1 {
return fieldError("share")
}
result.Languages = append(result.Languages, share)
}
if err := required(fields, "tree", &nodes); err != nil {
return err
}
result.Tree = make([]FolderNode, 0, len(nodes))
for _, raw := range nodes {
node, err := decodeFolderNode(raw)
if err != nil {
return err
}
result.Tree = append(result.Tree, node)
}
return nil
})
return result, err
}
func decodeFolderRepo(fields map[string]json.RawMessage) (*Repo, error) {
var raw *json.RawMessage
if err := nullable(fields, "repo", &raw); err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
fields, err := object(*raw)
if err != nil {
return nil, err
}
var repo Repo
if err := required(fields, "kind", &repo.Kind); err != nil {
return nil, err
}
if err := required(fields, "root", &repo.Root); err != nil {
return nil, err
}
if err := nullable(fields, "changed", &repo.Changed); err != nil {
return nil, err
}
if err := nullable(fields, "touched", &repo.Touched); err != nil {
return nil, err
}
switch repo.Kind {
case "git":
if err := nullable(fields, "branch", &repo.Branch); err != nil {
return nil, err
}
if err := nullable(fields, "commit", &repo.Commit); err != nil {
return nil, err
}
case "jj":
if err := nullable(fields, "change", &repo.Change); err != nil {
return nil, err
}
var bookmark *json.RawMessage
if err := nullable(fields, "bookmark", &bookmark); err != nil {
return nil, err
}
if bookmark != nil {
fields, err := object(*bookmark)
if err != nil {
return nil, err
}
repo.Bookmark = &Bookmark{}
if err := required(fields, "name", &repo.Bookmark.Name); err != nil {
return nil, err
}
if err := required(fields, "ahead", &repo.Bookmark.Ahead); err != nil {
return nil, err
}
}
default:
return nil, fieldError("kind")
}
return &repo, nil
}
func decodeFolderNode(data []byte) (FolderNode, error) {
var node FolderNode
fields, err := object(data)
if err != nil {
return node, err
}
if err := required(fields, "name", &node.Name); err != nil {
return node, err
}
if err := required(fields, "dir", &node.Dir); err != nil {
return node, err
}
if err := required(fields, "changed", &node.Changed); err != nil {
return node, err
}
if !node.Dir {
if err := nullable(fields, "language", &node.Language); err != nil {
return node, err
}
}
// Only top-level directories include their immediate children and remainder.
if _, present := fields["children"]; present {
var children []json.RawMessage
if err := required(fields, "children", &children); err != nil {
return node, err
}
if err := required(fields, "more", &node.More); err != nil {
return node, err
}
for _, raw := range children {
child, err := decodeFolderNode(raw)
if err != nil {
return node, err
}
node.Children = append(node.Children, child)
}
}
return node, nil
}
// MoveSession moves an idle session to another folder.
func MoveSession(ctx context.Context, conn *Connection, id, workspace string) (Session, error) {
return mutateSession(ctx, conn, operation{Name: "move session", Method: http.MethodPost, Path: sessionPath(id, "/workspace"), Body: map[string]string{"workspace": workspace}, Policy: authRecovery}, http.StatusOK)
}
// HostStatus is what the daemon knows of a remote host over ssh: State is
// ready, warming, needs_auth, unreachable or unsupported; Detail says why
// when it is not ready. OS, Arch and Home come with a ready one; Step is
// staging while a warming probe copies albedo's kernel to the host.
type HostStatus struct {
Host string `json:"host"`
State string `json:"state"`
Detail string `json:"detail"`
Step string `json:"step"`
OS string `json:"os"`
Arch string `json:"arch"`
Home string `json:"home"`
ControlPath string `json:"control_path"`
}
// KnownHost is a host worth offering: one sessions worked on ("recent"), or
// a Host entry of the daemon's ssh config ("config"). State is the cached
// probe's, empty when there is none.
type KnownHost struct {
Host string `json:"host"`
Label string `json:"label"`
Source string `json:"source"`
State string `json:"state"`
}
// ListHosts is GET /hosts. It never probes.
func ListHosts(ctx context.Context, conn *Connection) ([]KnownHost, error) {
var result []KnownHost
err := executeRead(ctx, conn, operation{Name: "list hosts", Method: http.MethodGet, Path: "/hosts", Policy: readRecovery}, func(data []byte) error {
fields, err := object(data)
if err != nil {
return err
}
return required(fields, "hosts", &result)
})
return result, err
}
// GetHost is the cached probe of host, or warming while one runs.
func GetHost(ctx context.Context, conn *Connection, host string) (HostStatus, error) {
var status HostStatus
err := executeRead(ctx, conn, operation{Name: "read host", Method: http.MethodGet, Path: "/hosts/" + url.PathEscape(host), Policy: readRecovery}, func(data []byte) error {
return decodeHostStatus(data, &status)
})
return status, err
}
// WarmHost probes host now, past a cached answer, and answers how it went.
func WarmHost(ctx context.Context, conn *Connection, host string) (HostStatus, error) {
// the probe waits up to a minute
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
var status HostStatus
operation := operation{Name: "warm host", Method: http.MethodPost, Path: "/hosts/" + url.PathEscape(host) + "/warm", Body: map[string]any{}, Policy: authRecovery}
// requestBytes directly: executeMutation would cap the wait at 20 s
body, err := requestBytes(ctx, conn, operation, responseLimits{successStatus: http.StatusOK, bodyBytes: 64 * 1024, errorBytes: 64 * 1024})
if err != nil {
return status, err
}
if err := decodeHostStatus(body, &status); err != nil {
return status, invalidResponse(operation, "", err)
}
return status, nil
}
// WarmHost probes the session's host again, past a cached failure, so a
// turn after a sign-in finds it ready.
func (c *ChatClient) WarmHost(ctx context.Context, host string) (HostStatus, error) {
return WarmHost(ctx, c.conn, host)
}
// Host is the daemon's cached probe of host.
func (c *ChatClient) Host(ctx context.Context, host string) (HostStatus, error) {
return GetHost(ctx, c.conn, host)
}
// LocalDaemon says the daemon runs on this machine, so an ssh master opened
// here is one it can ride.
func (c *ChatClient) LocalDaemon() bool {
return c.conn.Local()
}
func decodeHostStatus(data []byte, status *HostStatus) error {
fields, err := object(data)
if err != nil {
return err
}
if err := required(fields, "host", &status.Host); err != nil {
return err
}
if err := required(fields, "state", &status.State); err != nil {
return err
}
return json.Unmarshal(data, status)
}