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>
287 lines
8.4 KiB
Go
287 lines
8.4 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"forge.lthn.ai/core/go-log"
|
|
)
|
|
|
|
// SessionStatus represents the state of a session.
|
|
type SessionStatus string
|
|
|
|
const (
|
|
SessionActive SessionStatus = "active"
|
|
SessionPaused SessionStatus = "paused"
|
|
SessionCompleted SessionStatus = "completed"
|
|
SessionFailed SessionStatus = "failed"
|
|
)
|
|
|
|
// Session represents an agent session from the PHP API.
|
|
type Session struct {
|
|
SessionID string `json:"session_id"`
|
|
AgentType string `json:"agent_type"`
|
|
Status SessionStatus `json:"status"`
|
|
PlanSlug string `json:"plan_slug,omitempty"`
|
|
Plan string `json:"plan,omitempty"`
|
|
Duration string `json:"duration,omitempty"`
|
|
StartedAt string `json:"started_at,omitempty"`
|
|
LastActiveAt string `json:"last_active_at,omitempty"`
|
|
EndedAt string `json:"ended_at,omitempty"`
|
|
ActionCount int `json:"action_count,omitempty"`
|
|
ArtifactCount int `json:"artifact_count,omitempty"`
|
|
ContextSummary map[string]any `json:"context_summary,omitempty"`
|
|
HandoffNotes string `json:"handoff_notes,omitempty"`
|
|
ContinuedFrom string `json:"continued_from,omitempty"`
|
|
}
|
|
|
|
// StartSessionRequest is the payload for starting a new session.
|
|
type StartSessionRequest struct {
|
|
AgentType string `json:"agent_type"`
|
|
PlanSlug string `json:"plan_slug,omitempty"`
|
|
Context map[string]any `json:"context,omitempty"`
|
|
}
|
|
|
|
// EndSessionRequest is the payload for ending a session.
|
|
type EndSessionRequest struct {
|
|
Status string `json:"status"`
|
|
Summary string `json:"summary,omitempty"`
|
|
}
|
|
|
|
// ListSessionOptions specifies filters for listing sessions.
|
|
type ListSessionOptions struct {
|
|
Status SessionStatus `json:"status,omitempty"`
|
|
PlanSlug string `json:"plan_slug,omitempty"`
|
|
Limit int `json:"limit,omitempty"`
|
|
}
|
|
|
|
// sessionListResponse wraps the list endpoint response.
|
|
type sessionListResponse struct {
|
|
Sessions []Session `json:"sessions"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// sessionStartResponse wraps the session create endpoint response.
|
|
type sessionStartResponse struct {
|
|
SessionID string `json:"session_id"`
|
|
AgentType string `json:"agent_type"`
|
|
Plan string `json:"plan,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// sessionEndResponse wraps the session end endpoint response.
|
|
type sessionEndResponse struct {
|
|
SessionID string `json:"session_id"`
|
|
Status string `json:"status"`
|
|
Duration string `json:"duration,omitempty"`
|
|
}
|
|
|
|
// sessionContinueResponse wraps the session continue endpoint response.
|
|
type sessionContinueResponse struct {
|
|
SessionID string `json:"session_id"`
|
|
AgentType string `json:"agent_type"`
|
|
Plan string `json:"plan,omitempty"`
|
|
Status string `json:"status"`
|
|
ContinuedFrom string `json:"continued_from,omitempty"`
|
|
}
|
|
|
|
// ListSessions retrieves sessions matching the given options.
|
|
func (c *Client) ListSessions(ctx context.Context, opts ListSessionOptions) ([]Session, error) {
|
|
const op = "agentic.Client.ListSessions"
|
|
|
|
params := url.Values{}
|
|
if opts.Status != "" {
|
|
params.Set("status", string(opts.Status))
|
|
}
|
|
if opts.PlanSlug != "" {
|
|
params.Set("plan_slug", opts.PlanSlug)
|
|
}
|
|
if opts.Limit > 0 {
|
|
params.Set("limit", strconv.Itoa(opts.Limit))
|
|
}
|
|
|
|
endpoint := c.BaseURL + "/v1/sessions"
|
|
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 sessionListResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, log.E(op, "failed to decode response", err)
|
|
}
|
|
|
|
return result.Sessions, nil
|
|
}
|
|
|
|
// GetSession retrieves a session by ID.
|
|
func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
|
const op = "agentic.Client.GetSession"
|
|
|
|
if sessionID == "" {
|
|
return nil, log.E(op, "session ID is required", nil)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/sessions/%s", c.BaseURL, url.PathEscape(sessionID))
|
|
|
|
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 session Session
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
return nil, log.E(op, "failed to decode response", err)
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
// StartSession starts a new agent session.
|
|
func (c *Client) StartSession(ctx context.Context, req StartSessionRequest) (*sessionStartResponse, error) {
|
|
const op = "agentic.Client.StartSession"
|
|
|
|
if req.AgentType == "" {
|
|
return nil, log.E(op, "agent_type 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/sessions"
|
|
|
|
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 sessionStartResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, log.E(op, "failed to decode response", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// EndSession ends a session with a final status and optional summary.
|
|
func (c *Client) EndSession(ctx context.Context, sessionID string, status string, summary string) error {
|
|
const op = "agentic.Client.EndSession"
|
|
|
|
if sessionID == "" {
|
|
return log.E(op, "session ID is required", nil)
|
|
}
|
|
if status == "" {
|
|
return log.E(op, "status is required", nil)
|
|
}
|
|
|
|
payload := EndSessionRequest{Status: status, Summary: summary}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return log.E(op, "failed to marshal request", err)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/sessions/%s/end", c.BaseURL, url.PathEscape(sessionID))
|
|
|
|
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)
|
|
}
|
|
|
|
// ContinueSession creates a new session continuing from a previous one (multi-agent handoff).
|
|
func (c *Client) ContinueSession(ctx context.Context, previousSessionID, agentType string) (*sessionContinueResponse, error) {
|
|
const op = "agentic.Client.ContinueSession"
|
|
|
|
if previousSessionID == "" {
|
|
return nil, log.E(op, "previous session ID is required", nil)
|
|
}
|
|
if agentType == "" {
|
|
return nil, log.E(op, "agent_type is required", nil)
|
|
}
|
|
|
|
data, err := json.Marshal(map[string]string{"agent_type": agentType})
|
|
if err != nil {
|
|
return nil, log.E(op, "failed to marshal request", err)
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/v1/sessions/%s/continue", c.BaseURL, url.PathEscape(previousSessionID))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, log.E(op, "failed to create request", err)
|
|
}
|
|
c.setHeaders(req)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, log.E(op, "request failed", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if err := c.checkResponse(resp); err != nil {
|
|
return nil, log.E(op, "API error", err)
|
|
}
|
|
|
|
var result sessionContinueResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, log.E(op, "failed to decode response", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|