Add local JSON daemon and route TUI/CLI through it.
A per-DataDir background process owns ticket JSON, history, and sync so multiple TUI instances stay consistent; MCP token APIs are ready for phase 2. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/daemon/api"
|
||||
)
|
||||
|
||||
// Client talks to the local JSON daemon over a Unix socket.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
kind string // tui | cli | mcp
|
||||
token string // bearer for mcp
|
||||
project string // mcp bound project
|
||||
}
|
||||
|
||||
// Options configures a daemon client.
|
||||
type Options struct {
|
||||
Socket string
|
||||
Kind string
|
||||
Token string
|
||||
Project string
|
||||
}
|
||||
|
||||
// Dial creates a client for the given Unix socket.
|
||||
func Dial(opts Options) *Client {
|
||||
kind := opts.Kind
|
||||
if kind == "" {
|
||||
kind = api.ClientTUI
|
||||
}
|
||||
return &Client{
|
||||
kind: kind,
|
||||
token: opts.Token,
|
||||
project: opts.Project,
|
||||
http: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", opts.Socket)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, in any, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, "http://daemon"+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set(api.HeaderClient, c.kind)
|
||||
if c.kind == api.ClientMCP {
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if c.project != "" {
|
||||
req.Header.Set("X-TM-Project", c.project)
|
||||
}
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
var er struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &er)
|
||||
if er.Error == "" {
|
||||
er.Error = strings.TrimSpace(string(data))
|
||||
}
|
||||
if er.Error == "" {
|
||||
er.Error = resp.Status
|
||||
}
|
||||
return fmt.Errorf("%s", er.Error)
|
||||
}
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
// Health checks /v1/health.
|
||||
func (c *Client) Health() error {
|
||||
return c.do(http.MethodGet, "/v1/health", nil, nil)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/daemon/api"
|
||||
)
|
||||
|
||||
// SubscribeEvents streams SSE change events until ctx is cancelled.
|
||||
func (c *Client) SubscribeEvents(ctx context.Context, emit func(api.Event)) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://daemon/v1/events", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set(api.HeaderClient, c.kind)
|
||||
if c.kind == api.ClientMCP {
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if c.project != "" {
|
||||
req.Header.Set("X-TM-Project", c.project)
|
||||
}
|
||||
}
|
||||
// No overall timeout for long-lived SSE.
|
||||
httpClient := *c.http
|
||||
httpClient.Timeout = 0
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("events: %s", resp.Status)
|
||||
}
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
var ev api.Event
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
emit(ev)
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// Executor implements command.Executor via daemon mutation endpoints.
|
||||
type Executor struct{ c *Client }
|
||||
|
||||
func (c *Client) Executor() *Executor { return &Executor{c: c} }
|
||||
|
||||
func (e *Executor) Do(cmd command.Command) error {
|
||||
switch c := cmd.(type) {
|
||||
case *command.CreateTicket:
|
||||
var out ticket.Ticket
|
||||
if err := e.c.do(http.MethodPost, "/v1/tickets", c.Ticket, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Ticket = out
|
||||
return nil
|
||||
case *command.UpdateTicket:
|
||||
return e.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(c.After.ID), c.After, nil)
|
||||
case *command.DeleteTicket:
|
||||
return e.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(c.Snapshot.ID), nil, nil)
|
||||
case *command.MoveTicket:
|
||||
var out ticket.Ticket
|
||||
if err := e.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(c.Before.ID)+"/move", map[string]string{
|
||||
"toProjectId": c.After.ProjectID,
|
||||
}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
c.After = out
|
||||
return nil
|
||||
case *command.AddTimeEntry:
|
||||
var out ticket.TimeEntry
|
||||
if err := e.c.do(http.MethodPost, "/v1/time", c.Entry, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Entry = out
|
||||
return nil
|
||||
case *command.RemoveTimeEntry:
|
||||
return e.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(c.Snapshot.ID), nil, nil)
|
||||
case *command.UpdateTimeEntry:
|
||||
return e.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(c.After.ID), c.After, nil)
|
||||
default:
|
||||
return fmt.Errorf("unsupported command type %T", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) Undo() error {
|
||||
return e.c.do(http.MethodPost, "/v1/undo", map[string]any{}, nil)
|
||||
}
|
||||
|
||||
func (e *Executor) Redo() error {
|
||||
return e.c.do(http.MethodPost, "/v1/redo", map[string]any{}, nil)
|
||||
}
|
||||
|
||||
func (e *Executor) CanUndo() bool {
|
||||
st, err := e.status()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return st.CanUndo
|
||||
}
|
||||
|
||||
func (e *Executor) CanRedo() bool {
|
||||
st, err := e.status()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return st.CanRedo
|
||||
}
|
||||
|
||||
func (e *Executor) LastUndoName() string {
|
||||
st, _ := e.status()
|
||||
return st.LastUndoName
|
||||
}
|
||||
|
||||
func (e *Executor) LastRedoName() string {
|
||||
st, _ := e.status()
|
||||
return st.LastRedoName
|
||||
}
|
||||
|
||||
type historyStatus struct {
|
||||
CanUndo bool `json:"canUndo"`
|
||||
CanRedo bool `json:"canRedo"`
|
||||
LastUndoName string `json:"lastUndoName"`
|
||||
LastRedoName string `json:"lastRedoName"`
|
||||
}
|
||||
|
||||
func (e *Executor) status() (historyStatus, error) {
|
||||
var st historyStatus
|
||||
err := e.c.do(http.MethodGet, "/v1/history", nil, &st)
|
||||
return st, err
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// TicketStore implements ticket.Store against the daemon (reads + MarkSynced).
|
||||
// Mutations used by the TUI go through Executor; Create/Update/Delete/Move here
|
||||
// call the daemon mutation endpoints (with undo) for completeness.
|
||||
type TicketStore struct{ c *Client }
|
||||
|
||||
func (c *Client) TicketStore() *TicketStore { return &TicketStore{c: c} }
|
||||
|
||||
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
|
||||
var out []ticket.Ticket
|
||||
q := url.Values{"projectId": {projectID}}.Encode()
|
||||
err := s.c.do(http.MethodGet, "/v1/tickets?"+q, nil, &out)
|
||||
if out == nil {
|
||||
out = []ticket.Ticket{}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
|
||||
var out ticket.Ticket
|
||||
err := s.c.do(http.MethodGet, "/v1/tickets/"+url.PathEscape(id), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
|
||||
var out ticket.Ticket
|
||||
err := s.c.do(http.MethodPost, "/v1/tickets", t, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TicketStore) Update(t ticket.Ticket) error {
|
||||
return s.c.do(http.MethodPatch, "/v1/tickets/"+url.PathEscape(t.ID), t, nil)
|
||||
}
|
||||
|
||||
func (s *TicketStore) MarkSynced(id string) error {
|
||||
return fmt.Errorf("MarkSynced is daemon-internal (use sync endpoints)")
|
||||
}
|
||||
|
||||
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
|
||||
var out ticket.Ticket
|
||||
err := s.c.do(http.MethodPost, "/v1/tickets/"+url.PathEscape(id)+"/move", map[string]string{
|
||||
"toProjectId": toProjectID,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TicketStore) Delete(id string) error {
|
||||
return s.c.do(http.MethodDelete, "/v1/tickets/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
// TimeStore implements ticket.TimeStore.
|
||||
type TimeStore struct{ c *Client }
|
||||
|
||||
func (c *Client) TimeStore() *TimeStore { return &TimeStore{c: c} }
|
||||
|
||||
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
|
||||
var out []ticket.TimeEntry
|
||||
err := s.c.do(http.MethodGet, "/v1/time", nil, &out)
|
||||
if out == nil {
|
||||
out = []ticket.TimeEntry{}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
|
||||
var out []ticket.TimeEntry
|
||||
err := s.c.do(http.MethodGet, "/v1/time/by-ticket/"+url.PathEscape(ticketID), nil, &out)
|
||||
if out == nil {
|
||||
out = []ticket.TimeEntry{}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
|
||||
var out ticket.TimeEntry
|
||||
err := s.c.do(http.MethodPost, "/v1/time", e, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TimeStore) Update(e ticket.TimeEntry) error {
|
||||
return s.c.do(http.MethodPatch, "/v1/time/"+url.PathEscape(e.ID), e, nil)
|
||||
}
|
||||
|
||||
func (s *TimeStore) Delete(id string) error {
|
||||
return s.c.do(http.MethodDelete, "/v1/time/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
func (s *TimeStore) DeleteByProject(projectID string) error {
|
||||
// Composite delete is done server-side on project delete; not exposed alone.
|
||||
return fmt.Errorf("DeleteByProject is daemon-internal")
|
||||
}
|
||||
|
||||
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
|
||||
return fmt.Errorf("RetargetTicket is daemon-internal")
|
||||
}
|
||||
|
||||
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
|
||||
var out struct {
|
||||
Duration int64 `json:"duration"`
|
||||
}
|
||||
err := s.c.do(http.MethodGet, "/v1/time/sum/"+url.PathEscape(ticketID), nil, &out)
|
||||
return time.Duration(out.Duration), err
|
||||
}
|
||||
|
||||
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
|
||||
var out *ticket.ActiveTimer
|
||||
err := s.c.do(http.MethodGet, "/v1/timer", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
|
||||
if t == nil {
|
||||
return s.c.do(http.MethodPut, "/v1/timer", map[string]any{}, nil)
|
||||
}
|
||||
return s.c.do(http.MethodPut, "/v1/timer", t, nil)
|
||||
}
|
||||
|
||||
// ProjectStore implements project.Store.
|
||||
type ProjectStore struct{ c *Client }
|
||||
|
||||
func (c *Client) ProjectStore() *ProjectStore { return &ProjectStore{c: c} }
|
||||
|
||||
func (s *ProjectStore) List() ([]project.Project, error) {
|
||||
var out []project.Project
|
||||
err := s.c.do(http.MethodGet, "/v1/projects", nil, &out)
|
||||
if out == nil {
|
||||
out = []project.Project{}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *ProjectStore) Ensure(id string) error {
|
||||
return s.c.do(http.MethodPost, "/v1/projects", map[string]string{"id": id}, nil)
|
||||
}
|
||||
|
||||
func (s *ProjectStore) Delete(id string) error {
|
||||
return s.c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
|
||||
var out project.Meta
|
||||
err := s.c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(id)+"/meta", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
|
||||
return s.c.do(http.MethodPut, "/v1/projects/"+url.PathEscape(id)+"/meta", meta, nil)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
// SyncPull runs daemon sync pull for projectID.
|
||||
func (c *Client) SyncPull(projectID string) (ytsync.Result, error) {
|
||||
var out ytsync.Result
|
||||
err := c.do(http.MethodPost, "/v1/sync/pull", map[string]string{"projectId": projectID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// SyncPush runs daemon sync push for projectID.
|
||||
func (c *Client) SyncPush(projectID string) (ytsync.Result, error) {
|
||||
var out ytsync.Result
|
||||
err := c.do(http.MethodPost, "/v1/sync/push", map[string]string{"projectId": projectID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CreateMCPToken creates a new MCP token for projectID (plaintext once).
|
||||
func (c *Client) CreateMCPToken(projectID string) (token, createdAt string, err error) {
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
err = c.do(http.MethodPost, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", map[string]any{}, &out)
|
||||
return out.Token, out.CreatedAt, err
|
||||
}
|
||||
|
||||
// RevokeMCPToken removes the MCP token for projectID.
|
||||
func (c *Client) RevokeMCPToken(projectID string) error {
|
||||
return c.do(http.MethodDelete, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, nil)
|
||||
}
|
||||
|
||||
// MCPTokenStatus returns whether a token is configured.
|
||||
func (c *Client) MCPTokenStatus(projectID string) (configured bool, createdAt string, err error) {
|
||||
var out struct {
|
||||
Configured bool `json:"configured"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
err = c.do(http.MethodGet, "/v1/projects/"+url.PathEscape(projectID)+"/mcp-token", nil, &out)
|
||||
return out.Configured, out.CreatedAt, err
|
||||
}
|
||||
Reference in New Issue
Block a user