From 3b6427f324f86ced1d5f7948e13d5e76f6963cdb Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 19:58:41 +0000 Subject: [PATCH] feat(agentic): implement core-agentic API client Add pkg/agentic for AI-assisted task management: - API client for core-agentic service - Task listing, claiming, updating, completion - Config from .env or ~/.core/agentic.yaml CLI commands: - core dev tasks - list available tasks - core dev task - show/claim task - core dev task --auto - AI picks highest priority - core dev task:update - update progress - core dev task:complete - mark complete Co-Authored-By: Claude Opus 4.5 --- cmd/core/cmd/agentic.go | 442 +++++++++++++++++++++++++++++++++++++ cmd/core/cmd/root.go | 1 + go.work | 1 + pkg/agentic/client.go | 328 +++++++++++++++++++++++++++ pkg/agentic/client_test.go | 356 +++++++++++++++++++++++++++++ pkg/agentic/config.go | 199 +++++++++++++++++ pkg/agentic/config_test.go | 185 ++++++++++++++++ pkg/agentic/go.mod | 14 ++ pkg/agentic/types.go | 140 ++++++++++++ 9 files changed, 1666 insertions(+) create mode 100644 cmd/core/cmd/agentic.go create mode 100644 pkg/agentic/client.go create mode 100644 pkg/agentic/client_test.go create mode 100644 pkg/agentic/config.go create mode 100644 pkg/agentic/config_test.go create mode 100644 pkg/agentic/go.mod create mode 100644 pkg/agentic/types.go diff --git a/cmd/core/cmd/agentic.go b/cmd/core/cmd/agentic.go new file mode 100644 index 00000000..852427d7 --- /dev/null +++ b/cmd/core/cmd/agentic.go @@ -0,0 +1,442 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/pkg/agentic" + "github.com/leaanthony/clir" +) + +var ( + taskIDStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#3b82f6")) // blue-500 + + taskTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e2e8f0")) // gray-200 + + taskPriorityHighStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ef4444")) // red-500 + + taskPriorityMediumStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f59e0b")) // amber-500 + + taskPriorityLowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + taskStatusPendingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 + + taskStatusInProgressStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3b82f6")) // blue-500 + + taskStatusCompletedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + taskStatusBlockedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ef4444")) // red-500 + + taskLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a78bfa")) // violet-400 +) + +// AddAgenticCommands adds the agentic task management commands to the dev command. +func AddAgenticCommands(parent *clir.Command) { + // core dev tasks - list available tasks + addTasksCommand(parent) + + // core dev task - show task details and claim + addTaskCommand(parent) + + // core dev task:update - update task + addTaskUpdateCommand(parent) + + // core dev task:complete - mark task complete + addTaskCompleteCommand(parent) +} + +func addTasksCommand(parent *clir.Command) { + var status string + var priority string + var labels string + var limit int + var project string + + cmd := parent.NewSubCommand("tasks", "List available tasks from core-agentic") + cmd.LongDescription("Lists tasks from the core-agentic service.\n\n" + + "Configuration is loaded from:\n" + + " 1. Environment variables (AGENTIC_TOKEN, AGENTIC_BASE_URL)\n" + + " 2. .env file in current directory\n" + + " 3. ~/.core/agentic.yaml\n\n" + + "Examples:\n" + + " core dev tasks\n" + + " core dev tasks --status pending --priority high\n" + + " core dev tasks --labels bug,urgent") + + cmd.StringFlag("status", "Filter by status (pending, in_progress, completed, blocked)", &status) + cmd.StringFlag("priority", "Filter by priority (critical, high, medium, low)", &priority) + cmd.StringFlag("labels", "Filter by labels (comma-separated)", &labels) + cmd.IntFlag("limit", "Max number of tasks to return (default 20)", &limit) + cmd.StringFlag("project", "Filter by project", &project) + + cmd.Action(func() error { + if limit == 0 { + limit = 20 + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + opts := agentic.ListOptions{ + Limit: limit, + Project: project, + } + + if status != "" { + opts.Status = agentic.TaskStatus(status) + } + if priority != "" { + opts.Priority = agentic.TaskPriority(priority) + } + if labels != "" { + opts.Labels = strings.Split(labels, ",") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tasks, err := client.ListTasks(ctx, opts) + if err != nil { + return fmt.Errorf("failed to list tasks: %w", err) + } + + if len(tasks) == 0 { + fmt.Println("No tasks found.") + return nil + } + + printTaskList(tasks) + return nil + }) +} + +func addTaskCommand(parent *clir.Command) { + var autoSelect bool + var claim bool + + cmd := parent.NewSubCommand("task", "Show task details or auto-select a task") + cmd.LongDescription("Shows details of a specific task or auto-selects the highest priority task.\n\n" + + "Examples:\n" + + " core dev task abc123 # Show task details\n" + + " core dev task abc123 --claim # Show and claim the task\n" + + " core dev task --auto # Auto-select highest priority pending task") + + cmd.BoolFlag("auto", "Auto-select highest priority pending task", &autoSelect) + cmd.BoolFlag("claim", "Claim the task after showing details", &claim) + + cmd.Action(func() error { + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var task *agentic.Task + + // Get the task ID from remaining args + args := os.Args + var taskID string + + // Find the task ID in args (after "task" subcommand) + for i, arg := range args { + if arg == "task" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if autoSelect { + // Auto-select: find highest priority pending task + tasks, err := client.ListTasks(ctx, agentic.ListOptions{ + Status: agentic.StatusPending, + Limit: 50, + }) + if err != nil { + return fmt.Errorf("failed to list tasks: %w", err) + } + + if len(tasks) == 0 { + fmt.Println("No pending tasks available.") + return nil + } + + // Sort by priority (critical > high > medium > low) + priorityOrder := map[agentic.TaskPriority]int{ + agentic.PriorityCritical: 0, + agentic.PriorityHigh: 1, + agentic.PriorityMedium: 2, + agentic.PriorityLow: 3, + } + + sort.Slice(tasks, func(i, j int) bool { + return priorityOrder[tasks[i].Priority] < priorityOrder[tasks[j].Priority] + }) + + task = &tasks[0] + claim = true // Auto-select implies claiming + } else { + if taskID == "" { + return fmt.Errorf("task ID required (or use --auto)") + } + + task, err = client.GetTask(ctx, taskID) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + } + + printTaskDetails(task) + + if claim && task.Status == agentic.StatusPending { + fmt.Println() + fmt.Printf("%s Claiming task...\n", dimStyle.Render(">>")) + + claimedTask, err := client.ClaimTask(ctx, task.ID) + if err != nil { + return fmt.Errorf("failed to claim task: %w", err) + } + + fmt.Printf("%s Task claimed successfully!\n", successStyle.Render(">>")) + fmt.Printf(" Status: %s\n", formatTaskStatus(claimedTask.Status)) + } + + return nil + }) +} + +func addTaskUpdateCommand(parent *clir.Command) { + var status string + var progress int + var notes string + + cmd := parent.NewSubCommand("task:update", "Update task status or progress") + cmd.LongDescription("Updates a task's status, progress, or adds notes.\n\n" + + "Examples:\n" + + " core dev task:update abc123 --status in_progress\n" + + " core dev task:update abc123 --progress 50 --notes 'Halfway done'") + + cmd.StringFlag("status", "New status (pending, in_progress, completed, blocked)", &status) + cmd.IntFlag("progress", "Progress percentage (0-100)", &progress) + cmd.StringFlag("notes", "Notes about the update", ¬es) + + cmd.Action(func() error { + // Find task ID from args + args := os.Args + var taskID string + for i, arg := range args { + if arg == "task:update" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if taskID == "" { + return fmt.Errorf("task ID required") + } + + if status == "" && progress == 0 && notes == "" { + return fmt.Errorf("at least one of --status, --progress, or --notes required") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + update := agentic.TaskUpdate{ + Progress: progress, + Notes: notes, + } + if status != "" { + update.Status = agentic.TaskStatus(status) + } + + if err := client.UpdateTask(ctx, taskID, update); err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + + fmt.Printf("%s Task %s updated successfully\n", successStyle.Render(">>"), taskID) + return nil + }) +} + +func addTaskCompleteCommand(parent *clir.Command) { + var output string + var failed bool + var errorMsg string + + cmd := parent.NewSubCommand("task:complete", "Mark a task as completed") + cmd.LongDescription("Marks a task as completed with optional output and artifacts.\n\n" + + "Examples:\n" + + " core dev task:complete abc123 --output 'Feature implemented'\n" + + " core dev task:complete abc123 --failed --error 'Build failed'") + + cmd.StringFlag("output", "Summary of the completed work", &output) + cmd.BoolFlag("failed", "Mark the task as failed", &failed) + cmd.StringFlag("error", "Error message if failed", &errorMsg) + + cmd.Action(func() error { + // Find task ID from args + args := os.Args + var taskID string + for i, arg := range args { + if arg == "task:complete" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + taskID = args[i+1] + break + } + } + + if taskID == "" { + return fmt.Errorf("task ID required") + } + + cfg, err := agentic.LoadConfig("") + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + client := agentic.NewClientFromConfig(cfg) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := agentic.TaskResult{ + Success: !failed, + Output: output, + ErrorMessage: errorMsg, + } + + if err := client.CompleteTask(ctx, taskID, result); err != nil { + return fmt.Errorf("failed to complete task: %w", err) + } + + if failed { + fmt.Printf("%s Task %s marked as failed\n", errorStyle.Render(">>"), taskID) + } else { + fmt.Printf("%s Task %s completed successfully\n", successStyle.Render(">>"), taskID) + } + return nil + }) +} + +func printTaskList(tasks []agentic.Task) { + fmt.Printf("\n%d task(s) found:\n\n", len(tasks)) + + for _, task := range tasks { + id := taskIDStyle.Render(task.ID) + title := taskTitleStyle.Render(truncate(task.Title, 50)) + priority := formatTaskPriority(task.Priority) + status := formatTaskStatus(task.Status) + + line := fmt.Sprintf(" %s %s %s %s", id, priority, status, title) + + if len(task.Labels) > 0 { + labels := taskLabelStyle.Render("[" + strings.Join(task.Labels, ", ") + "]") + line += " " + labels + } + + fmt.Println(line) + } + + fmt.Println() + fmt.Printf("%s\n", dimStyle.Render("Use 'core dev task ' to view details")) +} + +func printTaskDetails(task *agentic.Task) { + fmt.Println() + fmt.Printf("%s %s\n", dimStyle.Render("ID:"), taskIDStyle.Render(task.ID)) + fmt.Printf("%s %s\n", dimStyle.Render("Title:"), taskTitleStyle.Render(task.Title)) + fmt.Printf("%s %s\n", dimStyle.Render("Priority:"), formatTaskPriority(task.Priority)) + fmt.Printf("%s %s\n", dimStyle.Render("Status:"), formatTaskStatus(task.Status)) + + if task.Project != "" { + fmt.Printf("%s %s\n", dimStyle.Render("Project:"), task.Project) + } + + if len(task.Labels) > 0 { + fmt.Printf("%s %s\n", dimStyle.Render("Labels:"), taskLabelStyle.Render(strings.Join(task.Labels, ", "))) + } + + if task.ClaimedBy != "" { + fmt.Printf("%s %s\n", dimStyle.Render("Claimed by:"), task.ClaimedBy) + } + + fmt.Printf("%s %s\n", dimStyle.Render("Created:"), formatAge(task.CreatedAt)) + + fmt.Println() + fmt.Printf("%s\n", dimStyle.Render("Description:")) + fmt.Println(task.Description) + + if len(task.Files) > 0 { + fmt.Println() + fmt.Printf("%s\n", dimStyle.Render("Related files:")) + for _, f := range task.Files { + fmt.Printf(" - %s\n", f) + } + } + + if len(task.Dependencies) > 0 { + fmt.Println() + fmt.Printf("%s %s\n", dimStyle.Render("Blocked by:"), strings.Join(task.Dependencies, ", ")) + } +} + +func formatTaskPriority(p agentic.TaskPriority) string { + switch p { + case agentic.PriorityCritical: + return taskPriorityHighStyle.Render("[CRITICAL]") + case agentic.PriorityHigh: + return taskPriorityHighStyle.Render("[HIGH]") + case agentic.PriorityMedium: + return taskPriorityMediumStyle.Render("[MEDIUM]") + case agentic.PriorityLow: + return taskPriorityLowStyle.Render("[LOW]") + default: + return dimStyle.Render("[" + string(p) + "]") + } +} + +func formatTaskStatus(s agentic.TaskStatus) string { + switch s { + case agentic.StatusPending: + return taskStatusPendingStyle.Render("pending") + case agentic.StatusInProgress: + return taskStatusInProgressStyle.Render("in_progress") + case agentic.StatusCompleted: + return taskStatusCompletedStyle.Render("completed") + case agentic.StatusBlocked: + return taskStatusBlockedStyle.Render("blocked") + default: + return dimStyle.Render(string(s)) + } +} diff --git a/cmd/core/cmd/root.go b/cmd/core/cmd/root.go index 83477cde..ce6d1a1c 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -68,6 +68,7 @@ func Execute() error { devCmd := app.NewSubCommand("dev", "Development tools for Core Framework") AddAPICommands(devCmd) AddSyncCommand(devCmd) + AddAgenticCommands(devCmd) AddBuildCommand(app) AddTviewCommand(app) AddWorkCommand(app) diff --git a/go.work b/go.work index 0e39f88d..bf4af505 100644 --- a/go.work +++ b/go.work @@ -7,6 +7,7 @@ use ( ./cmd/core-mcp ./cmd/examples/core-static-di ./cmd/lthn-desktop + ./pkg/agentic ./pkg/build ./pkg/cache ./pkg/config diff --git a/pkg/agentic/client.go b/pkg/agentic/client.go new file mode 100644 index 00000000..4eff2367 --- /dev/null +++ b/pkg/agentic/client.go @@ -0,0 +1,328 @@ +package agentic + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/host-uk/core/pkg/core" +) + +// Client is the API client for the core-agentic service. +type Client struct { + // BaseURL is the base URL of the API server. + BaseURL string + // Token is the authentication token. + Token string + // HTTPClient is the HTTP client used for requests. + HTTPClient *http.Client + // AgentID is the identifier for this agent when claiming tasks. + AgentID string +} + +// NewClient creates a new agentic API client with the given base URL and token. +func NewClient(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimSuffix(baseURL, "/"), + Token: token, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// NewClientFromConfig creates a new client from a Config struct. +func NewClientFromConfig(cfg *Config) *Client { + client := NewClient(cfg.BaseURL, cfg.Token) + client.AgentID = cfg.AgentID + return client +} + +// ListTasks retrieves a list of tasks matching the given options. +func (c *Client) ListTasks(ctx context.Context, opts ListOptions) ([]Task, error) { + const op = "agentic.Client.ListTasks" + + // Build query parameters + params := url.Values{} + if opts.Status != "" { + params.Set("status", string(opts.Status)) + } + if opts.Priority != "" { + params.Set("priority", string(opts.Priority)) + } + if opts.Project != "" { + params.Set("project", opts.Project) + } + if opts.ClaimedBy != "" { + params.Set("claimed_by", opts.ClaimedBy) + } + if opts.Limit > 0 { + params.Set("limit", strconv.Itoa(opts.Limit)) + } + if len(opts.Labels) > 0 { + params.Set("labels", strings.Join(opts.Labels, ",")) + } + + endpoint := c.BaseURL + "/api/tasks" + if len(params) > 0 { + endpoint += "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, core.E(op, "failed to create request", err) + } + + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, core.E(op, "request failed", err) + } + defer resp.Body.Close() + + if err := c.checkResponse(resp); err != nil { + return nil, core.E(op, "API error", err) + } + + var tasks []Task + if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil { + return nil, core.E(op, "failed to decode response", err) + } + + return tasks, nil +} + +// GetTask retrieves a single task by its ID. +func (c *Client) GetTask(ctx context.Context, id string) (*Task, error) { + const op = "agentic.Client.GetTask" + + if id == "" { + return nil, core.E(op, "task ID is required", nil) + } + + endpoint := fmt.Sprintf("%s/api/tasks/%s", c.BaseURL, url.PathEscape(id)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, core.E(op, "failed to create request", err) + } + + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, core.E(op, "request failed", err) + } + defer resp.Body.Close() + + if err := c.checkResponse(resp); err != nil { + return nil, core.E(op, "API error", err) + } + + var task Task + if err := json.NewDecoder(resp.Body).Decode(&task); err != nil { + return nil, core.E(op, "failed to decode response", err) + } + + return &task, nil +} + +// ClaimTask claims a task for the current agent. +func (c *Client) ClaimTask(ctx context.Context, id string) (*Task, error) { + const op = "agentic.Client.ClaimTask" + + if id == "" { + return nil, core.E(op, "task ID is required", nil) + } + + endpoint := fmt.Sprintf("%s/api/tasks/%s/claim", c.BaseURL, url.PathEscape(id)) + + // Include agent ID in the claim request if available + var body io.Reader + if c.AgentID != "" { + data, _ := json.Marshal(map[string]string{"agent_id": c.AgentID}) + body = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body) + if err != nil { + return nil, core.E(op, "failed to create request", err) + } + + c.setHeaders(req) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, core.E(op, "request failed", err) + } + defer resp.Body.Close() + + if err := c.checkResponse(resp); err != nil { + return nil, core.E(op, "API error", err) + } + + // Read body once to allow multiple decode attempts + bodyData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, core.E(op, "failed to read response", err) + } + + // Try decoding as ClaimResponse first + var result ClaimResponse + if err := json.Unmarshal(bodyData, &result); err == nil && result.Task != nil { + return result.Task, nil + } + + // Try decoding as just a Task for simpler API responses + var task Task + if err := json.Unmarshal(bodyData, &task); err != nil { + return nil, core.E(op, "failed to decode response", err) + } + + return &task, nil +} + +// UpdateTask updates a task with new status, progress, or notes. +func (c *Client) UpdateTask(ctx context.Context, id string, update TaskUpdate) error { + const op = "agentic.Client.UpdateTask" + + if id == "" { + return core.E(op, "task ID is required", nil) + } + + endpoint := fmt.Sprintf("%s/api/tasks/%s", c.BaseURL, url.PathEscape(id)) + + data, err := json.Marshal(update) + if err != nil { + return core.E(op, "failed to marshal update", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data)) + if err != nil { + return core.E(op, "failed to create request", err) + } + + c.setHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return core.E(op, "request failed", err) + } + defer resp.Body.Close() + + if err := c.checkResponse(resp); err != nil { + return core.E(op, "API error", err) + } + + return nil +} + +// CompleteTask marks a task as completed with the given result. +func (c *Client) CompleteTask(ctx context.Context, id string, result TaskResult) error { + const op = "agentic.Client.CompleteTask" + + if id == "" { + return core.E(op, "task ID is required", nil) + } + + endpoint := fmt.Sprintf("%s/api/tasks/%s/complete", c.BaseURL, url.PathEscape(id)) + + data, err := json.Marshal(result) + if err != nil { + return core.E(op, "failed to marshal result", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return core.E(op, "failed to create request", err) + } + + c.setHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return core.E(op, "request failed", err) + } + defer resp.Body.Close() + + if err := c.checkResponse(resp); err != nil { + return core.E(op, "API error", err) + } + + return nil +} + +// setHeaders adds common headers to the request. +func (c *Client) setHeaders(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "core-agentic-client/1.0") +} + +// checkResponse checks if the response indicates an error. +func (c *Client) checkResponse(resp *http.Response) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + + // Try to parse as APIError + var apiErr APIError + if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Message != "" { + apiErr.Code = resp.StatusCode + return &apiErr + } + + // Return generic error + return &APIError{ + Code: resp.StatusCode, + Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)), + Details: string(body), + } +} + +// mustReadAll reads all bytes from a reader, returning empty slice on error. +func mustReadAll(r io.Reader) []byte { + data, _ := io.ReadAll(r) + return data +} + +// Ping tests the connection to the API server. +func (c *Client) Ping(ctx context.Context) error { + const op = "agentic.Client.Ping" + + endpoint := c.BaseURL + "/api/health" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return core.E(op, "failed to create request", err) + } + + c.setHeaders(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return core.E(op, "request failed", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return core.E(op, fmt.Sprintf("server returned status %d", resp.StatusCode), nil) + } + + return nil +} diff --git a/pkg/agentic/client_test.go b/pkg/agentic/client_test.go new file mode 100644 index 00000000..89ff93d7 --- /dev/null +++ b/pkg/agentic/client_test.go @@ -0,0 +1,356 @@ +package agentic + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test fixtures +var testTask = Task{ + ID: "task-123", + Title: "Implement feature X", + Description: "Add the new feature X to the system", + Priority: PriorityHigh, + Status: StatusPending, + Labels: []string{"feature", "backend"}, + Files: []string{"pkg/feature/feature.go"}, + CreatedAt: time.Now().Add(-24 * time.Hour), + Project: "core", +} + +var testTasks = []Task{ + testTask, + { + ID: "task-456", + Title: "Fix bug Y", + Description: "Fix the bug in component Y", + Priority: PriorityCritical, + Status: StatusPending, + Labels: []string{"bug", "urgent"}, + CreatedAt: time.Now().Add(-2 * time.Hour), + Project: "core", + }, +} + +func TestNewClient_Good(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + + assert.Equal(t, "https://api.example.com", client.BaseURL) + assert.Equal(t, "test-token", client.Token) + assert.NotNil(t, client.HTTPClient) +} + +func TestNewClient_Good_TrailingSlash(t *testing.T) { + client := NewClient("https://api.example.com/", "test-token") + + assert.Equal(t, "https://api.example.com", client.BaseURL) +} + +func TestNewClientFromConfig_Good(t *testing.T) { + cfg := &Config{ + BaseURL: "https://api.example.com", + Token: "config-token", + AgentID: "agent-001", + } + + client := NewClientFromConfig(cfg) + + assert.Equal(t, "https://api.example.com", client.BaseURL) + assert.Equal(t, "config-token", client.Token) + assert.Equal(t, "agent-001", client.AgentID) +} + +func TestClient_ListTasks_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/tasks", r.URL.Path) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(testTasks) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + tasks, err := client.ListTasks(context.Background(), ListOptions{}) + + require.NoError(t, err) + assert.Len(t, tasks, 2) + assert.Equal(t, "task-123", tasks[0].ID) + assert.Equal(t, "task-456", tasks[1].ID) +} + +func TestClient_ListTasks_Good_WithFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + assert.Equal(t, "pending", query.Get("status")) + assert.Equal(t, "high", query.Get("priority")) + assert.Equal(t, "core", query.Get("project")) + assert.Equal(t, "10", query.Get("limit")) + assert.Equal(t, "bug,urgent", query.Get("labels")) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]Task{testTask}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + opts := ListOptions{ + Status: StatusPending, + Priority: PriorityHigh, + Project: "core", + Limit: 10, + Labels: []string{"bug", "urgent"}, + } + + tasks, err := client.ListTasks(context.Background(), opts) + + require.NoError(t, err) + assert.Len(t, tasks, 1) +} + +func TestClient_ListTasks_Bad_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(APIError{Message: "internal error"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + tasks, err := client.ListTasks(context.Background(), ListOptions{}) + + assert.Error(t, err) + assert.Nil(t, tasks) + assert.Contains(t, err.Error(), "internal error") +} + +func TestClient_GetTask_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/tasks/task-123", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(testTask) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + task, err := client.GetTask(context.Background(), "task-123") + + require.NoError(t, err) + assert.Equal(t, "task-123", task.ID) + assert.Equal(t, "Implement feature X", task.Title) + assert.Equal(t, PriorityHigh, task.Priority) +} + +func TestClient_GetTask_Bad_EmptyID(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + task, err := client.GetTask(context.Background(), "") + + assert.Error(t, err) + assert.Nil(t, task) + assert.Contains(t, err.Error(), "task ID is required") +} + +func TestClient_GetTask_Bad_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(APIError{Message: "task not found"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + task, err := client.GetTask(context.Background(), "nonexistent") + + assert.Error(t, err) + assert.Nil(t, task) + assert.Contains(t, err.Error(), "task not found") +} + +func TestClient_ClaimTask_Good(t *testing.T) { + claimedTask := testTask + claimedTask.Status = StatusInProgress + claimedTask.ClaimedBy = "agent-001" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/tasks/task-123/claim", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ClaimResponse{Task: &claimedTask}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + client.AgentID = "agent-001" + task, err := client.ClaimTask(context.Background(), "task-123") + + require.NoError(t, err) + assert.Equal(t, StatusInProgress, task.Status) + assert.Equal(t, "agent-001", task.ClaimedBy) +} + +func TestClient_ClaimTask_Good_SimpleResponse(t *testing.T) { + // Some APIs might return just the task without wrapping + claimedTask := testTask + claimedTask.Status = StatusInProgress + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(claimedTask) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + task, err := client.ClaimTask(context.Background(), "task-123") + + require.NoError(t, err) + assert.Equal(t, "task-123", task.ID) +} + +func TestClient_ClaimTask_Bad_EmptyID(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + task, err := client.ClaimTask(context.Background(), "") + + assert.Error(t, err) + assert.Nil(t, task) + assert.Contains(t, err.Error(), "task ID is required") +} + +func TestClient_ClaimTask_Bad_AlreadyClaimed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(APIError{Message: "task already claimed"}) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + task, err := client.ClaimTask(context.Background(), "task-123") + + assert.Error(t, err) + assert.Nil(t, task) + assert.Contains(t, err.Error(), "task already claimed") +} + +func TestClient_UpdateTask_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/tasks/task-123", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var update TaskUpdate + err := json.NewDecoder(r.Body).Decode(&update) + require.NoError(t, err) + assert.Equal(t, StatusInProgress, update.Status) + assert.Equal(t, 50, update.Progress) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + err := client.UpdateTask(context.Background(), "task-123", TaskUpdate{ + Status: StatusInProgress, + Progress: 50, + Notes: "Making progress", + }) + + assert.NoError(t, err) +} + +func TestClient_UpdateTask_Bad_EmptyID(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + err := client.UpdateTask(context.Background(), "", TaskUpdate{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "task ID is required") +} + +func TestClient_CompleteTask_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/tasks/task-123/complete", r.URL.Path) + + var result TaskResult + err := json.NewDecoder(r.Body).Decode(&result) + require.NoError(t, err) + assert.True(t, result.Success) + assert.Equal(t, "Feature implemented", result.Output) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + err := client.CompleteTask(context.Background(), "task-123", TaskResult{ + Success: true, + Output: "Feature implemented", + Artifacts: []string{"pkg/feature/feature.go"}, + }) + + assert.NoError(t, err) +} + +func TestClient_CompleteTask_Bad_EmptyID(t *testing.T) { + client := NewClient("https://api.example.com", "test-token") + err := client.CompleteTask(context.Background(), "", TaskResult{}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "task ID is required") +} + +func TestClient_Ping_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/health", r.URL.Path) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + err := client.Ping(context.Background()) + + assert.NoError(t, err) +} + +func TestClient_Ping_Bad_ServerDown(t *testing.T) { + client := NewClient("http://localhost:99999", "test-token") + client.HTTPClient.Timeout = 100 * time.Millisecond + + err := client.Ping(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "request failed") +} + +func TestAPIError_Error_Good(t *testing.T) { + err := &APIError{ + Code: 404, + Message: "task not found", + } + + assert.Equal(t, "task not found", err.Error()) + + err.Details = "task-123 does not exist" + assert.Equal(t, "task not found: task-123 does not exist", err.Error()) +} + +func TestTaskStatus_Good(t *testing.T) { + assert.Equal(t, TaskStatus("pending"), StatusPending) + assert.Equal(t, TaskStatus("in_progress"), StatusInProgress) + assert.Equal(t, TaskStatus("completed"), StatusCompleted) + assert.Equal(t, TaskStatus("blocked"), StatusBlocked) +} + +func TestTaskPriority_Good(t *testing.T) { + assert.Equal(t, TaskPriority("critical"), PriorityCritical) + assert.Equal(t, TaskPriority("high"), PriorityHigh) + assert.Equal(t, TaskPriority("medium"), PriorityMedium) + assert.Equal(t, TaskPriority("low"), PriorityLow) +} diff --git a/pkg/agentic/config.go b/pkg/agentic/config.go new file mode 100644 index 00000000..42285781 --- /dev/null +++ b/pkg/agentic/config.go @@ -0,0 +1,199 @@ +package agentic + +import ( + "bufio" + "os" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/core" + "gopkg.in/yaml.v3" +) + +// Config holds the configuration for connecting to the core-agentic service. +type Config struct { + // BaseURL is the URL of the core-agentic API server. + BaseURL string `yaml:"base_url" json:"base_url"` + // Token is the authentication token for API requests. + Token string `yaml:"token" json:"token"` + // DefaultProject is the project to use when none is specified. + DefaultProject string `yaml:"default_project" json:"default_project"` + // AgentID is the identifier for this agent (optional, used for claiming tasks). + AgentID string `yaml:"agent_id" json:"agent_id"` +} + +// configFileName is the name of the YAML config file. +const configFileName = "agentic.yaml" + +// envFileName is the name of the environment file. +const envFileName = ".env" + +// DefaultBaseURL is the default API endpoint if none is configured. +const DefaultBaseURL = "https://api.core-agentic.dev" + +// LoadConfig loads the agentic configuration from the specified directory. +// It first checks for a .env file, then falls back to ~/.core/agentic.yaml. +// If dir is empty, it checks the current directory first. +// +// Environment variables take precedence: +// - AGENTIC_BASE_URL: API base URL +// - AGENTIC_TOKEN: Authentication token +// - AGENTIC_PROJECT: Default project +// - AGENTIC_AGENT_ID: Agent identifier +func LoadConfig(dir string) (*Config, error) { + cfg := &Config{ + BaseURL: DefaultBaseURL, + } + + // Try loading from .env file in the specified directory + if dir != "" { + envPath := filepath.Join(dir, envFileName) + if err := loadEnvFile(envPath, cfg); err == nil { + // Successfully loaded from .env + applyEnvOverrides(cfg) + if cfg.Token != "" { + return cfg, nil + } + } + } + + // Try loading from current directory .env + if dir == "" { + cwd, err := os.Getwd() + if err == nil { + envPath := filepath.Join(cwd, envFileName) + if err := loadEnvFile(envPath, cfg); err == nil { + applyEnvOverrides(cfg) + if cfg.Token != "" { + return cfg, nil + } + } + } + } + + // Try loading from ~/.core/agentic.yaml + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, core.E("agentic.LoadConfig", "failed to get home directory", err) + } + + configPath := filepath.Join(homeDir, ".core", configFileName) + if err := loadYAMLConfig(configPath, cfg); err != nil && !os.IsNotExist(err) { + return nil, core.E("agentic.LoadConfig", "failed to load config", err) + } + + // Apply environment variable overrides + applyEnvOverrides(cfg) + + // Validate configuration + if cfg.Token == "" { + return nil, core.E("agentic.LoadConfig", "no authentication token configured", nil) + } + + return cfg, nil +} + +// loadEnvFile reads a .env file and extracts agentic configuration. +func loadEnvFile(path string, cfg *Config) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Parse KEY=value + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes if present + value = strings.Trim(value, `"'`) + + switch key { + case "AGENTIC_BASE_URL": + cfg.BaseURL = value + case "AGENTIC_TOKEN": + cfg.Token = value + case "AGENTIC_PROJECT": + cfg.DefaultProject = value + case "AGENTIC_AGENT_ID": + cfg.AgentID = value + } + } + + return scanner.Err() +} + +// loadYAMLConfig reads configuration from a YAML file. +func loadYAMLConfig(path string, cfg *Config) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + return yaml.Unmarshal(data, cfg) +} + +// applyEnvOverrides applies environment variable overrides to the config. +func applyEnvOverrides(cfg *Config) { + if v := os.Getenv("AGENTIC_BASE_URL"); v != "" { + cfg.BaseURL = v + } + if v := os.Getenv("AGENTIC_TOKEN"); v != "" { + cfg.Token = v + } + if v := os.Getenv("AGENTIC_PROJECT"); v != "" { + cfg.DefaultProject = v + } + if v := os.Getenv("AGENTIC_AGENT_ID"); v != "" { + cfg.AgentID = v + } +} + +// SaveConfig saves the configuration to ~/.core/agentic.yaml. +func SaveConfig(cfg *Config) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return core.E("agentic.SaveConfig", "failed to get home directory", err) + } + + configDir := filepath.Join(homeDir, ".core") + if err := os.MkdirAll(configDir, 0755); err != nil { + return core.E("agentic.SaveConfig", "failed to create config directory", err) + } + + configPath := filepath.Join(configDir, configFileName) + + data, err := yaml.Marshal(cfg) + if err != nil { + return core.E("agentic.SaveConfig", "failed to marshal config", err) + } + + if err := os.WriteFile(configPath, data, 0600); err != nil { + return core.E("agentic.SaveConfig", "failed to write config file", err) + } + + return nil +} + +// ConfigPath returns the path to the config file in the user's home directory. +func ConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", core.E("agentic.ConfigPath", "failed to get home directory", err) + } + return filepath.Join(homeDir, ".core", configFileName), nil +} diff --git a/pkg/agentic/config_test.go b/pkg/agentic/config_test.go new file mode 100644 index 00000000..6e88478b --- /dev/null +++ b/pkg/agentic/config_test.go @@ -0,0 +1,185 @@ +package agentic + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadConfig_Good_FromEnvFile(t *testing.T) { + // Create temp directory with .env file + tmpDir, err := os.MkdirTemp("", "agentic-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + envContent := ` +AGENTIC_BASE_URL=https://test.api.com +AGENTIC_TOKEN=test-token-123 +AGENTIC_PROJECT=my-project +AGENTIC_AGENT_ID=agent-001 +` + err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + cfg, err := LoadConfig(tmpDir) + + require.NoError(t, err) + assert.Equal(t, "https://test.api.com", cfg.BaseURL) + assert.Equal(t, "test-token-123", cfg.Token) + assert.Equal(t, "my-project", cfg.DefaultProject) + assert.Equal(t, "agent-001", cfg.AgentID) +} + +func TestLoadConfig_Good_FromEnvVars(t *testing.T) { + // Create temp directory with .env file (partial config) + tmpDir, err := os.MkdirTemp("", "agentic-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + envContent := ` +AGENTIC_TOKEN=env-file-token +` + err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + // Set environment variables that should override + os.Setenv("AGENTIC_BASE_URL", "https://env-override.com") + os.Setenv("AGENTIC_TOKEN", "env-override-token") + defer func() { + os.Unsetenv("AGENTIC_BASE_URL") + os.Unsetenv("AGENTIC_TOKEN") + }() + + cfg, err := LoadConfig(tmpDir) + + require.NoError(t, err) + assert.Equal(t, "https://env-override.com", cfg.BaseURL) + assert.Equal(t, "env-override-token", cfg.Token) +} + +func TestLoadConfig_Bad_NoToken(t *testing.T) { + // Create temp directory without config + tmpDir, err := os.MkdirTemp("", "agentic-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create empty .env + err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(""), 0644) + require.NoError(t, err) + + // Ensure no env vars are set + os.Unsetenv("AGENTIC_TOKEN") + os.Unsetenv("AGENTIC_BASE_URL") + + _, err = LoadConfig(tmpDir) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no authentication token") +} + +func TestLoadConfig_Good_EnvFileWithQuotes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentic-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Test with quoted values + envContent := ` +AGENTIC_TOKEN="quoted-token" +AGENTIC_BASE_URL='single-quoted-url' +` + err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + cfg, err := LoadConfig(tmpDir) + + require.NoError(t, err) + assert.Equal(t, "quoted-token", cfg.Token) + assert.Equal(t, "single-quoted-url", cfg.BaseURL) +} + +func TestLoadConfig_Good_EnvFileWithComments(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentic-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + envContent := ` +# This is a comment +AGENTIC_TOKEN=token-with-comments + +# Another comment +AGENTIC_PROJECT=commented-project +` + err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + cfg, err := LoadConfig(tmpDir) + + require.NoError(t, err) + assert.Equal(t, "token-with-comments", cfg.Token) + assert.Equal(t, "commented-project", cfg.DefaultProject) +} + +func TestSaveConfig_Good(t *testing.T) { + // Create temp home directory + tmpHome, err := os.MkdirTemp("", "agentic-home") + require.NoError(t, err) + defer os.RemoveAll(tmpHome) + + // Override HOME for the test + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tmpHome) + defer os.Setenv("HOME", originalHome) + + cfg := &Config{ + BaseURL: "https://saved.api.com", + Token: "saved-token", + DefaultProject: "saved-project", + AgentID: "saved-agent", + } + + err = SaveConfig(cfg) + require.NoError(t, err) + + // Verify file was created + configPath := filepath.Join(tmpHome, ".core", "agentic.yaml") + _, err = os.Stat(configPath) + assert.NoError(t, err) + + // Read back the config + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), "saved.api.com") + assert.Contains(t, string(data), "saved-token") +} + +func TestConfigPath_Good(t *testing.T) { + path, err := ConfigPath() + + require.NoError(t, err) + assert.Contains(t, path, ".core") + assert.Contains(t, path, "agentic.yaml") +} + +func TestLoadConfig_Good_DefaultBaseURL(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agentic-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Only provide token, should use default base URL + envContent := ` +AGENTIC_TOKEN=test-token +` + err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644) + require.NoError(t, err) + + // Clear any env overrides + os.Unsetenv("AGENTIC_BASE_URL") + + cfg, err := LoadConfig(tmpDir) + + require.NoError(t, err) + assert.Equal(t, DefaultBaseURL, cfg.BaseURL) +} diff --git a/pkg/agentic/go.mod b/pkg/agentic/go.mod new file mode 100644 index 00000000..6204c83f --- /dev/null +++ b/pkg/agentic/go.mod @@ -0,0 +1,14 @@ +module github.com/host-uk/core/pkg/agentic + +go 1.25 + +require ( + github.com/host-uk/core/pkg/core v0.0.0 + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/pkg/agentic/types.go b/pkg/agentic/types.go new file mode 100644 index 00000000..53fc4803 --- /dev/null +++ b/pkg/agentic/types.go @@ -0,0 +1,140 @@ +// Package agentic provides an API client for core-agentic, an AI-assisted task +// management service. It enables developers and AI agents to discover, claim, +// and complete development tasks. +package agentic + +import ( + "time" +) + +// TaskStatus represents the state of a task in the system. +type TaskStatus string + +const ( + // StatusPending indicates the task is available to be claimed. + StatusPending TaskStatus = "pending" + // StatusInProgress indicates the task has been claimed and is being worked on. + StatusInProgress TaskStatus = "in_progress" + // StatusCompleted indicates the task has been successfully completed. + StatusCompleted TaskStatus = "completed" + // StatusBlocked indicates the task cannot proceed due to dependencies. + StatusBlocked TaskStatus = "blocked" +) + +// TaskPriority represents the urgency level of a task. +type TaskPriority string + +const ( + // PriorityCritical indicates the task requires immediate attention. + PriorityCritical TaskPriority = "critical" + // PriorityHigh indicates the task is important and should be addressed soon. + PriorityHigh TaskPriority = "high" + // PriorityMedium indicates the task has normal priority. + PriorityMedium TaskPriority = "medium" + // PriorityLow indicates the task can be addressed when time permits. + PriorityLow TaskPriority = "low" +) + +// Task represents a development task in the core-agentic system. +type Task struct { + // ID is the unique identifier for the task. + ID string `json:"id"` + // Title is the short description of the task. + Title string `json:"title"` + // Description provides detailed information about what needs to be done. + Description string `json:"description"` + // Priority indicates the urgency of the task. + Priority TaskPriority `json:"priority"` + // Status indicates the current state of the task. + Status TaskStatus `json:"status"` + // Labels are tags used to categorize the task. + Labels []string `json:"labels,omitempty"` + // Files lists the files that are relevant to this task. + Files []string `json:"files,omitempty"` + // CreatedAt is when the task was created. + CreatedAt time.Time `json:"created_at"` + // UpdatedAt is when the task was last modified. + UpdatedAt time.Time `json:"updated_at,omitempty"` + // ClaimedBy is the identifier of the agent or developer who claimed the task. + ClaimedBy string `json:"claimed_by,omitempty"` + // ClaimedAt is when the task was claimed. + ClaimedAt *time.Time `json:"claimed_at,omitempty"` + // Project is the project this task belongs to. + Project string `json:"project,omitempty"` + // Dependencies lists task IDs that must be completed before this task. + Dependencies []string `json:"dependencies,omitempty"` + // Blockers lists task IDs that this task is blocking. + Blockers []string `json:"blockers,omitempty"` +} + +// TaskUpdate contains fields that can be updated on a task. +type TaskUpdate struct { + // Status is the new status for the task. + Status TaskStatus `json:"status,omitempty"` + // Progress is a percentage (0-100) indicating completion. + Progress int `json:"progress,omitempty"` + // Notes are additional comments about the update. + Notes string `json:"notes,omitempty"` +} + +// TaskResult contains the outcome of a completed task. +type TaskResult struct { + // Success indicates whether the task was completed successfully. + Success bool `json:"success"` + // Output is the result or summary of the completed work. + Output string `json:"output,omitempty"` + // Artifacts are files or resources produced by the task. + Artifacts []string `json:"artifacts,omitempty"` + // ErrorMessage contains details if the task failed. + ErrorMessage string `json:"error_message,omitempty"` +} + +// ListOptions specifies filters for listing tasks. +type ListOptions struct { + // Status filters tasks by their current status. + Status TaskStatus `json:"status,omitempty"` + // Labels filters tasks that have all specified labels. + Labels []string `json:"labels,omitempty"` + // Priority filters tasks by priority level. + Priority TaskPriority `json:"priority,omitempty"` + // Limit is the maximum number of tasks to return. + Limit int `json:"limit,omitempty"` + // Project filters tasks by project. + Project string `json:"project,omitempty"` + // ClaimedBy filters tasks claimed by a specific agent. + ClaimedBy string `json:"claimed_by,omitempty"` +} + +// APIError represents an error response from the API. +type APIError struct { + // Code is the HTTP status code. + Code int `json:"code"` + // Message is the error description. + Message string `json:"message"` + // Details provides additional context about the error. + Details string `json:"details,omitempty"` +} + +// Error implements the error interface for APIError. +func (e *APIError) Error() string { + if e.Details != "" { + return e.Message + ": " + e.Details + } + return e.Message +} + +// ClaimResponse is returned when a task is successfully claimed. +type ClaimResponse struct { + // Task is the claimed task with updated fields. + Task *Task `json:"task"` + // Message provides additional context about the claim. + Message string `json:"message,omitempty"` +} + +// CompleteResponse is returned when a task is completed. +type CompleteResponse struct { + // Task is the completed task with final status. + Task *Task `json:"task"` + // Message provides additional context about the completion. + Message string `json:"message,omitempty"` +}