refactor: extract agentic/ to standalone core/go-agentic module

Now lives at forge.lthn.ai/core/go-agentic.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-19 18:28:48 +00:00
parent 34d0f9ce41
commit f99ca10c6c
15 changed files with 0 additions and 3373 deletions

View file

@ -1,299 +0,0 @@
package agentic
import (
"sync"
"time"
)
// AllowanceStatus indicates the current state of an agent's quota.
type AllowanceStatus string
const (
// AllowanceOK indicates the agent has remaining quota.
AllowanceOK AllowanceStatus = "ok"
// AllowanceWarning indicates the agent is at 80%+ usage.
AllowanceWarning AllowanceStatus = "warning"
// AllowanceExceeded indicates the agent has exceeded its quota.
AllowanceExceeded AllowanceStatus = "exceeded"
)
// AgentAllowance defines the quota limits for a single agent.
type AgentAllowance struct {
// AgentID is the unique identifier for the agent.
AgentID string `json:"agent_id" yaml:"agent_id"`
// DailyTokenLimit is the maximum tokens (in+out) per 24h. 0 means unlimited.
DailyTokenLimit int64 `json:"daily_token_limit" yaml:"daily_token_limit"`
// DailyJobLimit is the maximum jobs per 24h. 0 means unlimited.
DailyJobLimit int `json:"daily_job_limit" yaml:"daily_job_limit"`
// ConcurrentJobs is the maximum simultaneous jobs. 0 means unlimited.
ConcurrentJobs int `json:"concurrent_jobs" yaml:"concurrent_jobs"`
// MaxJobDuration is the maximum job duration before kill. 0 means unlimited.
MaxJobDuration time.Duration `json:"max_job_duration" yaml:"max_job_duration"`
// ModelAllowlist restricts which models this agent can use. Empty means all.
ModelAllowlist []string `json:"model_allowlist,omitempty" yaml:"model_allowlist"`
}
// ModelQuota defines global per-model limits across all agents.
type ModelQuota struct {
// Model is the model identifier (e.g. "claude-sonnet-4-5-20250929").
Model string `json:"model" yaml:"model"`
// DailyTokenBudget is the total tokens across all agents per 24h.
DailyTokenBudget int64 `json:"daily_token_budget" yaml:"daily_token_budget"`
// HourlyRateLimit is the max requests per hour.
HourlyRateLimit int `json:"hourly_rate_limit" yaml:"hourly_rate_limit"`
// CostCeiling stops all usage if cumulative cost exceeds this (in cents).
CostCeiling int64 `json:"cost_ceiling" yaml:"cost_ceiling"`
}
// RepoLimit defines per-repository rate limits.
type RepoLimit struct {
// Repo is the repository identifier (e.g. "owner/repo").
Repo string `json:"repo" yaml:"repo"`
// MaxDailyPRs is the maximum PRs per day. 0 means unlimited.
MaxDailyPRs int `json:"max_daily_prs" yaml:"max_daily_prs"`
// MaxDailyIssues is the maximum issues per day. 0 means unlimited.
MaxDailyIssues int `json:"max_daily_issues" yaml:"max_daily_issues"`
// CooldownAfterFailure is the wait time after a failure before retrying.
CooldownAfterFailure time.Duration `json:"cooldown_after_failure" yaml:"cooldown_after_failure"`
}
// UsageRecord tracks an agent's current usage within a quota period.
type UsageRecord struct {
// AgentID is the agent this record belongs to.
AgentID string `json:"agent_id"`
// TokensUsed is the total tokens consumed in the current period.
TokensUsed int64 `json:"tokens_used"`
// JobsStarted is the total jobs started in the current period.
JobsStarted int `json:"jobs_started"`
// ActiveJobs is the number of currently running jobs.
ActiveJobs int `json:"active_jobs"`
// PeriodStart is when the current quota period began.
PeriodStart time.Time `json:"period_start"`
}
// QuotaCheckResult is the outcome of a pre-dispatch allowance check.
type QuotaCheckResult struct {
// Allowed indicates whether the agent may proceed.
Allowed bool `json:"allowed"`
// Status is the current allowance state.
Status AllowanceStatus `json:"status"`
// Remaining is the number of tokens remaining in the period.
RemainingTokens int64 `json:"remaining_tokens"`
// RemainingJobs is the number of jobs remaining in the period.
RemainingJobs int `json:"remaining_jobs"`
// Reason explains why the check failed (if !Allowed).
Reason string `json:"reason,omitempty"`
}
// QuotaEvent represents a change in quota usage, used for recovery.
type QuotaEvent string
const (
// QuotaEventJobStarted deducts quota when a job begins.
QuotaEventJobStarted QuotaEvent = "job_started"
// QuotaEventJobCompleted deducts nothing (already counted).
QuotaEventJobCompleted QuotaEvent = "job_completed"
// QuotaEventJobFailed returns 50% of token quota.
QuotaEventJobFailed QuotaEvent = "job_failed"
// QuotaEventJobCancelled returns 100% of token quota.
QuotaEventJobCancelled QuotaEvent = "job_cancelled"
)
// UsageReport is emitted by the agent runner to report token consumption.
type UsageReport struct {
// AgentID is the agent that consumed tokens.
AgentID string `json:"agent_id"`
// JobID identifies the specific job.
JobID string `json:"job_id"`
// Model is the model used.
Model string `json:"model"`
// TokensIn is the number of input tokens consumed.
TokensIn int64 `json:"tokens_in"`
// TokensOut is the number of output tokens consumed.
TokensOut int64 `json:"tokens_out"`
// Event is the type of quota event.
Event QuotaEvent `json:"event"`
// Timestamp is when the usage occurred.
Timestamp time.Time `json:"timestamp"`
}
// AllowanceStore is the interface for persisting and querying allowance data.
// Implementations may use Redis, SQLite, or any backing store.
type AllowanceStore interface {
// GetAllowance returns the quota limits for an agent.
GetAllowance(agentID string) (*AgentAllowance, error)
// SetAllowance persists quota limits for an agent.
SetAllowance(a *AgentAllowance) error
// GetUsage returns the current usage record for an agent.
GetUsage(agentID string) (*UsageRecord, error)
// IncrementUsage atomically adds to an agent's usage counters.
IncrementUsage(agentID string, tokens int64, jobs int) error
// DecrementActiveJobs reduces the active job count by 1.
DecrementActiveJobs(agentID string) error
// ReturnTokens adds tokens back to the agent's remaining quota.
ReturnTokens(agentID string, tokens int64) error
// ResetUsage clears usage counters for an agent (daily reset).
ResetUsage(agentID string) error
// GetModelQuota returns global limits for a model.
GetModelQuota(model string) (*ModelQuota, error)
// GetModelUsage returns current token usage for a model.
GetModelUsage(model string) (int64, error)
// IncrementModelUsage atomically adds to a model's usage counter.
IncrementModelUsage(model string, tokens int64) error
}
// MemoryStore is an in-memory AllowanceStore for testing and single-node use.
type MemoryStore struct {
mu sync.RWMutex
allowances map[string]*AgentAllowance
usage map[string]*UsageRecord
modelQuotas map[string]*ModelQuota
modelUsage map[string]int64
}
// NewMemoryStore creates a new in-memory allowance store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
allowances: make(map[string]*AgentAllowance),
usage: make(map[string]*UsageRecord),
modelQuotas: make(map[string]*ModelQuota),
modelUsage: make(map[string]int64),
}
}
// GetAllowance returns the quota limits for an agent.
func (m *MemoryStore) GetAllowance(agentID string) (*AgentAllowance, error) {
m.mu.RLock()
defer m.mu.RUnlock()
a, ok := m.allowances[agentID]
if !ok {
return nil, &APIError{Code: 404, Message: "allowance not found for agent: " + agentID}
}
cp := *a
return &cp, nil
}
// SetAllowance persists quota limits for an agent.
func (m *MemoryStore) SetAllowance(a *AgentAllowance) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *a
m.allowances[a.AgentID] = &cp
return nil
}
// GetUsage returns the current usage record for an agent.
func (m *MemoryStore) GetUsage(agentID string) (*UsageRecord, error) {
m.mu.RLock()
defer m.mu.RUnlock()
u, ok := m.usage[agentID]
if !ok {
return &UsageRecord{
AgentID: agentID,
PeriodStart: startOfDay(time.Now().UTC()),
}, nil
}
cp := *u
return &cp, nil
}
// IncrementUsage atomically adds to an agent's usage counters.
func (m *MemoryStore) IncrementUsage(agentID string, tokens int64, jobs int) error {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.usage[agentID]
if !ok {
u = &UsageRecord{
AgentID: agentID,
PeriodStart: startOfDay(time.Now().UTC()),
}
m.usage[agentID] = u
}
u.TokensUsed += tokens
u.JobsStarted += jobs
if jobs > 0 {
u.ActiveJobs += jobs
}
return nil
}
// DecrementActiveJobs reduces the active job count by 1.
func (m *MemoryStore) DecrementActiveJobs(agentID string) error {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.usage[agentID]
if !ok {
return nil
}
if u.ActiveJobs > 0 {
u.ActiveJobs--
}
return nil
}
// ReturnTokens adds tokens back to the agent's remaining quota.
func (m *MemoryStore) ReturnTokens(agentID string, tokens int64) error {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.usage[agentID]
if !ok {
return nil
}
u.TokensUsed -= tokens
if u.TokensUsed < 0 {
u.TokensUsed = 0
}
return nil
}
// ResetUsage clears usage counters for an agent.
func (m *MemoryStore) ResetUsage(agentID string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.usage[agentID] = &UsageRecord{
AgentID: agentID,
PeriodStart: startOfDay(time.Now().UTC()),
}
return nil
}
// GetModelQuota returns global limits for a model.
func (m *MemoryStore) GetModelQuota(model string) (*ModelQuota, error) {
m.mu.RLock()
defer m.mu.RUnlock()
q, ok := m.modelQuotas[model]
if !ok {
return nil, &APIError{Code: 404, Message: "model quota not found: " + model}
}
cp := *q
return &cp, nil
}
// GetModelUsage returns current token usage for a model.
func (m *MemoryStore) GetModelUsage(model string) (int64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.modelUsage[model], nil
}
// IncrementModelUsage atomically adds to a model's usage counter.
func (m *MemoryStore) IncrementModelUsage(model string, tokens int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.modelUsage[model] += tokens
return nil
}
// SetModelQuota sets global limits for a model (used in testing).
func (m *MemoryStore) SetModelQuota(q *ModelQuota) {
m.mu.Lock()
defer m.mu.Unlock()
cp := *q
m.modelQuotas[q.Model] = &cp
}
// startOfDay returns midnight UTC for the given time.
func startOfDay(t time.Time) time.Time {
y, mo, d := t.Date()
return time.Date(y, mo, d, 0, 0, 0, 0, time.UTC)
}

