package cli import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "time" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" ) // commandTimeout bounds a mutating daemon call. Spawns do real work (git // worktree add, tmux launch, hook install), so it is generous compared to the // status probe timeout. const commandTimeout = 2 * time.Minute // apiError is the subset of the daemon's JSON error envelope the CLI surfaces. // RequestID is surfaced so a failed command can be correlated with daemon logs. type apiError struct { Message string `json:"message"` Code string `json:"code"` RequestID string `json:"requestId"` } // String renders the envelope for the user: " () [request ]", // omitting whichever parts the daemon left empty. func (e apiError) String() string { msg := e.Message if e.Code != "" { msg = fmt.Sprintf("%s (%s)", msg, e.Code) } if e.RequestID != "" { msg = fmt.Sprintf("%s [request %s]", msg, e.RequestID) } return msg } // getJSON sends GET /api/v1/ to the running daemon and decodes a 2xx // response into out. A missing daemon or non-2xx API envelope is rendered the // same way as mutating calls. func (c *commandContext) getJSON(ctx context.Context, path string, out any) error { return c.doJSON(ctx, http.MethodGet, path, nil, out) } // postJSON sends body as JSON to POST /api/v1/ on the running daemon and // decodes a 2xx response into out (out may be nil). A non-2xx response becomes // an error built from the API error envelope. A missing run-file or a stale one // (dead PID) yields a clear "not running" message rather than a // connection-refused dump. func (c *commandContext) postJSON(ctx context.Context, path string, body, out any) error { return c.doJSON(ctx, http.MethodPost, path, body, out) } // patchJSON sends body as JSON to PATCH /api/v1/ on the running daemon // and decodes a 2xx response into out. func (c *commandContext) patchJSON(ctx context.Context, path string, body, out any) error { return c.doJSON(ctx, http.MethodPatch, path, body, out) } // putJSON sends body as JSON to PUT /api/v1/ on the running daemon and // decodes a 2xx response into out. func (c *commandContext) putJSON(ctx context.Context, path string, body, out any) error { return c.doJSON(ctx, http.MethodPut, path, body, out) } // deleteJSON sends DELETE /api/v1/ to the running daemon and decodes a // 2xx response into out. func (c *commandContext) deleteJSON(ctx context.Context, path string, out any) error { return c.doJSON(ctx, http.MethodDelete, path, nil, out) } func (c *commandContext) doJSON(ctx context.Context, method, path string, body, out any) error { return c.doJSONPath(ctx, method, "/api/v1/"+path, body, out) } func (c *commandContext) postLoopbackJSON(ctx context.Context, path string, body any) error { return c.doJSONPath(ctx, http.MethodPost, path, body, nil) } func (c *commandContext) doJSONPath(ctx context.Context, method, path string, body, out any) error { cfg, err := config.Load() if err != nil { return err } info, err := runfile.Read(cfg.RunFilePath) if err != nil { return err } if info == nil { return fmt.Errorf("AO daemon is not running — start it with `ao start`") } if !c.deps.ProcessAlive(info.PID) { return fmt.Errorf("AO daemon is not running (stale run-file at %s) — start it with `ao start`", cfg.RunFilePath) } var reader io.Reader = http.NoBody if body != nil { payload, err := json.Marshal(body) if err != nil { return err } reader = bytes.NewReader(payload) } url := fmt.Sprintf("http://%s:%d%s", config.LoopbackHost, info.Port, path) req, err := http.NewRequestWithContext(ctx, method, url, reader) // #nosec G704 -- daemon host is fixed loopback; path is an internal API route. if err != nil { return err } if body != nil { req.Header.Set("Content-Type", "application/json") } // Reuse the injected client's transport (keeps it stubbable in tests) but // give daemon API calls far more headroom than the 2s status-probe timeout. client := *c.deps.HTTPClient client.Timeout = commandTimeout resp, err := client.Do(req) // #nosec G704 -- request target is the fixed loopback daemon URL above. if err != nil { return fmt.Errorf("call daemon: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e apiError _ = json.NewDecoder(resp.Body).Decode(&e) if e.Message == "" { return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode) } return fmt.Errorf("%s", e.String()) } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { if errors.Is(err, io.EOF) { return nil } return fmt.Errorf("decode response: %w", err) } } return nil }