Combines three repositories into a single workspace: - go-agent → pkg/orchestrator (Clotho), pkg/jobrunner, pkg/loop, cmd/ - go-agentic → pkg/lifecycle (allowance, sessions, plans, dispatch) - php-devops → repos.yaml, setup.sh, scripts/, .core/ Module path: forge.lthn.ai/core/agent All packages build, all tests pass. Co-Authored-By: Virgil <virgil@lethean.io>
525 lines
15 KiB
Go
525 lines
15 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"forge.lthn.ai/core/go-log"
|
|
)
|
|
|
|
// PlanStatus represents the state of a plan.
|
|
type PlanStatus string
|
|
|
|
const (
|
|
PlanDraft PlanStatus = "draft"
|
|
PlanActive PlanStatus = "active"
|
|
PlanPaused PlanStatus = "paused"
|
|
PlanCompleted PlanStatus = "completed"
|
|
PlanArchived PlanStatus = "archived"
|
|
)
|
|
|
|
// PhaseStatus represents the state of a phase within a plan.
|
|
type PhaseStatus string
|
|
|
|
const (
|
|
PhasePending PhaseStatus = "pending"
|
|
PhaseInProgress PhaseStatus = "in_progress"
|
|
PhaseCompleted PhaseStatus = "completed"
|
|
PhaseBlocked PhaseStatus = "blocked"
|
|
PhaseSkipped PhaseStatus = "skipped"
|
|
)
|
|
|
|
// Plan represents an agent plan from the PHP API.
|
|
type Plan struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description,omitempty"`
|
|
Status PlanStatus `json:"status"`
|
|
CurrentPhase int `json:"current_phase,omitempty"`
|
|
Progress Progress `json:"progress,omitempty"`
|
|
Phases []Phase `json:"phases,omitempty"`
|
|
Metadata any `json:"metadata,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
// Phase represents a phase within a plan.
|
|
type Phase struct {
|
|
ID int `json:"id,omitempty"`
|
|
Order int `json:"order"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Status PhaseStatus `json:"status"`
|
|
Tasks []PhaseTask `json:"tasks,omitempty"`
|
|
TaskProgress TaskProgress `json:"task_progress,omitempty"`
|
|
RemainingTasks []string `json:"remaining_tasks,omitempty"`
|
|
Dependencies []int `json:"dependencies,omitempty"`
|
|
DependencyBlockers []string `json:"dependency_blockers,omitempty"`
|
|
CanStart bool `json:"can_start,omitempty"`
|
|
Checkpoints []any `json:"checkpoints,omitempty"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
Metadata any `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// PhaseTask represents a task within a phase. Tasks are stored as a JSON array
|
|
// in the phase and may be simple strings or objects with status/notes.
|
|
type PhaseTask struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// UnmarshalJSON handles the fact that tasks can be either strings or objects.
|
|
func (t *PhaseTask) UnmarshalJSON(data []byte) error {
|
|
// Try string first
|
|
var s string
|
|
if err := json.Unmarshal(data, &s); err == nil {
|
|
t.Name = s
|
|
t.Status = "pending"
|
|
return nil
|
|
}
|
|
|
|
// Try object
|
|
type taskAlias PhaseTask
|
|
var obj taskAlias
|
|
if err := json.Unmarshal(data, &obj); err != nil {
|
|
return err
|
|
}
|
|
*t = PhaseTask(obj)
|
|
return nil
|
|
}
|
|
|
|
// Progress represents plan progress metrics.
|
|
type Progress struct {
|
|
Total int `json:"total"`
|
|
Completed int `json:"completed"`
|
|
InProgress int `json:"in_progress"`
|
|
Pending int `json:"pending"`
|
|
Percentage int `json:"percentage"`
|
|
}
|
|
|
|
// TaskProgress represents task-level progress within a phase.
|
|
type TaskProgress struct {
|
|
Total int `json:"total"`
|
|
Completed int `json:"completed"`
|
|
Pending int `json:"pending"`
|
|
Percentage int `json:"percentage"`
|
|
}
|
|
|
|
// ListPlanOptions specifies filters for listing plans.
|
|
type ListPlanOptions struct {
|
|
Status PlanStatus `json:"status,omitempty"`
|
|
IncludeArchived bool `json:"include_archived,omitempty"`
|
|
}
|
|
|
|
// CreatePlanRequest is the payload for creating a new plan.
|
|
type CreatePlanRequest struct {
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Context map[string]any `json:"context,omitempty"`
|
|
Phases []CreatePhaseInput `json:"phases,omitempty"`
|
|
}
|
|
|
|
// CreatePhaseInput is a phase definition for plan creation.
|
|
type CreatePhaseInput struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Tasks []string `json:"tasks,omitempty"`
|
|
}
|
|
|
|
// planListResponse wraps the list endpoint response.
|
|
type planListResponse struct {
|
|
Plans []Plan `json:"plans"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// planCreateResponse wraps the create endpoint response.
|
|
type planCreateResponse struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Phases int `json:"phases"`
|
|
}
|
|
|
|
// planUpdateResponse wraps the update endpoint response.
|
|
type planUpdateResponse struct {
|
|
Slug string `json:"slug"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// planArchiveResponse wraps the archive endpoint response.
|
|
type planArchiveResponse struct {
|
|
Slug string `json:"slug"`
|
|
Status string `json:"status"`
|
|
ArchivedAt string `json:"archived_at,omitempty"`
|
|
}
|
|
|
|
// ListPlans retrieves plans matching the given options.
|
|
func (c *Client) ListPlans(ctx context.Context, opts ListPlanOptions) ([]Plan, error) {
|
|
const op = "agentic.Client.ListPlans"
|
|
|
|
params := url.Values{}
|
|
if opts.Status != "" {
|
|
params.Set("status", string(opts.Status))
|
|
}
|
|
if opts.IncludeArchived {
|
|
params.Set("include_archived", "1")
|
|
}
|
|
|
|
endpoint := c.BaseURL + "/v1/plans"
|
|
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 result planListResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, log.E(op, "failed to decode response", err)
|
|
}
|
|
|
|
return result.Plans, nil
|
|
}
|
|
|
|
// GetPlan retrieves a plan by slug (returns full detail with phases).
|
|
func (c *Client) GetPlan(ctx context.Context, slug string) (*Plan, error) {
|
|
const op = "agentic.Client.GetPlan"
|
|
|
|
if slug == "" {
|
|
return nil, log.E(op, "plan slug is required", nil)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
|
|
|
|
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 plan Plan
|
|
if err := json.NewDecoder(resp.Body).Decode(&plan); err != nil {
|
|
return nil, log.E(op, "failed to decode response", err)
|
|
}
|
|
|
|
return &plan, nil
|
|
}
|
|
|
|
// CreatePlan creates a new plan with optional phases.
|
|
func (c *Client) CreatePlan(ctx context.Context, req CreatePlanRequest) (*planCreateResponse, error) {
|
|
const op = "agentic.Client.CreatePlan"
|
|
|
|
if req.Title == "" {
|
|
return nil, log.E(op, "title is required", nil)
|
|
}
|
|
|
|
data, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, log.E(op, "failed to marshal request", err)
|
|
}
|
|
|
|
endpoint := c.BaseURL + "/v1/plans"
|
|
|
|
httpReq, 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(httpReq)
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.HTTPClient.Do(httpReq)
|
|
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 result planCreateResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, log.E(op, "failed to decode response", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// UpdatePlanStatus changes a plan's status.
|
|
func (c *Client) UpdatePlanStatus(ctx context.Context, slug string, status PlanStatus) error {
|
|
const op = "agentic.Client.UpdatePlanStatus"
|
|
|
|
if slug == "" {
|
|
return log.E(op, "plan slug is required", nil)
|
|
}
|
|
|
|
data, err := json.Marshal(map[string]string{"status": string(status)})
|
|
if err != nil {
|
|
return log.E(op, "failed to marshal request", err)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
|
|
|
|
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() }()
|
|
|
|
return c.checkResponse(resp)
|
|
}
|
|
|
|
// ArchivePlan archives a plan with an optional reason.
|
|
func (c *Client) ArchivePlan(ctx context.Context, slug string, reason string) error {
|
|
const op = "agentic.Client.ArchivePlan"
|
|
|
|
if slug == "" {
|
|
return log.E(op, "plan slug is required", nil)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
|
|
|
|
var body *bytes.Reader
|
|
if reason != "" {
|
|
data, _ := json.Marshal(map[string]string{"reason": reason})
|
|
body = bytes.NewReader(data)
|
|
}
|
|
|
|
var reqBody *bytes.Reader
|
|
if body != nil {
|
|
reqBody = body
|
|
}
|
|
|
|
var httpReq *http.Request
|
|
var err error
|
|
if reqBody != nil {
|
|
httpReq, err = http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, reqBody)
|
|
if err != nil {
|
|
return log.E(op, "failed to create request", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
httpReq, err = http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
|
if err != nil {
|
|
return log.E(op, "failed to create request", err)
|
|
}
|
|
}
|
|
c.setHeaders(httpReq)
|
|
|
|
resp, err := c.HTTPClient.Do(httpReq)
|
|
if err != nil {
|
|
return log.E(op, "request failed", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
return c.checkResponse(resp)
|
|
}
|
|
|
|
// GetPhase retrieves a specific phase within a plan.
|
|
func (c *Client) GetPhase(ctx context.Context, planSlug string, phase string) (*Phase, error) {
|
|
const op = "agentic.Client.GetPhase"
|
|
|
|
if planSlug == "" || phase == "" {
|
|
return nil, log.E(op, "plan slug and phase identifier are required", nil)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s",
|
|
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
|
|
|
|
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 result Phase
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, log.E(op, "failed to decode response", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// UpdatePhaseStatus changes a phase's status.
|
|
func (c *Client) UpdatePhaseStatus(ctx context.Context, planSlug, phase string, status PhaseStatus, notes string) error {
|
|
const op = "agentic.Client.UpdatePhaseStatus"
|
|
|
|
if planSlug == "" || phase == "" {
|
|
return log.E(op, "plan slug and phase identifier are required", nil)
|
|
}
|
|
|
|
payload := map[string]string{"status": string(status)}
|
|
if notes != "" {
|
|
payload["notes"] = notes
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return log.E(op, "failed to marshal request", err)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s",
|
|
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
|
|
|
|
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() }()
|
|
|
|
return c.checkResponse(resp)
|
|
}
|
|
|
|
// AddCheckpoint adds a checkpoint note to a phase.
|
|
func (c *Client) AddCheckpoint(ctx context.Context, planSlug, phase, note string, checkpointCtx map[string]any) error {
|
|
const op = "agentic.Client.AddCheckpoint"
|
|
|
|
if planSlug == "" || phase == "" || note == "" {
|
|
return log.E(op, "plan slug, phase, and note are required", nil)
|
|
}
|
|
|
|
payload := map[string]any{"note": note}
|
|
if len(checkpointCtx) > 0 {
|
|
payload["context"] = checkpointCtx
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return log.E(op, "failed to marshal request", err)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/checkpoint",
|
|
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
|
|
|
|
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() }()
|
|
|
|
return c.checkResponse(resp)
|
|
}
|
|
|
|
// UpdateTaskStatus updates a task within a phase.
|
|
func (c *Client) UpdateTaskStatus(ctx context.Context, planSlug, phase string, taskIdx int, status string, notes string) error {
|
|
const op = "agentic.Client.UpdateTaskStatus"
|
|
|
|
if planSlug == "" || phase == "" {
|
|
return log.E(op, "plan slug and phase are required", nil)
|
|
}
|
|
|
|
payload := map[string]any{}
|
|
if status != "" {
|
|
payload["status"] = status
|
|
}
|
|
if notes != "" {
|
|
payload["notes"] = notes
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return log.E(op, "failed to marshal request", err)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/tasks/%d",
|
|
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase), taskIdx)
|
|
|
|
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() }()
|
|
|
|
return c.checkResponse(resp)
|
|
}
|
|
|
|
// ToggleTask toggles a task between pending and completed.
|
|
func (c *Client) ToggleTask(ctx context.Context, planSlug, phase string, taskIdx int) error {
|
|
const op = "agentic.Client.ToggleTask"
|
|
|
|
if planSlug == "" || phase == "" {
|
|
return log.E(op, "plan slug and phase are required", nil)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/tasks/%d/toggle",
|
|
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase), taskIdx)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, 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() }()
|
|
|
|
return c.checkResponse(resp)
|
|
}
|