View file

@ -1,176 +0,0 @@
package agentic
import (
"slices"
"forge.lthn.ai/core/go/pkg/log"
)
// AllowanceService enforces agent quota limits. It provides pre-dispatch checks,
// runtime usage recording, and quota recovery for failed/cancelled jobs.
type AllowanceService struct {
store AllowanceStore
}
// NewAllowanceService creates a new AllowanceService with the given store.
func NewAllowanceService(store AllowanceStore) *AllowanceService {
return &AllowanceService{store: store}
}
// Check performs a pre-dispatch allowance check for the given agent and model.
// It verifies daily token limits, daily job limits, concurrent job limits, and
// model allowlists. Returns a QuotaCheckResult indicating whether the agent may proceed.
func (s *AllowanceService) Check(agentID, model string) (*QuotaCheckResult, error) {
const op = "AllowanceService.Check"
allowance, err := s.store.GetAllowance(agentID)
if err != nil {
return nil, log.E(op, "failed to get allowance", err)
}
usage, err := s.store.GetUsage(agentID)
if err != nil {
return nil, log.E(op, "failed to get usage", err)
}
result := &QuotaCheckResult{
Allowed: true,
Status: AllowanceOK,
RemainingTokens: -1, // unlimited
RemainingJobs: -1, // unlimited
}
// Check model allowlist
if len(allowance.ModelAllowlist) > 0 && model != "" {
if !slices.Contains(allowance.ModelAllowlist, model) {
result.Allowed = false
result.Status = AllowanceExceeded
result.Reason = "model not in allowlist: " + model
return result, nil
}
}
// Check daily token limit
if allowance.DailyTokenLimit > 0 {
remaining := allowance.DailyTokenLimit - usage.TokensUsed
result.RemainingTokens = remaining
if remaining <= 0 {
result.Allowed = false
result.Status = AllowanceExceeded
result.Reason = "daily token limit exceeded"
return result, nil
}
ratio := float64(usage.TokensUsed) / float64(allowance.DailyTokenLimit)
if ratio >= 0.8 {
result.Status = AllowanceWarning
}
}
// Check daily job limit
if allowance.DailyJobLimit > 0 {
remaining := allowance.DailyJobLimit - usage.JobsStarted
result.RemainingJobs = remaining
if remaining <= 0 {
result.Allowed = false
result.Status = AllowanceExceeded
result.Reason = "daily job limit exceeded"
return result, nil
}
}
// Check concurrent jobs
if allowance.ConcurrentJobs > 0 && usage.ActiveJobs >= allowance.ConcurrentJobs {
result.Allowed = false
result.Status = AllowanceExceeded
result.Reason = "concurrent job limit reached"
return result, nil
}
// Check global model quota
if model != "" {
modelQuota, err := s.store.GetModelQuota(model)
if err == nil && modelQuota.DailyTokenBudget > 0 {
modelUsage, err := s.store.GetModelUsage(model)
if err == nil && modelUsage >= modelQuota.DailyTokenBudget {
result.Allowed = false
result.Status = AllowanceExceeded
result.Reason = "global model token budget exceeded for: " + model
return result, nil
}
}
}
return result, nil
}
// RecordUsage processes a usage report, updating counters and handling quota recovery.
func (s *AllowanceService) RecordUsage(report UsageReport) error {
const op = "AllowanceService.RecordUsage"
totalTokens := report.TokensIn + report.TokensOut
switch report.Event {
case QuotaEventJobStarted:
if err := s.store.IncrementUsage(report.AgentID, 0, 1); err != nil {
return log.E(op, "failed to increment job count", err)
}
case QuotaEventJobCompleted:
if err := s.store.IncrementUsage(report.AgentID, totalTokens, 0); err != nil {
return log.E(op, "failed to record token usage", err)
}
if err := s.store.DecrementActiveJobs(report.AgentID); err != nil {
return log.E(op, "failed to decrement active jobs", err)
}
// Record model-level usage
if report.Model != "" {
if err := s.store.IncrementModelUsage(report.Model, totalTokens); err != nil {
return log.E(op, "failed to record model usage", err)
}
}
case QuotaEventJobFailed:
// Record partial usage, return 50% of tokens
if err := s.store.IncrementUsage(report.AgentID, totalTokens, 0); err != nil {
return log.E(op, "failed to record token usage", err)
}
if err := s.store.DecrementActiveJobs(report.AgentID); err != nil {
return log.E(op, "failed to decrement active jobs", err)
}
returnAmount := totalTokens / 2
if returnAmount > 0 {
if err := s.store.ReturnTokens(report.AgentID, returnAmount); err != nil {
return log.E(op, "failed to return tokens", err)
}
}
// Still record model-level usage (net of return)
if report.Model != "" {
if err := s.store.IncrementModelUsage(report.Model, totalTokens-returnAmount); err != nil {
return log.E(op, "failed to record model usage", err)
}
}
case QuotaEventJobCancelled:
// Return 100% of tokens
if err := s.store.DecrementActiveJobs(report.AgentID); err != nil {
return log.E(op, "failed to decrement active jobs", err)
}
if totalTokens > 0 {
if err := s.store.ReturnTokens(report.AgentID, totalTokens); err != nil {
return log.E(op, "failed to return tokens", err)
}
}
// No model-level usage for cancelled jobs
}
return nil
}
// ResetAgent clears daily usage counters for the given agent (midnight reset).
func (s *AllowanceService) ResetAgent(agentID string) error {
const op = "AllowanceService.ResetAgent"
if err := s.store.ResetUsage(agentID); err != nil {
return log.E(op, "failed to reset usage", err)
}
return nil
}

