agent/pkg/lifecycle/sessions.go
Snider e90a84eaa0 feat: merge go-agent + go-agentic + php-devops into unified agent repo
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>
2026-03-06 15:23:00 +00:00

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
}