Files
paperclip-harness/tui/paperclip/client.go

374 lines
11 KiB
Go

// Package paperclip is a thin read/write client for the Paperclip control plane.
//
// It holds no state. Every value it returns came from Paperclip on this call.
// Nothing here caches, and nothing here writes to disk.
package paperclip
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Client talks to one Paperclip instance.
type Client struct {
BaseURL string
Company string
token string
http *http.Client
}
// New builds a client from the environment.
//
// The token is read from PAPERCLIP_BOARD_KEY (the person's key) and only falls
// back to PAPERCLIP_API_KEY for read-only use. The token is never logged.
func New() (*Client, error) {
base := os.Getenv("PAPERCLIP_API_URL")
if base == "" {
base = "http://127.0.0.1:3100"
}
company := os.Getenv("PAPERCLIP_COMPANY_ID")
if company == "" {
company = "d4921013-e5da-4d88-8016-57d5746e2d03"
}
token := os.Getenv("PAPERCLIP_BOARD_KEY")
if token == "" {
token = os.Getenv("PAPERCLIP_API_KEY")
}
if token == "" {
return nil, errors.New("no key in the environment: set PAPERCLIP_BOARD_KEY")
}
return &Client{
BaseURL: strings.TrimRight(base, "/"),
Company: company,
token: token,
http: &http.Client{Timeout: 20 * time.Second},
}, nil
}
// UsingUserKey reports whether the person's key is in use. Writes need it.
func (c *Client) UsingUserKey() bool { return os.Getenv("PAPERCLIP_BOARD_KEY") != "" }
type Issue struct {
ID string `json:"id"`
Description string `json:"description"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Status string `json:"status"`
Priority string `json:"priority"`
ProjectID string `json:"projectId"`
ParentID string `json:"parentId"`
AssigneeAgent string `json:"assigneeAgentId"`
AssigneeUser string `json:"assigneeUserId"`
UpdatedAt string `json:"updatedAt"`
// Workspace is where Paperclip put this task's own copy of the code. It is
// null when the instance does not make worktrees.
Workspace *struct {
CWD string `json:"cwd"`
Branch string `json:"branchName"`
BaseRef string `json:"baseRef"`
Strategy string `json:"strategyType"`
} `json:"currentExecutionWorkspace"`
}
// Run is one heartbeat run: when it started, how it ended, what it cost.
type Run struct {
ID string `json:"runId"`
Status string `json:"status"`
Source string `json:"invocationSource"`
AgentID string `json:"agentId"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt"`
ErrorCode string `json:"errorCode"`
}
// Activity is one structural event on a task.
type Activity struct {
ID string `json:"id"`
ActorType string `json:"actorType"`
Action string `json:"action"`
CreatedAt string `json:"createdAt"`
}
// Runs returns a task's run history, newest first.
func (c *Client) Runs(issueID string) ([]Run, error) {
var out []Run
err := c.do("GET", "/api/issues/"+issueID+"/runs", nil, &out)
return out, err
}
// Activity returns a task's structural events, newest first.
func (c *Client) Activity(issueID string) ([]Activity, error) {
var out []Activity
err := c.do("GET", "/api/issues/"+issueID+"/activity", nil, &out)
return out, err
}
// CreateTask creates a task, unassigned. Blockers are never accepted at
// creation: they must be a separate call, or the write is ignored.
type NewTask struct {
Title string
Description string
ProjectID string
}
func (c *Client) CreateTask(t NewTask) (Issue, error) {
if !c.UsingUserKey() {
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
}
var out Issue
body := map[string]any{
"title": t.Title,
"description": t.Description,
"projectId": t.ProjectID,
"status": "todo",
}
err := c.do("POST", "/api/companies/"+c.Company+"/issues", body, &out)
return out, err
}
// SetBlockers replaces a task's blocked-by list.
func (c *Client) SetBlockers(issueID string, blockerIDs []string) (Issue, error) {
if !c.UsingUserKey() {
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
}
var out Issue
err := c.do("PATCH", "/api/issues/"+issueID, map[string]any{"blockedByIssueIds": blockerIDs}, &out)
return out, err
}
// Assign gives a task to an agent. It is always a separate call from creation:
// an assigned task is claimed by that agent's run within seconds, and the run
// then owns the task's fields.
func (c *Client) Assign(issueID, agentID string) (Issue, error) {
if !c.UsingUserKey() {
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
}
var out Issue
err := c.do("PATCH", "/api/issues/"+issueID, map[string]any{"assigneeAgentId": agentID}, &out)
return out, err
}
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
URLKey string `json:"urlKey"`
Status string `json:"status"`
}
// Comment is one entry on a task's thread.
type Comment struct {
ID string `json:"id"`
Body string `json:"body"`
AuthorType string `json:"authorType"`
AuthorID string `json:"authorAgentId"`
AuthorUser string `json:"authorUserId"`
CreatedAt string `json:"createdAt"`
}
// BlockerInfo is what Paperclip says about one task's blockers.
//
// The list endpoint does not carry blockers, so the tree asks per task.
type BlockerInfo struct {
IssueID string
UnresolvedCount int
AllBlockersDone bool
DependencyReady bool
Diagnosis string
BlockingIdentities []string
}
type blockerResponse struct {
Diagnosis string `json:"diagnosis"`
Readiness struct {
AllBlockersDone bool `json:"allBlockersDone"`
DependencyReady bool `json:"isDependencyReady"`
UnresolvedBlockerCount int `json:"unresolvedBlockerCount"`
} `json:"readiness"`
Blockers []struct {
Identifier string `json:"identifier"`
Title string `json:"title"`
Status string `json:"status"`
} `json:"blockers"`
}
func (c *Client) do(method, path string, body any, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.BaseURL+path, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
var ue *url.Error
if errors.As(err, &ue) {
return fmt.Errorf("cannot reach Paperclip at %s: %w", c.BaseURL, ue.Err)
}
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, snippet(raw))
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s %s returned unreadable JSON: %w", method, path, err)
}
return nil
}
func snippet(b []byte) string {
s := strings.TrimSpace(string(b))
if len(s) > 300 {
s = s[:300] + "…"
}
return s
}
// Projects lists the company's projects.
func (c *Client) Projects() ([]Project, error) {
var out []Project
err := c.do("GET", "/api/companies/"+c.Company+"/projects", nil, &out)
return out, err
}
// ResolveProject accepts a project id or url key and returns the project.
func (c *Client) ResolveProject(ref string) (Project, error) {
if ref == "" {
return Project{}, errors.New("no project given: use -project <id or urlKey>")
}
ps, err := c.Projects()
if err != nil {
return Project{}, err
}
for _, p := range ps {
if p.ID == ref || p.URLKey == ref {
return p, nil
}
}
return Project{}, fmt.Errorf("no project matches %q", ref)
}
// Issues returns every task in the company. The caller filters by project,
// because the list endpoint takes no project filter.
func (c *Client) Issues() ([]Issue, error) {
var out []Issue
err := c.do("GET", "/api/companies/"+c.Company+"/issues", nil, &out)
return out, err
}
// Blockers asks Paperclip what blocks one task.
func (c *Client) Blockers(issueID string) (BlockerInfo, error) {
var raw blockerResponse
err := c.do("GET", "/api/issues/"+issueID+"/diagnostics/blockers", nil, &raw)
info := BlockerInfo{
IssueID: issueID,
UnresolvedCount: raw.Readiness.UnresolvedBlockerCount,
AllBlockersDone: raw.Readiness.AllBlockersDone,
DependencyReady: raw.Readiness.DependencyReady,
Diagnosis: raw.Diagnosis,
}
for _, b := range raw.Blockers {
info.BlockingIdentities = append(info.BlockingIdentities, b.Identifier+" "+b.Status)
}
return info, err
}
// Comments returns a task's thread, oldest first.
func (c *Client) Comments(issueID string) ([]Comment, error) {
var out []Comment
err := c.do("GET", "/api/issues/"+issueID+"/comments?order=asc", nil, &out)
return out, err
}
// PostComment writes a comment as the person, which requires the user key.
func (c *Client) PostComment(issueID, body string) (Comment, error) {
if !c.UsingUserKey() {
return Comment{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
}
var out Comment
err := c.do("POST", "/api/issues/"+issueID+"/comments", map[string]string{"body": body}, &out)
return out, err
}
// SetStatus changes one task's status as the person.
func (c *Client) SetStatus(issueID, status, comment string) (Issue, error) {
if !c.UsingUserKey() {
return Issue{}, errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY")
}
payload := map[string]any{"status": status}
if comment != "" {
payload["comment"] = comment
}
var out Issue
err := c.do("PATCH", "/api/issues/"+issueID, payload, &out)
return out, err
}
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
// Agents lists the company's agents, used only to put a name on a task.
func (c *Client) Agents() ([]Agent, error) {
var out []Agent
err := c.do("GET", "/api/companies/"+c.Company+"/agents", nil, &out)
return out, err
}
// Issue returns one task by id.
func (c *Client) Issue(issueID string) (Issue, error) {
var out Issue
err := c.do("GET", "/api/issues/"+issueID, nil, &out)
return out, err
}
// Cost is what one task has cost so far.
type Cost struct {
CostCents int `json:"costCents"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
RunCount int `json:"runCount"`
RuntimeMs int `json:"runtimeMs"`
}
// CostSummary returns the task's recorded spend.
func (c *Client) CostSummary(issueID string) (Cost, error) {
var out Cost
err := c.do("GET", "/api/issues/"+issueID+"/cost-summary", nil, &out)
return out, err
}
// Health asks the control plane whether it is answering at all.
func (c *Client) Health() error {
return c.do("GET", "/api/health", nil, nil)
}