View file

@ -1,407 +0,0 @@
package agentic
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- MemoryStore tests ---
func TestMemoryStore_SetGetAllowance_Good(t *testing.T) {
store := NewMemoryStore()
a := &AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 100000,
DailyJobLimit: 10,
ConcurrentJobs: 2,
MaxJobDuration: 30 * time.Minute,
ModelAllowlist: []string{"claude-sonnet-4-5-20250929"},
}
err := store.SetAllowance(a)
require.NoError(t, err)
got, err := store.GetAllowance("agent-1")
require.NoError(t, err)
assert.Equal(t, a.AgentID, got.AgentID)
assert.Equal(t, a.DailyTokenLimit, got.DailyTokenLimit)
assert.Equal(t, a.DailyJobLimit, got.DailyJobLimit)
assert.Equal(t, a.ConcurrentJobs, got.ConcurrentJobs)
assert.Equal(t, a.ModelAllowlist, got.ModelAllowlist)
}
func TestMemoryStore_GetAllowance_Bad_NotFound(t *testing.T) {
store := NewMemoryStore()
_, err := store.GetAllowance("nonexistent")
require.Error(t, err)
}
func TestMemoryStore_IncrementUsage_Good(t *testing.T) {
store := NewMemoryStore()
err := store.IncrementUsage("agent-1", 5000, 1)
require.NoError(t, err)
usage, err := store.GetUsage("agent-1")
require.NoError(t, err)
assert.Equal(t, int64(5000), usage.TokensUsed)
assert.Equal(t, 1, usage.JobsStarted)
assert.Equal(t, 1, usage.ActiveJobs)
}
func TestMemoryStore_DecrementActiveJobs_Good(t *testing.T) {
store := NewMemoryStore()
_ = store.IncrementUsage("agent-1", 0, 2)
_ = store.DecrementActiveJobs("agent-1")
usage, _ := store.GetUsage("agent-1")
assert.Equal(t, 1, usage.ActiveJobs)
}
func TestMemoryStore_DecrementActiveJobs_Good_FloorAtZero(t *testing.T) {
store := NewMemoryStore()
_ = store.DecrementActiveJobs("agent-1") // no-op, no usage record
_ = store.IncrementUsage("agent-1", 0, 0)
_ = store.DecrementActiveJobs("agent-1") // should stay at 0
usage, _ := store.GetUsage("agent-1")
assert.Equal(t, 0, usage.ActiveJobs)
}
func TestMemoryStore_ReturnTokens_Good(t *testing.T) {
store := NewMemoryStore()
_ = store.IncrementUsage("agent-1", 10000, 0)
err := store.ReturnTokens("agent-1", 5000)
require.NoError(t, err)
usage, _ := store.GetUsage("agent-1")
assert.Equal(t, int64(5000), usage.TokensUsed)
}
func TestMemoryStore_ReturnTokens_Good_FloorAtZero(t *testing.T) {
store := NewMemoryStore()
_ = store.IncrementUsage("agent-1", 1000, 0)
_ = store.ReturnTokens("agent-1", 5000) // more than used
usage, _ := store.GetUsage("agent-1")
assert.Equal(t, int64(0), usage.TokensUsed)
}
func TestMemoryStore_ResetUsage_Good(t *testing.T) {
store := NewMemoryStore()
_ = store.IncrementUsage("agent-1", 50000, 5)
err := store.ResetUsage("agent-1")
require.NoError(t, err)
usage, _ := store.GetUsage("agent-1")
assert.Equal(t, int64(0), usage.TokensUsed)
assert.Equal(t, 0, usage.JobsStarted)
assert.Equal(t, 0, usage.ActiveJobs)
}
func TestMemoryStore_ModelUsage_Good(t *testing.T) {
store := NewMemoryStore()
_ = store.IncrementModelUsage("claude-sonnet", 10000)
_ = store.IncrementModelUsage("claude-sonnet", 5000)
usage, err := store.GetModelUsage("claude-sonnet")
require.NoError(t, err)
assert.Equal(t, int64(15000), usage)
}
// --- AllowanceService.Check tests ---
func TestAllowanceServiceCheck_Good(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 100000,
DailyJobLimit: 10,
ConcurrentJobs: 2,
})
result, err := svc.Check("agent-1", "")
require.NoError(t, err)
assert.True(t, result.Allowed)
assert.Equal(t, AllowanceOK, result.Status)
assert.Equal(t, int64(100000), result.RemainingTokens)
assert.Equal(t, 10, result.RemainingJobs)
}
func TestAllowanceServiceCheck_Good_Warning(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 100000,
})
_ = store.IncrementUsage("agent-1", 85000, 0)
result, err := svc.Check("agent-1", "")
require.NoError(t, err)
assert.True(t, result.Allowed)
assert.Equal(t, AllowanceWarning, result.Status)
assert.Equal(t, int64(15000), result.RemainingTokens)
}
func TestAllowanceServiceCheck_Bad_TokenLimitExceeded(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 100000,
})
_ = store.IncrementUsage("agent-1", 100001, 0)
result, err := svc.Check("agent-1", "")
require.NoError(t, err)
assert.False(t, result.Allowed)
assert.Equal(t, AllowanceExceeded, result.Status)
assert.Contains(t, result.Reason, "daily token limit")
}
func TestAllowanceServiceCheck_Bad_JobLimitExceeded(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
DailyJobLimit: 5,
})
_ = store.IncrementUsage("agent-1", 0, 5)
result, err := svc.Check("agent-1", "")
require.NoError(t, err)
assert.False(t, result.Allowed)
assert.Contains(t, result.Reason, "daily job limit")
}
func TestAllowanceServiceCheck_Bad_ConcurrentLimitReached(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
ConcurrentJobs: 1,
})
_ = store.IncrementUsage("agent-1", 0, 1) // 1 active job
result, err := svc.Check("agent-1", "")
require.NoError(t, err)
assert.False(t, result.Allowed)
assert.Contains(t, result.Reason, "concurrent job limit")
}
func TestAllowanceServiceCheck_Bad_ModelNotInAllowlist(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
ModelAllowlist: []string{"claude-sonnet-4-5-20250929"},
})
result, err := svc.Check("agent-1", "claude-opus-4-6")
require.NoError(t, err)
assert.False(t, result.Allowed)
assert.Contains(t, result.Reason, "model not in allowlist")
}
func TestAllowanceServiceCheck_Good_ModelInAllowlist(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
ModelAllowlist: []string{"claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001"},
})
result, err := svc.Check("agent-1", "claude-sonnet-4-5-20250929")
require.NoError(t, err)
assert.True(t, result.Allowed)
}
func TestAllowanceServiceCheck_Good_EmptyModelSkipsCheck(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
ModelAllowlist: []string{"claude-sonnet-4-5-20250929"},
})
result, err := svc.Check("agent-1", "")
require.NoError(t, err)
assert.True(t, result.Allowed)
}
func TestAllowanceServiceCheck_Bad_GlobalModelBudgetExceeded(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
})
store.SetModelQuota(&ModelQuota{
Model: "claude-opus-4-6",
DailyTokenBudget: 500000,
})
_ = store.IncrementModelUsage("claude-opus-4-6", 500001)
result, err := svc.Check("agent-1", "claude-opus-4-6")
require.NoError(t, err)
assert.False(t, result.Allowed)
assert.Contains(t, result.Reason, "global model token budget")
}
func TestAllowanceServiceCheck_Bad_NoAllowance(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_, err := svc.Check("unknown-agent", "")
require.Error(t, err)
}
// --- AllowanceService.RecordUsage tests ---
func TestAllowanceServiceRecordUsage_Good_JobStarted(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
err := svc.RecordUsage(UsageReport{
AgentID: "agent-1",
JobID: "job-1",
Event: QuotaEventJobStarted,
})
require.NoError(t, err)
usage, _ := store.GetUsage("agent-1")
assert.Equal(t, 1, usage.JobsStarted)
assert.Equal(t, 1, usage.ActiveJobs)
assert.Equal(t, int64(0), usage.TokensUsed)
}
func TestAllowanceServiceRecordUsage_Good_JobCompleted(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
// Start a job first
_ = svc.RecordUsage(UsageReport{
AgentID: "agent-1",
JobID: "job-1",
Event: QuotaEventJobStarted,
})
err := svc.RecordUsage(UsageReport{
AgentID: "agent-1",
JobID: "job-1",
Model: "claude-sonnet",
TokensIn: 1000,
TokensOut: 500,
Event: QuotaEventJobCompleted,
})
require.NoError(t, err)
usage, _ := store.GetUsage("agent-1")
assert.Equal(t, int64(1500), usage.TokensUsed)
assert.Equal(t, 0, usage.ActiveJobs)
modelUsage, _ := store.GetModelUsage("claude-sonnet")
assert.Equal(t, int64(1500), modelUsage)
}
func TestAllowanceServiceRecordUsage_Good_JobFailed_ReturnsHalf(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = svc.RecordUsage(UsageReport{
AgentID: "agent-1",
JobID: "job-1",
Event: QuotaEventJobStarted,
})
err := svc.RecordUsage(UsageReport{
AgentID: "agent-1",
JobID: "job-1",
Model: "claude-sonnet",
TokensIn: 1000,
TokensOut: 1000,
Event: QuotaEventJobFailed,
})
require.NoError(t, err)
usage, _ := store.GetUsage("agent-1")
// 2000 tokens used, 1000 returned (50%) = 1000 net
assert.Equal(t, int64(1000), usage.TokensUsed)
assert.Equal(t, 0, usage.ActiveJobs)
// Model sees net usage (2000 - 1000 = 1000)
modelUsage, _ := store.GetModelUsage("claude-sonnet")
assert.Equal(t, int64(1000), modelUsage)
}
func TestAllowanceServiceRecordUsage_Good_JobCancelled_ReturnsAll(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.IncrementUsage("agent-1", 5000, 1) // simulate pre-existing usage
err := svc.RecordUsage(UsageReport{
AgentID: "agent-1",
JobID: "job-1",
TokensIn: 500,
TokensOut: 500,
Event: QuotaEventJobCancelled,
})
require.NoError(t, err)
usage, _ := store.GetUsage("agent-1")
// 5000 pre-existing - 1000 returned = 4000
assert.Equal(t, int64(4000), usage.TokensUsed)
assert.Equal(t, 0, usage.ActiveJobs)
}
// --- AllowanceService.ResetAgent tests ---
func TestAllowanceServiceResetAgent_Good(t *testing.T) {
store := NewMemoryStore()
svc := NewAllowanceService(store)
_ = store.IncrementUsage("agent-1", 50000, 5)
err := svc.ResetAgent("agent-1")
require.NoError(t, err)
usage, _ := store.GetUsage("agent-1")
assert.Equal(t, int64(0), usage.TokensUsed)
assert.Equal(t, 0, usage.JobsStarted)
}
// --- startOfDay helper test ---
func TestStartOfDay_Good(t *testing.T) {
input := time.Date(2026, 2, 10, 15, 30, 45, 0, time.UTC)
expected := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC)
assert.Equal(t, expected, startOfDay(input))
}
// --- AllowanceStatus tests ---
func TestAllowanceStatus_Good_Values(t *testing.T) {
assert.Equal(t, AllowanceStatus("ok"), AllowanceOK)
assert.Equal(t, AllowanceStatus("warning"), AllowanceWarning)
assert.Equal(t, AllowanceStatus("exceeded"), AllowanceExceeded)
}

