feat(cli): add status summary, task submission, and log streaming
CLI backing functions for core agent commands: - GetStatus/FormatStatus aggregates registry + client + allowance data - SubmitTask + Client.CreateTask for task creation - StreamLogs polls task updates to io.Writer Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
b03e4c1e0b
commit
ef81db73c1
7 changed files with 797 additions and 0 deletions
37
client.go
37
client.go
|
|
@ -295,6 +295,43 @@ func (c *Client) checkResponse(resp *http.Response) error {
|
|||
}
|
||||
}
|
||||
|
||||
// CreateTask creates a new task via POST /api/tasks.
|
||||
func (c *Client) CreateTask(ctx context.Context, task Task) (*Task, error) {
|
||||
const op = "agentic.Client.CreateTask"
|
||||
|
||||
data, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
return nil, log.E(op, "failed to marshal task", err)
|
||||
}
|
||||
|
||||
endpoint := c.BaseURL + "/api/tasks"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, log.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 nil, log.E(op, "request failed", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return nil, log.E(op, "API error", err)
|
||||
}
|
||||
|
||||
var created Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return nil, log.E(op, "failed to decode response", err)
|
||||
}
|
||||
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
// Ping tests the connection to the API server.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
const op = "agentic.Client.Ping"
|
||||
|
|
|
|||
47
logs.go
Normal file
47
logs.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// StreamLogs polls a task's status and writes updates to writer. It polls at
|
||||
// the given interval until the task reaches a terminal state (completed or
|
||||
// blocked) or the context is cancelled. Returns ctx.Err() on cancellation.
|
||||
func StreamLogs(ctx context.Context, client *Client, taskID string, interval time.Duration, writer io.Writer) error {
|
||||
const op = "agentic.StreamLogs"
|
||||
|
||||
if taskID == "" {
|
||||
return log.E(op, "task ID is required", nil)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
task, err := client.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
// Write the error but continue polling -- transient failures
|
||||
// should not stop the stream.
|
||||
_, _ = fmt.Fprintf(writer, "[%s] Error: %s\n", time.Now().UTC().Format("2006-01-02 15:04:05"), err)
|
||||
continue
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("[%s] Status: %s", time.Now().UTC().Format("2006-01-02 15:04:05"), task.Status)
|
||||
_, _ = fmt.Fprintln(writer, line)
|
||||
|
||||
// Stop on terminal states.
|
||||
if task.Status == StatusCompleted || task.Status == StatusBlocked {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
logs_test.go
Normal file
139
logs_test.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStreamLogs_Good_CompletedTask(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/tasks/task-1", r.URL.Path)
|
||||
n := calls.Add(1)
|
||||
|
||||
task := Task{ID: "task-1"}
|
||||
switch {
|
||||
case n <= 2:
|
||||
task.Status = StatusInProgress
|
||||
default:
|
||||
task.Status = StatusCompleted
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(task)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := StreamLogs(context.Background(), client, "task-1", 10*time.Millisecond, &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Status: in_progress")
|
||||
assert.Contains(t, output, "Status: completed")
|
||||
assert.GreaterOrEqual(t, int(calls.Load()), 3)
|
||||
}
|
||||
|
||||
func TestStreamLogs_Good_BlockedTask(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
task := Task{ID: "task-2", Status: StatusBlocked}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(task)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := StreamLogs(context.Background(), client, "task-2", 10*time.Millisecond, &buf)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, buf.String(), "Status: blocked")
|
||||
}
|
||||
|
||||
func TestStreamLogs_Good_ContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
task := Task{ID: "task-3", Status: StatusInProgress}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(task)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var buf bytes.Buffer
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := StreamLogs(ctx, client, "task-3", 20*time.Millisecond, &buf)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
assert.Contains(t, buf.String(), "Status: in_progress")
|
||||
}
|
||||
|
||||
func TestStreamLogs_Good_TransientErrorContinues(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
|
||||
if n == 1 {
|
||||
// First call: server error.
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(APIError{Message: "transient"})
|
||||
return
|
||||
}
|
||||
// Second call: completed.
|
||||
task := Task{ID: "task-4", Status: StatusCompleted}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(task)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := StreamLogs(context.Background(), client, "task-4", 10*time.Millisecond, &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Error:")
|
||||
assert.Contains(t, output, "Status: completed")
|
||||
}
|
||||
|
||||
func TestStreamLogs_Bad_EmptyTaskID(t *testing.T) {
|
||||
client := NewClient("https://api.example.com", "test-token")
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := StreamLogs(context.Background(), client, "", 10*time.Millisecond, &buf)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "task ID is required")
|
||||
}
|
||||
|
||||
func TestStreamLogs_Good_ImmediateCancel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
task := Task{ID: "task-5", Status: StatusInProgress}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(task)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var buf bytes.Buffer
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately.
|
||||
|
||||
err := StreamLogs(ctx, client, "task-5", 10*time.Millisecond, &buf)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
135
status.go
Normal file
135
status.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// StatusSummary aggregates status from the agent registry, task client, and
|
||||
// allowance service for CLI display.
|
||||
type StatusSummary struct {
|
||||
// Agents is the list of registered agents.
|
||||
Agents []AgentInfo
|
||||
// PendingTasks is the count of tasks with StatusPending.
|
||||
PendingTasks int
|
||||
// InProgressTasks is the count of tasks with StatusInProgress.
|
||||
InProgressTasks int
|
||||
// AllowanceRemaining maps agent ID to remaining daily tokens. -1 means unlimited.
|
||||
AllowanceRemaining map[string]int64
|
||||
}
|
||||
|
||||
// GetStatus aggregates status from the registry, client, and allowance service.
|
||||
// Any of registry, client, or allowanceSvc can be nil -- those sections are
|
||||
// simply skipped. Returns what we can collect without failing on nil components.
|
||||
func GetStatus(ctx context.Context, registry AgentRegistry, client *Client, allowanceSvc *AllowanceService) (*StatusSummary, error) {
|
||||
const op = "agentic.GetStatus"
|
||||
|
||||
summary := &StatusSummary{
|
||||
AllowanceRemaining: make(map[string]int64),
|
||||
}
|
||||
|
||||
// Collect agents from registry.
|
||||
if registry != nil {
|
||||
summary.Agents = registry.List()
|
||||
}
|
||||
|
||||
// Count tasks by status via client.
|
||||
if client != nil {
|
||||
pending, err := client.ListTasks(ctx, ListOptions{Status: StatusPending})
|
||||
if err != nil {
|
||||
return nil, log.E(op, "failed to list pending tasks", err)
|
||||
}
|
||||
summary.PendingTasks = len(pending)
|
||||
|
||||
inProgress, err := client.ListTasks(ctx, ListOptions{Status: StatusInProgress})
|
||||
if err != nil {
|
||||
return nil, log.E(op, "failed to list in-progress tasks", err)
|
||||
}
|
||||
summary.InProgressTasks = len(inProgress)
|
||||
}
|
||||
|
||||
// Collect allowance remaining per agent.
|
||||
if allowanceSvc != nil {
|
||||
for _, agent := range summary.Agents {
|
||||
check, err := allowanceSvc.Check(agent.ID, "")
|
||||
if err != nil {
|
||||
// Skip agents whose allowance cannot be resolved.
|
||||
continue
|
||||
}
|
||||
summary.AllowanceRemaining[agent.ID] = check.RemainingTokens
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// FormatStatus renders the summary as a human-readable table string suitable
|
||||
// for CLI output.
|
||||
func FormatStatus(s *StatusSummary) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Count agents by status.
|
||||
available := 0
|
||||
busy := 0
|
||||
for _, a := range s.Agents {
|
||||
switch a.Status {
|
||||
case AgentAvailable:
|
||||
available++
|
||||
case AgentBusy:
|
||||
busy++
|
||||
}
|
||||
}
|
||||
|
||||
total := len(s.Agents)
|
||||
statusParts := make([]string, 0, 2)
|
||||
if available > 0 {
|
||||
statusParts = append(statusParts, fmt.Sprintf("%d available", available))
|
||||
}
|
||||
if busy > 0 {
|
||||
statusParts = append(statusParts, fmt.Sprintf("%d busy", busy))
|
||||
}
|
||||
offline := total - available - busy
|
||||
if offline > 0 {
|
||||
statusParts = append(statusParts, fmt.Sprintf("%d offline", offline))
|
||||
}
|
||||
|
||||
if len(statusParts) > 0 {
|
||||
fmt.Fprintf(&b, "Agents: %d (%s)\n", total, strings.Join(statusParts, ", "))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Agents: %d\n", total)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "Tasks: %d pending, %d in progress\n", s.PendingTasks, s.InProgressTasks)
|
||||
|
||||
if len(s.Agents) > 0 {
|
||||
// Sort agents by ID for deterministic output.
|
||||
agents := make([]AgentInfo, len(s.Agents))
|
||||
copy(agents, s.Agents)
|
||||
sort.Slice(agents, func(i, j int) bool { return agents[i].ID < agents[j].ID })
|
||||
|
||||
fmt.Fprintf(&b, "%-16s%-12s%-8s%s\n", "Agent", "Status", "Load", "Remaining")
|
||||
for _, a := range agents {
|
||||
load := fmt.Sprintf("%d/%d", a.CurrentLoad, a.MaxLoad)
|
||||
if a.MaxLoad == 0 {
|
||||
load = fmt.Sprintf("%d/-", a.CurrentLoad)
|
||||
}
|
||||
|
||||
remaining := "unknown"
|
||||
if tokens, ok := s.AllowanceRemaining[a.ID]; ok {
|
||||
if tokens < 0 {
|
||||
remaining = "unlimited"
|
||||
} else {
|
||||
remaining = fmt.Sprintf("%d tokens", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "%-16s%-12s%-8s%s\n", a.ID, string(a.Status), load, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
270
status_test.go
Normal file
270
status_test.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- GetStatus tests ---
|
||||
|
||||
func TestGetStatus_Good_AllNil(t *testing.T) {
|
||||
summary, err := GetStatus(context.Background(), nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, summary.Agents)
|
||||
assert.Equal(t, 0, summary.PendingTasks)
|
||||
assert.Equal(t, 0, summary.InProgressTasks)
|
||||
assert.Empty(t, summary.AllowanceRemaining)
|
||||
}
|
||||
|
||||
func TestGetStatus_Good_RegistryOnly(t *testing.T) {
|
||||
reg := NewMemoryRegistry()
|
||||
_ = reg.Register(AgentInfo{
|
||||
ID: "virgil",
|
||||
Name: "Virgil",
|
||||
Status: AgentAvailable,
|
||||
LastHeartbeat: time.Now().UTC(),
|
||||
MaxLoad: 5,
|
||||
})
|
||||
_ = reg.Register(AgentInfo{
|
||||
ID: "charon",
|
||||
Name: "Charon",
|
||||
Status: AgentBusy,
|
||||
CurrentLoad: 3,
|
||||
MaxLoad: 5,
|
||||
LastHeartbeat: time.Now().UTC(),
|
||||
})
|
||||
|
||||
summary, err := GetStatus(context.Background(), reg, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summary.Agents, 2)
|
||||
assert.Equal(t, 0, summary.PendingTasks)
|
||||
assert.Equal(t, 0, summary.InProgressTasks)
|
||||
}
|
||||
|
||||
func TestGetStatus_Good_FullSummary(t *testing.T) {
|
||||
// Set up mock server returning task counts.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch status {
|
||||
case "pending":
|
||||
tasks := []Task{
|
||||
{ID: "t1", Status: StatusPending},
|
||||
{ID: "t2", Status: StatusPending},
|
||||
{ID: "t3", Status: StatusPending},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(tasks)
|
||||
case "in_progress":
|
||||
tasks := []Task{
|
||||
{ID: "t4", Status: StatusInProgress},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(tasks)
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode([]Task{})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
reg := NewMemoryRegistry()
|
||||
_ = reg.Register(AgentInfo{
|
||||
ID: "virgil",
|
||||
Name: "Virgil",
|
||||
Status: AgentAvailable,
|
||||
LastHeartbeat: time.Now().UTC(),
|
||||
MaxLoad: 5,
|
||||
})
|
||||
_ = reg.Register(AgentInfo{
|
||||
ID: "charon",
|
||||
Name: "Charon",
|
||||
Status: AgentBusy,
|
||||
CurrentLoad: 3,
|
||||
MaxLoad: 5,
|
||||
LastHeartbeat: time.Now().UTC(),
|
||||
})
|
||||
|
||||
store := NewMemoryStore()
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "virgil",
|
||||
DailyTokenLimit: 50000,
|
||||
})
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "charon",
|
||||
DailyTokenLimit: 50000,
|
||||
})
|
||||
// Simulate charon has used 38000 tokens.
|
||||
_ = store.IncrementUsage("charon", 38000, 0)
|
||||
|
||||
svc := NewAllowanceService(store)
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
summary, err := GetStatus(context.Background(), reg, client, svc)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summary.Agents, 2)
|
||||
assert.Equal(t, 3, summary.PendingTasks)
|
||||
assert.Equal(t, 1, summary.InProgressTasks)
|
||||
assert.Equal(t, int64(50000), summary.AllowanceRemaining["virgil"])
|
||||
assert.Equal(t, int64(12000), summary.AllowanceRemaining["charon"])
|
||||
}
|
||||
|
||||
func TestGetStatus_Good_UnlimitedAllowance(t *testing.T) {
|
||||
reg := NewMemoryRegistry()
|
||||
_ = reg.Register(AgentInfo{
|
||||
ID: "darbs",
|
||||
Name: "Darbs",
|
||||
Status: AgentAvailable,
|
||||
LastHeartbeat: time.Now().UTC(),
|
||||
MaxLoad: 3,
|
||||
})
|
||||
|
||||
store := NewMemoryStore()
|
||||
// DailyTokenLimit 0 means unlimited.
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "darbs",
|
||||
DailyTokenLimit: 0,
|
||||
})
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
summary, err := GetStatus(context.Background(), reg, nil, svc)
|
||||
require.NoError(t, err)
|
||||
// Unlimited: Check returns RemainingTokens = -1.
|
||||
assert.Equal(t, int64(-1), summary.AllowanceRemaining["darbs"])
|
||||
}
|
||||
|
||||
func TestGetStatus_Good_AllowanceSkipsUnknownAgents(t *testing.T) {
|
||||
reg := NewMemoryRegistry()
|
||||
_ = reg.Register(AgentInfo{
|
||||
ID: "unknown-agent",
|
||||
Name: "Unknown",
|
||||
Status: AgentAvailable,
|
||||
LastHeartbeat: time.Now().UTC(),
|
||||
})
|
||||
|
||||
store := NewMemoryStore()
|
||||
// No allowance set for "unknown-agent" -- GetAllowance will error.
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
summary, err := GetStatus(context.Background(), reg, nil, svc)
|
||||
require.NoError(t, err)
|
||||
// AllowanceRemaining should not have an entry for unknown-agent.
|
||||
_, exists := summary.AllowanceRemaining["unknown-agent"]
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestGetStatus_Bad_ClientError(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: "server error"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
summary, err := GetStatus(context.Background(), nil, client, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, summary)
|
||||
assert.Contains(t, err.Error(), "pending tasks")
|
||||
}
|
||||
|
||||
// --- FormatStatus tests ---
|
||||
|
||||
func TestFormatStatus_Good_Empty(t *testing.T) {
|
||||
s := &StatusSummary{
|
||||
AllowanceRemaining: make(map[string]int64),
|
||||
}
|
||||
output := FormatStatus(s)
|
||||
assert.Contains(t, output, "Agents: 0")
|
||||
assert.Contains(t, output, "Tasks: 0 pending, 0 in progress")
|
||||
// No agent table rows when there are no agents — only the summary lines.
|
||||
assert.NotContains(t, output, "Status")
|
||||
}
|
||||
|
||||
func TestFormatStatus_Good_FullTable(t *testing.T) {
|
||||
s := &StatusSummary{
|
||||
Agents: []AgentInfo{
|
||||
{ID: "virgil", Status: AgentAvailable, CurrentLoad: 0, MaxLoad: 5},
|
||||
{ID: "charon", Status: AgentBusy, CurrentLoad: 3, MaxLoad: 5},
|
||||
{ID: "darbs", Status: AgentAvailable, CurrentLoad: 0, MaxLoad: 3},
|
||||
},
|
||||
PendingTasks: 5,
|
||||
InProgressTasks: 2,
|
||||
AllowanceRemaining: map[string]int64{
|
||||
"virgil": 45000,
|
||||
"charon": 12000,
|
||||
"darbs": -1,
|
||||
},
|
||||
}
|
||||
|
||||
output := FormatStatus(s)
|
||||
assert.Contains(t, output, "Agents: 3 (2 available, 1 busy)")
|
||||
assert.Contains(t, output, "Tasks: 5 pending, 2 in progress")
|
||||
assert.Contains(t, output, "virgil")
|
||||
assert.Contains(t, output, "available")
|
||||
assert.Contains(t, output, "45000 tokens")
|
||||
assert.Contains(t, output, "charon")
|
||||
assert.Contains(t, output, "busy")
|
||||
assert.Contains(t, output, "12000 tokens")
|
||||
assert.Contains(t, output, "darbs")
|
||||
assert.Contains(t, output, "unlimited")
|
||||
|
||||
// Verify deterministic sort order (agents sorted by ID).
|
||||
lines := strings.Split(output, "\n")
|
||||
var agentLines []string
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "charon") || strings.HasPrefix(line, "darbs") || strings.HasPrefix(line, "virgil") {
|
||||
agentLines = append(agentLines, line)
|
||||
}
|
||||
}
|
||||
require.Len(t, agentLines, 3)
|
||||
assert.True(t, strings.HasPrefix(agentLines[0], "charon"))
|
||||
assert.True(t, strings.HasPrefix(agentLines[1], "darbs"))
|
||||
assert.True(t, strings.HasPrefix(agentLines[2], "virgil"))
|
||||
}
|
||||
|
||||
func TestFormatStatus_Good_OfflineAgent(t *testing.T) {
|
||||
s := &StatusSummary{
|
||||
Agents: []AgentInfo{
|
||||
{ID: "offline-bot", Status: AgentOffline, CurrentLoad: 0, MaxLoad: 5},
|
||||
},
|
||||
AllowanceRemaining: map[string]int64{
|
||||
"offline-bot": 30000,
|
||||
},
|
||||
}
|
||||
|
||||
output := FormatStatus(s)
|
||||
assert.Contains(t, output, "1 offline")
|
||||
assert.Contains(t, output, "offline-bot")
|
||||
}
|
||||
|
||||
func TestFormatStatus_Good_UnlimitedMaxLoad(t *testing.T) {
|
||||
s := &StatusSummary{
|
||||
Agents: []AgentInfo{
|
||||
{ID: "unlimited", Status: AgentAvailable, CurrentLoad: 2, MaxLoad: 0},
|
||||
},
|
||||
AllowanceRemaining: map[string]int64{
|
||||
"unlimited": -1,
|
||||
},
|
||||
}
|
||||
|
||||
output := FormatStatus(s)
|
||||
assert.Contains(t, output, "2/-")
|
||||
assert.Contains(t, output, "unlimited")
|
||||
}
|
||||
|
||||
func TestFormatStatus_Good_UnknownAllowance(t *testing.T) {
|
||||
s := &StatusSummary{
|
||||
Agents: []AgentInfo{
|
||||
{ID: "mystery", Status: AgentAvailable, MaxLoad: 5},
|
||||
},
|
||||
AllowanceRemaining: make(map[string]int64),
|
||||
}
|
||||
|
||||
output := FormatStatus(s)
|
||||
assert.Contains(t, output, "unknown")
|
||||
}
|
||||
35
submit.go
Normal file
35
submit.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// SubmitTask creates a new task with the given parameters via the API client.
|
||||
// It validates that title is non-empty, sets CreatedAt to the current time,
|
||||
// and delegates creation to client.CreateTask.
|
||||
func SubmitTask(ctx context.Context, client *Client, title, description string, labels []string, priority TaskPriority) (*Task, error) {
|
||||
const op = "agentic.SubmitTask"
|
||||
|
||||
if title == "" {
|
||||
return nil, log.E(op, "title is required", nil)
|
||||
}
|
||||
|
||||
task := Task{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Labels: labels,
|
||||
Priority: priority,
|
||||
Status: StatusPending,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
created, err := client.CreateTask(ctx, task)
|
||||
if err != nil {
|
||||
return nil, log.E(op, "failed to create task", err)
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
134
submit_test.go
Normal file
134
submit_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- Client.CreateTask tests ---
|
||||
|
||||
func TestClient_CreateTask_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", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
var task Task
|
||||
err := json.NewDecoder(r.Body).Decode(&task)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "New feature", task.Title)
|
||||
assert.Equal(t, PriorityHigh, task.Priority)
|
||||
|
||||
// Return the task with an assigned ID.
|
||||
task.ID = "task-new-1"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(task)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
task := Task{
|
||||
Title: "New feature",
|
||||
Description: "Build something great",
|
||||
Priority: PriorityHigh,
|
||||
Labels: []string{"feature"},
|
||||
Status: StatusPending,
|
||||
}
|
||||
|
||||
created, err := client.CreateTask(context.Background(), task)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "task-new-1", created.ID)
|
||||
assert.Equal(t, "New feature", created.Title)
|
||||
assert.Equal(t, PriorityHigh, created.Priority)
|
||||
}
|
||||
|
||||
func TestClient_CreateTask_Bad_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(APIError{Message: "validation failed"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
task := Task{Title: "Bad task"}
|
||||
|
||||
created, err := client.CreateTask(context.Background(), task)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, created)
|
||||
assert.Contains(t, err.Error(), "validation failed")
|
||||
}
|
||||
|
||||
// --- SubmitTask tests ---
|
||||
|
||||
func TestSubmitTask_Good_AllFields(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var task Task
|
||||
err := json.NewDecoder(r.Body).Decode(&task)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Implement login", task.Title)
|
||||
assert.Equal(t, "OAuth2 login flow", task.Description)
|
||||
assert.Equal(t, []string{"auth", "frontend"}, task.Labels)
|
||||
assert.Equal(t, PriorityHigh, task.Priority)
|
||||
assert.Equal(t, StatusPending, task.Status)
|
||||
assert.False(t, task.CreatedAt.IsZero())
|
||||
|
||||
task.ID = "task-submit-1"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(task)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
created, err := SubmitTask(context.Background(), client, "Implement login", "OAuth2 login flow", []string{"auth", "frontend"}, PriorityHigh)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "task-submit-1", created.ID)
|
||||
assert.Equal(t, "Implement login", created.Title)
|
||||
}
|
||||
|
||||
func TestSubmitTask_Good_MinimalFields(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var task Task
|
||||
_ = json.NewDecoder(r.Body).Decode(&task)
|
||||
task.ID = "task-minimal"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(task)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
created, err := SubmitTask(context.Background(), client, "Simple task", "", nil, PriorityLow)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "task-minimal", created.ID)
|
||||
}
|
||||
|
||||
func TestSubmitTask_Bad_EmptyTitle(t *testing.T) {
|
||||
client := NewClient("https://api.example.com", "test-token")
|
||||
created, err := SubmitTask(context.Background(), client, "", "description", nil, PriorityMedium)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, created)
|
||||
assert.Contains(t, err.Error(), "title is required")
|
||||
}
|
||||
|
||||
func TestSubmitTask_Bad_ClientError(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")
|
||||
created, err := SubmitTask(context.Background(), client, "Good title", "", nil, PriorityMedium)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, created)
|
||||
assert.Contains(t, err.Error(), "create task")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue