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 <id> - show/claim task
- core dev task --auto - AI picks highest priority
- core dev task:update <id> - update progress
- core dev task:complete <id> - mark complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 19:58:41 +00:00
parent 605ee023ca
commit 3b6427f324
9 changed files with 1666 additions and 0 deletions

442
cmd/core/cmd/agentic.go Normal file
View file

@ -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 <id> - show task details and claim
addTaskCommand(parent)
// core dev task:update <id> - update task
addTaskUpdateCommand(parent)
// core dev task:complete <id> - 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", &notes)
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 <id>' 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))
}
}

View file

@ -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)

View file

@ -7,6 +7,7 @@ use (
./cmd/core-mcp
./cmd/examples/core-static-di
./cmd/lthn-desktop
./pkg/agentic
./pkg/build
./pkg/cache
./pkg/config

328
pkg/agentic/client.go Normal file
View file

@ -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
}

356
pkg/agentic/client_test.go Normal file
View file

@ -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)
}

199
pkg/agentic/config.go Normal file
View file

@ -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
}

185
pkg/agentic/config_test.go Normal file
View file

@ -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)
}

14
pkg/agentic/go.mod Normal file
View file

@ -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
)

140
pkg/agentic/types.go Normal file
View file

@ -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"`
}