View file

@ -1,322 +0,0 @@
package agentic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/log"
)
// 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, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
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 tasks []Task
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
return nil, log.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, log.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, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
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 task Task
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
return nil, log.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, log.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, log.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, 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)
}
// Read body once to allow multiple decode attempts
bodyData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, log.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, log.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 log.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 log.E(op, "failed to marshal update", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
if err != nil {
return 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 log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return log.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 log.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 log.E(op, "failed to marshal result", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return 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 log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return log.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),
}
}
// 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 log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return log.E(op, fmt.Sprintf("server returned status %d", resp.StatusCode), nil)
}
return nil
}

View file

@ -1,356 +0,0 @@
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)
}

View file

@ -1,338 +0,0 @@
// Package agentic provides AI collaboration features for task management.
package agentic
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"forge.lthn.ai/core/go/pkg/log"
)
// PROptions contains options for creating a pull request.
type PROptions struct {
// Title is the PR title.
Title string `json:"title"`
// Body is the PR description.
Body string `json:"body"`
// Draft marks the PR as a draft.
Draft bool `json:"draft"`
// Labels are labels to add to the PR.
Labels []string `json:"labels"`
// Base is the base branch (defaults to main).
Base string `json:"base"`
}
// AutoCommit creates a git commit with a task reference.
// The commit message follows the format:
//
// feat(scope): description
//
// Task: #123
// Co-Authored-By: Claude <noreply@anthropic.com>
func AutoCommit(ctx context.Context, task *Task, dir string, message string) error {
const op = "agentic.AutoCommit"
if task == nil {
return log.E(op, "task is required", nil)
}
if message == "" {
return log.E(op, "commit message is required", nil)
}
// Build full commit message
fullMessage := buildCommitMessage(task, message)
// Stage all changes
if _, err := runGitCommandCtx(ctx, dir, "add", "-A"); err != nil {
return log.E(op, "failed to stage changes", err)
}
// Create commit
if _, err := runGitCommandCtx(ctx, dir, "commit", "-m", fullMessage); err != nil {
return log.E(op, "failed to create commit", err)
}
return nil
}
// buildCommitMessage formats a commit message with task reference.
func buildCommitMessage(task *Task, message string) string {
var sb strings.Builder
// Write the main message
sb.WriteString(message)
sb.WriteString("\n\n")
// Add task reference
sb.WriteString("Task: #")
sb.WriteString(task.ID)
sb.WriteString("\n")
// Add co-author
sb.WriteString("Co-Authored-By: Claude <noreply@anthropic.com>\n")
return sb.String()
}
// CreatePR creates a pull request using the gh CLI.
func CreatePR(ctx context.Context, task *Task, dir string, opts PROptions) (string, error) {
const op = "agentic.CreatePR"
if task == nil {
return "", log.E(op, "task is required", nil)
}
// Build title if not provided
title := opts.Title
if title == "" {
title = task.Title
}
// Build body if not provided
body := opts.Body
if body == "" {
body = buildPRBody(task)
}
// Build gh command arguments
args := []string{"pr", "create", "--title", title, "--body", body}
if opts.Draft {
args = append(args, "--draft")
}
if opts.Base != "" {
args = append(args, "--base", opts.Base)
}
for _, label := range opts.Labels {
args = append(args, "--label", label)
}
// Run gh pr create
output, err := runCommandCtx(ctx, dir, "gh", args...)
if err != nil {
return "", log.E(op, "failed to create PR", err)
}
// Extract PR URL from output
prURL := strings.TrimSpace(output)
return prURL, nil
}
// buildPRBody creates a PR body from task details.
func buildPRBody(task *Task) string {
var sb strings.Builder
sb.WriteString("## Summary\n\n")
sb.WriteString(task.Description)
sb.WriteString("\n\n")
sb.WriteString("## Task Reference\n\n")
sb.WriteString("- Task ID: #")
sb.WriteString(task.ID)
sb.WriteString("\n")
sb.WriteString("- Priority: ")
sb.WriteString(string(task.Priority))
sb.WriteString("\n")
if len(task.Labels) > 0 {
sb.WriteString("- Labels: ")
sb.WriteString(strings.Join(task.Labels, ", "))
sb.WriteString("\n")
}
sb.WriteString("\n---\n")
sb.WriteString("Generated with AI assistance\n")
return sb.String()
}
// SyncStatus syncs the task status back to the agentic service.
func SyncStatus(ctx context.Context, client *Client, task *Task, update TaskUpdate) error {
const op = "agentic.SyncStatus"
if client == nil {
return log.E(op, "client is required", nil)
}
if task == nil {
return log.E(op, "task is required", nil)
}
return client.UpdateTask(ctx, task.ID, update)
}
// CommitAndSync commits changes and syncs task status.
func CommitAndSync(ctx context.Context, client *Client, task *Task, dir string, message string, progress int) error {
const op = "agentic.CommitAndSync"
// Create commit
if err := AutoCommit(ctx, task, dir, message); err != nil {
return log.E(op, "failed to commit", err)
}
// Sync status if client provided
if client != nil {
update := TaskUpdate{
Status: StatusInProgress,
Progress: progress,
Notes: "Committed: " + message,
}
if err := SyncStatus(ctx, client, task, update); err != nil {
// Log but don't fail on sync errors
return log.E(op, "commit succeeded but sync failed", err)
}
}
return nil
}
// PushChanges pushes committed changes to the remote.
func PushChanges(ctx context.Context, dir string) error {
const op = "agentic.PushChanges"
_, err := runGitCommandCtx(ctx, dir, "push")
if err != nil {
return log.E(op, "failed to push changes", err)
}
return nil
}
// CreateBranch creates a new branch for the task.
func CreateBranch(ctx context.Context, task *Task, dir string) (string, error) {
const op = "agentic.CreateBranch"
if task == nil {
return "", log.E(op, "task is required", nil)
}
// Generate branch name from task
branchName := generateBranchName(task)
// Create and checkout branch
_, err := runGitCommandCtx(ctx, dir, "checkout", "-b", branchName)
if err != nil {
return "", log.E(op, "failed to create branch", err)
}
return branchName, nil
}
// generateBranchName creates a branch name from task details.
func generateBranchName(task *Task) string {
// Determine prefix based on labels
prefix := "feat"
for _, label := range task.Labels {
switch strings.ToLower(label) {
case "bug", "bugfix", "fix":
prefix = "fix"
case "docs", "documentation":
prefix = "docs"
case "refactor":
prefix = "refactor"
case "test", "tests":
prefix = "test"
case "chore":
prefix = "chore"
}
}
// Sanitize title for branch name
title := strings.ToLower(task.Title)
title = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
if r == ' ' || r == '-' || r == '_' {
return '-'
}
return -1
}, title)
// Remove consecutive dashes
for strings.Contains(title, "--") {
title = strings.ReplaceAll(title, "--", "-")
}
title = strings.Trim(title, "-")
// Truncate if too long
if len(title) > 40 {
title = title[:40]
title = strings.TrimRight(title, "-")
}
return fmt.Sprintf("%s/%s-%s", prefix, task.ID, title)
}
// runGitCommandCtx runs a git command with context.
func runGitCommandCtx(ctx context.Context, dir string, args ...string) (string, error) {
return runCommandCtx(ctx, dir, "git", args...)
}
// runCommandCtx runs an arbitrary command with context.
func runCommandCtx(ctx context.Context, dir string, command string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, command, args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return "", fmt.Errorf("%w: %s", err, stderr.String())
}
return "", err
}
return stdout.String(), nil
}
// GetCurrentBranch returns the current git branch name.
func GetCurrentBranch(ctx context.Context, dir string) (string, error) {
const op = "agentic.GetCurrentBranch"
output, err := runGitCommandCtx(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return "", log.E(op, "failed to get current branch", err)
}
return strings.TrimSpace(output), nil
}
// HasUncommittedChanges checks if there are uncommitted changes.
func HasUncommittedChanges(ctx context.Context, dir string) (bool, error) {
const op = "agentic.HasUncommittedChanges"
output, err := runGitCommandCtx(ctx, dir, "status", "--porcelain")
if err != nil {
return false, log.E(op, "failed to get git status", err)
}
return strings.TrimSpace(output) != "", nil
}
// GetDiff returns the current diff for staged and unstaged changes.
func GetDiff(ctx context.Context, dir string, staged bool) (string, error) {
const op = "agentic.GetDiff"
args := []string{"diff"}
if staged {
args = append(args, "--staged")
}
output, err := runGitCommandCtx(ctx, dir, args...)
if err != nil {
return "", log.E(op, "failed to get diff", err)
}
return output, nil
}

View file

@ -1,199 +0,0 @@
package agentic
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildCommitMessage(t *testing.T) {
task := &Task{
ID: "ABC123",
Title: "Test Task",
}
message := buildCommitMessage(task, "add new feature")
assert.Contains(t, message, "add new feature")
assert.Contains(t, message, "Task: #ABC123")
assert.Contains(t, message, "Co-Authored-By: Claude <noreply@anthropic.com>")
}
func TestBuildPRBody(t *testing.T) {
task := &Task{
ID: "PR-456",
Title: "Add authentication",
Description: "Implement user authentication with OAuth2",
Priority: PriorityHigh,
Labels: []string{"enhancement", "security"},
}
body := buildPRBody(task)
assert.Contains(t, body, "## Summary")
assert.Contains(t, body, "Implement user authentication with OAuth2")
assert.Contains(t, body, "## Task Reference")
assert.Contains(t, body, "Task ID: #PR-456")
assert.Contains(t, body, "Priority: high")
assert.Contains(t, body, "Labels: enhancement, security")
assert.Contains(t, body, "Generated with AI assistance")
}
func TestBuildPRBody_NoLabels(t *testing.T) {
task := &Task{
ID: "PR-789",
Title: "Fix bug",
Description: "Fix the login bug",
Priority: PriorityMedium,
Labels: nil,
}
body := buildPRBody(task)
assert.Contains(t, body, "## Summary")
assert.Contains(t, body, "Fix the login bug")
assert.NotContains(t, body, "Labels:")
}
func TestGenerateBranchName(t *testing.T) {
tests := []struct {
name string
task *Task
expected string
}{
{
name: "feature task",
task: &Task{
ID: "123",
Title: "Add user authentication",
Labels: []string{"enhancement"},
},
expected: "feat/123-add-user-authentication",
},
{
name: "bug fix task",
task: &Task{
ID: "456",
Title: "Fix login error",
Labels: []string{"bug"},
},
expected: "fix/456-fix-login-error",
},
{
name: "docs task",
task: &Task{
ID: "789",
Title: "Update README",
Labels: []string{"documentation"},
},
expected: "docs/789-update-readme",
},
{
name: "refactor task",
task: &Task{
ID: "101",
Title: "Refactor auth module",
Labels: []string{"refactor"},
},
expected: "refactor/101-refactor-auth-module",
},
{
name: "test task",
task: &Task{
ID: "202",
Title: "Add unit tests",
Labels: []string{"test"},
},
expected: "test/202-add-unit-tests",
},
{
name: "chore task",
task: &Task{
ID: "303",
Title: "Update dependencies",
Labels: []string{"chore"},
},
expected: "chore/303-update-dependencies",
},
{
name: "long title truncated",
task: &Task{
ID: "404",
Title: "This is a very long title that should be truncated to fit the branch name limit",
Labels: nil,
},
expected: "feat/404-this-is-a-very-long-title-that-should-be",
},
{
name: "special characters removed",
task: &Task{
ID: "505",
Title: "Fix: user's auth (OAuth2) [important]",
Labels: nil,
},
expected: "feat/505-fix-users-auth-oauth2-important",
},
{
name: "no labels defaults to feat",
task: &Task{
ID: "606",
Title: "New feature",
Labels: nil,
},
expected: "feat/606-new-feature",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := generateBranchName(tt.task)
assert.Equal(t, tt.expected, result)
})
}
}
func TestAutoCommit_Bad_NilTask(t *testing.T) {
err := AutoCommit(context.TODO(), nil, ".", "test message")
assert.Error(t, err)
assert.Contains(t, err.Error(), "task is required")
}
func TestAutoCommit_Bad_EmptyMessage(t *testing.T) {
task := &Task{ID: "123", Title: "Test"}
err := AutoCommit(context.TODO(), task, ".", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "commit message is required")
}
func TestSyncStatus_Bad_NilClient(t *testing.T) {
task := &Task{ID: "123", Title: "Test"}
update := TaskUpdate{Status: StatusInProgress}
err := SyncStatus(context.TODO(), nil, task, update)
assert.Error(t, err)
assert.Contains(t, err.Error(), "client is required")
}
func TestSyncStatus_Bad_NilTask(t *testing.T) {
client := &Client{BaseURL: "http://test"}
update := TaskUpdate{Status: StatusInProgress}
err := SyncStatus(context.TODO(), client, nil, update)
assert.Error(t, err)
assert.Contains(t, err.Error(), "task is required")
}
func TestCreateBranch_Bad_NilTask(t *testing.T) {
branch, err := CreateBranch(context.TODO(), nil, ".")
assert.Error(t, err)
assert.Empty(t, branch)
assert.Contains(t, err.Error(), "task is required")
}
func TestCreatePR_Bad_NilTask(t *testing.T) {
url, err := CreatePR(context.TODO(), nil, ".", PROptions{})
assert.Error(t, err)
assert.Empty(t, url)
assert.Contains(t, err.Error(), "task is required")
}

View file

@ -1,197 +0,0 @@
package agentic
import (
"os"
"path/filepath"
"strings"
errors "forge.lthn.ai/core/go/pkg/framework/core"
"forge.lthn.ai/core/go/pkg/io"
"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, errors.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, errors.E("agentic.LoadConfig", "failed to load config", err)
}
// Apply environment variable overrides
applyEnvOverrides(cfg)
// Validate configuration
if cfg.Token == "" {
return nil, errors.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 {
content, err := io.Local.Read(path)
if err != nil {
return err
}
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
// 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 nil
}
// loadYAMLConfig reads configuration from a YAML file.
func loadYAMLConfig(path string, cfg *Config) error {
content, err := io.Local.Read(path)
if err != nil {
return err
}
return yaml.Unmarshal([]byte(content), 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 errors.E("agentic.SaveConfig", "failed to get home directory", err)
}
configDir := filepath.Join(homeDir, ".core")
if err := io.Local.EnsureDir(configDir); err != nil {
return errors.E("agentic.SaveConfig", "failed to create config directory", err)
}
configPath := filepath.Join(configDir, configFileName)
data, err := yaml.Marshal(cfg)
if err != nil {
return errors.E("agentic.SaveConfig", "failed to marshal config", err)
}
if err := io.Local.Write(configPath, string(data)); err != nil {
return errors.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 "", errors.E("agentic.ConfigPath", "failed to get home directory", err)
}
return filepath.Join(homeDir, ".core", configFileName), nil
}

View file

@ -1,185 +0,0 @@
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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = os.RemoveAll(tmpHome) }()
// Override HOME for the test
originalHome := os.Getenv("HOME")
_ = os.Setenv("HOME", tmpHome)
defer func() { _ = 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 func() { _ = 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)
}

View file

@ -1,335 +0,0 @@
// Package agentic provides AI collaboration features for task management.
package agentic
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
errors "forge.lthn.ai/core/go/pkg/framework/core"
"forge.lthn.ai/core/go/pkg/io"
)
// FileContent represents the content of a file for AI context.
type FileContent struct {
// Path is the relative path to the file.
Path string `json:"path"`
// Content is the file content.
Content string `json:"content"`
// Language is the detected programming language.
Language string `json:"language"`
}
// TaskContext contains gathered context for AI collaboration.
type TaskContext struct {
// Task is the task being worked on.
Task *Task `json:"task"`
// Files is a list of relevant file contents.
Files []FileContent `json:"files"`
// GitStatus is the current git status output.
GitStatus string `json:"git_status"`
// RecentCommits is the recent commit log.
RecentCommits string `json:"recent_commits"`
// RelatedCode contains code snippets related to the task.
RelatedCode []FileContent `json:"related_code"`
}
// BuildTaskContext gathers context for AI collaboration on a task.
func BuildTaskContext(task *Task, dir string) (*TaskContext, error) {
const op = "agentic.BuildTaskContext"
if task == nil {
return nil, errors.E(op, "task is required", nil)
}
if dir == "" {
cwd, err := os.Getwd()
if err != nil {
return nil, errors.E(op, "failed to get working directory", err)
}
dir = cwd
}
ctx := &TaskContext{
Task: task,
}
// Gather files mentioned in the task
files, err := GatherRelatedFiles(task, dir)
if err != nil {
// Non-fatal: continue without files
files = nil
}
ctx.Files = files
// Get git status
gitStatus, _ := runGitCommand(dir, "status", "--porcelain")
ctx.GitStatus = gitStatus
// Get recent commits
recentCommits, _ := runGitCommand(dir, "log", "--oneline", "-10")
ctx.RecentCommits = recentCommits
// Find related code by searching for keywords
relatedCode, err := findRelatedCode(task, dir)
if err != nil {
relatedCode = nil
}
ctx.RelatedCode = relatedCode
return ctx, nil
}
// GatherRelatedFiles reads files mentioned in the task.
func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) {
const op = "agentic.GatherRelatedFiles"
if task == nil {
return nil, errors.E(op, "task is required", nil)
}
var files []FileContent
// Read files explicitly mentioned in the task
for _, relPath := range task.Files {
fullPath := filepath.Join(dir, relPath)
content, err := io.Local.Read(fullPath)
if err != nil {
// Skip files that don't exist
continue
}
files = append(files, FileContent{
Path: relPath,
Content: content,
Language: detectLanguage(relPath),
})
}
return files, nil
}
// findRelatedCode searches for code related to the task by keywords.
func findRelatedCode(task *Task, dir string) ([]FileContent, error) {
const op = "agentic.findRelatedCode"
if task == nil {
return nil, errors.E(op, "task is required", nil)
}
// Extract keywords from title and description
keywords := extractKeywords(task.Title + " " + task.Description)
if len(keywords) == 0 {
return nil, nil
}
var files []FileContent
seen := make(map[string]bool)
// Search for each keyword using git grep
for _, keyword := range keywords {
if len(keyword) < 3 {
continue
}
output, err := runGitCommand(dir, "grep", "-l", "-i", keyword, "--", "*.go", "*.ts", "*.js", "*.py")
if err != nil {
continue
}
// Parse matched files
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" || seen[line] {
continue
}
seen[line] = true
// Limit to 10 related files
if len(files) >= 10 {
break
}
fullPath := filepath.Join(dir, line)
content, err := io.Local.Read(fullPath)
if err != nil {
continue
}
// Truncate large files
if len(content) > 5000 {
content = content[:5000] + "\n... (truncated)"
}
files = append(files, FileContent{
Path: line,
Content: content,
Language: detectLanguage(line),
})
}
if len(files) >= 10 {
break
}
}
return files, nil
}
// extractKeywords extracts meaningful words from text for searching.
func extractKeywords(text string) []string {
// Remove common words and extract identifiers
text = strings.ToLower(text)
// Split by non-alphanumeric characters
re := regexp.MustCompile(`[^a-zA-Z0-9]+`)
words := re.Split(text, -1)
// Filter stop words and short words
stopWords := map[string]bool{
"the": true, "a": true, "an": true, "and": true, "or": true, "but": true,
"in": true, "on": true, "at": true, "to": true, "for": true, "of": true,
"with": true, "by": true, "from": true, "is": true, "are": true, "was": true,
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
"should": true, "may": true, "might": true, "must": true, "shall": true,
"this": true, "that": true, "these": true, "those": true, "it": true,
"add": true, "create": true, "update": true, "fix": true, "remove": true,
"implement": true, "new": true, "file": true, "code": true,
}
var keywords []string
for _, word := range words {
word = strings.TrimSpace(word)
if len(word) >= 3 && !stopWords[word] {
keywords = append(keywords, word)
}
}
// Limit to first 5 keywords
if len(keywords) > 5 {
keywords = keywords[:5]
}
return keywords
}
// detectLanguage detects the programming language from a file extension.
func detectLanguage(path string) string {
ext := strings.ToLower(filepath.Ext(path))
languages := map[string]string{
".go": "go",
".ts": "typescript",
".tsx": "typescript",
".js": "javascript",
".jsx": "javascript",
".py": "python",
".rs": "rust",
".java": "java",
".kt": "kotlin",
".swift": "swift",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
".rb": "ruby",
".php": "php",
".cs": "csharp",
".fs": "fsharp",
".scala": "scala",
".sh": "bash",
".bash": "bash",
".zsh": "zsh",
".yaml": "yaml",
".yml": "yaml",
".json": "json",
".xml": "xml",
".html": "html",
".css": "css",
".scss": "scss",
".sql": "sql",
".md": "markdown",
}
if lang, ok := languages[ext]; ok {
return lang
}
return "text"
}
// runGitCommand runs a git command and returns the output.
func runGitCommand(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", err
}
return stdout.String(), nil
}
// FormatContext formats the TaskContext for AI consumption.
func (tc *TaskContext) FormatContext() string {
var sb strings.Builder
sb.WriteString("# Task Context\n\n")
// Task info
sb.WriteString("## Task\n")
sb.WriteString("ID: " + tc.Task.ID + "\n")
sb.WriteString("Title: " + tc.Task.Title + "\n")
sb.WriteString("Priority: " + string(tc.Task.Priority) + "\n")
sb.WriteString("Status: " + string(tc.Task.Status) + "\n")
sb.WriteString("\n### Description\n")
sb.WriteString(tc.Task.Description + "\n\n")
// Files
if len(tc.Files) > 0 {
sb.WriteString("## Task Files\n")
for _, f := range tc.Files {
sb.WriteString("### " + f.Path + " (" + f.Language + ")\n")
sb.WriteString("```" + f.Language + "\n")
sb.WriteString(f.Content)
sb.WriteString("\n```\n\n")
}
}
// Git status
if tc.GitStatus != "" {
sb.WriteString("## Git Status\n")
sb.WriteString("```\n")
sb.WriteString(tc.GitStatus)
sb.WriteString("\n```\n\n")
}
// Recent commits
if tc.RecentCommits != "" {
sb.WriteString("## Recent Commits\n")
sb.WriteString("```\n")
sb.WriteString(tc.RecentCommits)
sb.WriteString("\n```\n\n")
}
// Related code
if len(tc.RelatedCode) > 0 {
sb.WriteString("## Related Code\n")
for _, f := range tc.RelatedCode {
sb.WriteString("### " + f.Path + " (" + f.Language + ")\n")
sb.WriteString("```" + f.Language + "\n")
sb.WriteString(f.Content)
sb.WriteString("\n```\n\n")
}
}
return sb.String()
}

View file

@ -1,214 +0,0 @@
package agentic
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildTaskContext_Good(t *testing.T) {
// Create a temp directory with some files
tmpDir := t.TempDir()
// Create a test file
testFile := filepath.Join(tmpDir, "main.go")
err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {}\n"), 0644)
require.NoError(t, err)
task := &Task{
ID: "test-123",
Title: "Test Task",
Description: "A test task description",
Priority: PriorityMedium,
Status: StatusPending,
Files: []string{"main.go"},
CreatedAt: time.Now(),
}
ctx, err := BuildTaskContext(task, tmpDir)
require.NoError(t, err)
assert.NotNil(t, ctx)
assert.Equal(t, task, ctx.Task)
assert.Len(t, ctx.Files, 1)
assert.Equal(t, "main.go", ctx.Files[0].Path)
assert.Equal(t, "go", ctx.Files[0].Language)
}
func TestBuildTaskContext_Bad_NilTask(t *testing.T) {
ctx, err := BuildTaskContext(nil, ".")
assert.Error(t, err)
assert.Nil(t, ctx)
assert.Contains(t, err.Error(), "task is required")
}
func TestGatherRelatedFiles_Good(t *testing.T) {
tmpDir := t.TempDir()
// Create test files
files := map[string]string{
"app.go": "package app\n\nfunc Run() {}\n",
"config.ts": "export const config = {};\n",
"README.md": "# Project\n",
}
for name, content := range files {
path := filepath.Join(tmpDir, name)
err := os.WriteFile(path, []byte(content), 0644)
require.NoError(t, err)
}
task := &Task{
ID: "task-1",
Title: "Test",
Files: []string{"app.go", "config.ts"},
}
gathered, err := GatherRelatedFiles(task, tmpDir)
require.NoError(t, err)
assert.Len(t, gathered, 2)
// Check languages detected correctly
foundGo := false
foundTS := false
for _, f := range gathered {
if f.Path == "app.go" {
foundGo = true
assert.Equal(t, "go", f.Language)
}
if f.Path == "config.ts" {
foundTS = true
assert.Equal(t, "typescript", f.Language)
}
}
assert.True(t, foundGo, "should find app.go")
assert.True(t, foundTS, "should find config.ts")
}
func TestGatherRelatedFiles_Bad_NilTask(t *testing.T) {
files, err := GatherRelatedFiles(nil, ".")
assert.Error(t, err)
assert.Nil(t, files)
}
func TestGatherRelatedFiles_Good_MissingFiles(t *testing.T) {
tmpDir := t.TempDir()
task := &Task{
ID: "task-1",
Title: "Test",
Files: []string{"nonexistent.go", "also-missing.ts"},
}
// Should not error, just return empty list
gathered, err := GatherRelatedFiles(task, tmpDir)
require.NoError(t, err)
assert.Empty(t, gathered)
}
func TestDetectLanguage(t *testing.T) {
tests := []struct {
path string
expected string
}{
{"main.go", "go"},
{"app.ts", "typescript"},
{"app.tsx", "typescript"},
{"script.js", "javascript"},
{"script.jsx", "javascript"},
{"main.py", "python"},
{"lib.rs", "rust"},
{"App.java", "java"},
{"config.yaml", "yaml"},
{"config.yml", "yaml"},
{"data.json", "json"},
{"index.html", "html"},
{"styles.css", "css"},
{"styles.scss", "scss"},
{"query.sql", "sql"},
{"README.md", "markdown"},
{"unknown.xyz", "text"},
{"", "text"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result := detectLanguage(tt.path)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExtractKeywords(t *testing.T) {
tests := []struct {
name string
text string
expected int // minimum number of keywords expected
}{
{
name: "simple title",
text: "Add user authentication feature",
expected: 2,
},
{
name: "with stop words",
text: "The quick brown fox jumps over the lazy dog",
expected: 3,
},
{
name: "technical text",
text: "Implement OAuth2 authentication with JWT tokens",
expected: 3,
},
{
name: "empty",
text: "",
expected: 0,
},
{
name: "only stop words",
text: "the a an and or but in on at",
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
keywords := extractKeywords(tt.text)
assert.GreaterOrEqual(t, len(keywords), tt.expected)
// Keywords should not exceed 5
assert.LessOrEqual(t, len(keywords), 5)
})
}
}
func TestTaskContext_FormatContext(t *testing.T) {
task := &Task{
ID: "test-456",
Title: "Test Formatting",
Description: "This is a test description",
Priority: PriorityHigh,
Status: StatusInProgress,
}
ctx := &TaskContext{
Task: task,
Files: []FileContent{{Path: "main.go", Content: "package main", Language: "go"}},
GitStatus: " M main.go",
RecentCommits: "abc123 Initial commit",
RelatedCode: []FileContent{{Path: "util.go", Content: "package util", Language: "go"}},
}
formatted := ctx.FormatContext()
assert.Contains(t, formatted, "# Task Context")
assert.Contains(t, formatted, "test-456")
assert.Contains(t, formatted, "Test Formatting")
assert.Contains(t, formatted, "## Task Files")
assert.Contains(t, formatted, "## Git Status")
assert.Contains(t, formatted, "## Recent Commits")
assert.Contains(t, formatted, "## Related Code")
}

View file

@ -1,19 +0,0 @@
package agentic
import (
"embed"
"strings"
)
//go:embed prompts/*.md
var promptsFS embed.FS
// Prompt returns the content of an embedded prompt file.
// Name should be without the .md extension (e.g., "commit").
func Prompt(name string) string {
data, err := promptsFS.ReadFile("prompts/" + name + ".md")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}

View file

@ -1,44 +0,0 @@
# Commit Instructions
## Context
When asked to commit changes in these repositories:
1. **All changes are pre-approved** - Both tracked (modified) and untracked files have been reviewed and approved for commit
2. **Include everything** - Commit ALL modified files AND all untracked files (including new directories)
3. **Don't skip files** - Do not leave out untracked files assuming they need separate review
4. **Single commit preferred** - Combine all changes into one cohesive commit unless explicitly told otherwise
The user has already validated these changes. Proceed with confidence.
## Handling Ignored Files
If you see untracked directories that should typically be ignored (like `node_modules/`, `vendor/`, `.cache/`, `dist/`, `build/`):
1. **Fix the .gitignore** - Create or update `.gitignore` to exclude these directories
2. **Commit the .gitignore** - Include this fix in your commit
3. **Don't ask** - Just fix it and commit
Common patterns to add to .gitignore:
```
node_modules/
vendor/
.cache/
dist/
build/
*.log
.env
.DS_Store
```
## Commit Message Style
- Use conventional commit format: `type(scope): description`
- Common types: `refactor`, `feat`, `fix`, `docs`, `chore`
- Keep the first line under 72 characters
- Add body for complex changes explaining the "why"
- Include `Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>`
## Task
Review the uncommitted changes and create an appropriate commit. Be concise.

View file

@ -1,142 +0,0 @@
package agentic
import (
"context"
"os"
"os/exec"
"strings"
"forge.lthn.ai/core/go/pkg/framework"
"forge.lthn.ai/core/go/pkg/log"
)
// Tasks for AI service
// TaskCommit requests Claude to create a commit.
type TaskCommit struct {
Path string
Name string
CanEdit bool // allow Write/Edit tools
}
// TaskPrompt sends a custom prompt to Claude.
type TaskPrompt struct {
Prompt string
WorkDir string
AllowedTools []string
taskID string
}
func (t *TaskPrompt) SetTaskID(id string) { t.taskID = id }
func (t *TaskPrompt) GetTaskID() string { return t.taskID }
// ServiceOptions for configuring the AI service.
type ServiceOptions struct {
DefaultTools []string
AllowEdit bool // global permission for Write/Edit tools
}
// DefaultServiceOptions returns sensible defaults.
func DefaultServiceOptions() ServiceOptions {
return ServiceOptions{
DefaultTools: []string{"Bash", "Read", "Glob", "Grep"},
AllowEdit: false,
}
}
// Service provides AI/Claude operations as a Core service.
type Service struct {
*framework.ServiceRuntime[ServiceOptions]
}
// NewService creates an AI service factory.
func NewService(opts ServiceOptions) func(*framework.Core) (any, error) {
return func(c *framework.Core) (any, error) {
return &Service{
ServiceRuntime: framework.NewServiceRuntime(c, opts),
}, nil
}
}
// OnStartup registers task handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
switch m := t.(type) {
case TaskCommit:
err := s.doCommit(m)
if err != nil {
log.Error("agentic: commit task failed", "err", err, "path", m.Path)
}
return nil, true, err
case TaskPrompt:
err := s.doPrompt(m)
if err != nil {
log.Error("agentic: prompt task failed", "err", err)
}
return nil, true, err
}
return nil, false, nil
}
func (s *Service) doCommit(task TaskCommit) error {
prompt := Prompt("commit")
tools := []string{"Bash", "Read", "Glob", "Grep"}
if task.CanEdit {
tools = []string{"Bash", "Read", "Write", "Edit", "Glob", "Grep"}
}
cmd := exec.CommandContext(context.Background(), "claude", "-p", prompt, "--allowedTools", strings.Join(tools, ","))
cmd.Dir = task.Path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func (s *Service) doPrompt(task TaskPrompt) error {
if task.taskID != "" {
s.Core().Progress(task.taskID, 0.1, "Starting Claude...", &task)
}
opts := s.Opts()
tools := opts.DefaultTools
if len(tools) == 0 {
tools = []string{"Bash", "Read", "Glob", "Grep"}
}
if len(task.AllowedTools) > 0 {
tools = task.AllowedTools
}
cmd := exec.CommandContext(context.Background(), "claude", "-p", task.Prompt, "--allowedTools", strings.Join(tools, ","))
if task.WorkDir != "" {
cmd.Dir = task.WorkDir
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if task.taskID != "" {
s.Core().Progress(task.taskID, 0.5, "Running Claude prompt...", &task)
}
err := cmd.Run()
if task.taskID != "" {
if err != nil {
s.Core().Progress(task.taskID, 1.0, "Failed: "+err.Error(), &task)
} else {
s.Core().Progress(task.taskID, 1.0, "Completed", &task)
}
}
return err
}

View file

@ -1,140 +0,0 @@
// 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"`
}