206 lines
6.5 KiB
Go
206 lines
6.5 KiB
Go
// Package agent implements a Go worker agent: a coordinator client and
|
|
// task-lifecycle supervisor that executes SDK workloads in a Python
|
|
// subprocess. It mirrors the Python worker's v1 wire contract exactly; the
|
|
// Python worker remains the reference implementation.
|
|
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
|
sha256Pattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
|
workloadPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$`)
|
|
)
|
|
|
|
// RegisteredWorker is the coordinator's answer to /workers/register.
|
|
type RegisteredWorker struct {
|
|
WorkerID string
|
|
HeartbeatIntervalSeconds float64
|
|
}
|
|
|
|
// Input is the claimed task's input artifact.
|
|
type Input struct {
|
|
URI string
|
|
SHA256 string
|
|
}
|
|
|
|
// Task is one claimed, leased task.
|
|
type Task struct {
|
|
TaskID string
|
|
Attempt int
|
|
LeaseExpiresAt time.Time
|
|
Workload string
|
|
Input Input
|
|
Parameters map[string]any
|
|
leaseExpiresRaw string
|
|
}
|
|
|
|
// Uploaded is the coordinator-owned metadata returned after artifact upload.
|
|
type Uploaded struct {
|
|
ArtifactID string
|
|
URI string
|
|
SHA256 string
|
|
SizeBytes int64
|
|
}
|
|
|
|
func requireString(value any, field string) (string, error) {
|
|
text, ok := value.(string)
|
|
if !ok || strings.TrimSpace(text) == "" {
|
|
return "", fmt.Errorf("%s must be a non-empty string", field)
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
func safeCoordinatorURI(value any, field string) (string, error) {
|
|
uri, err := requireString(value, field)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if strings.HasPrefix(uri, "/") {
|
|
// A network-path reference (//host/path) or dot segments would
|
|
// resolve to another origin; reject both.
|
|
if strings.HasPrefix(uri, "//") {
|
|
return "", fmt.Errorf("%s must be a safe coordinator path", field)
|
|
}
|
|
for _, segment := range strings.Split(uri, "/") {
|
|
if segment == ".." {
|
|
return "", fmt.Errorf("%s must be a safe coordinator path", field)
|
|
}
|
|
}
|
|
return uri, nil
|
|
}
|
|
parsed, err := url.Parse(uri)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
|
return "", fmt.Errorf("%s must be an absolute HTTP(S) URL or coordinator path", field)
|
|
}
|
|
return uri, nil
|
|
}
|
|
|
|
func sha256Hex(value any, field string) (string, error) {
|
|
digest, err := requireString(value, field)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
digest = strings.ToLower(digest)
|
|
if !sha256Pattern.MatchString(digest) {
|
|
return "", fmt.Errorf("%s must be a SHA-256 hex digest", field)
|
|
}
|
|
return digest, nil
|
|
}
|
|
|
|
func uuid(value any, field string) (string, error) {
|
|
text, err := requireString(value, field)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !uuidPattern.MatchString(text) {
|
|
return "", fmt.Errorf("%s must be a UUID", field)
|
|
}
|
|
return strings.ToLower(text), nil
|
|
}
|
|
|
|
// ParseTask validates a claimed-task response with the same strictness as the
|
|
// Python worker's ClaimedTask.from_json.
|
|
func ParseTask(payload map[string]any) (*Task, error) {
|
|
rawInput, ok := payload["input"].(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("input must be an object")
|
|
}
|
|
rawAttempt, ok := payload["attempt"].(float64)
|
|
if !ok || rawAttempt < 1 || rawAttempt != float64(int(rawAttempt)) {
|
|
return nil, fmt.Errorf("attempt must be a positive integer")
|
|
}
|
|
taskID, err := uuid(payload["task_id"], "task_id")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
|
|
}
|
|
rawLease, err := requireString(payload["lease_expires_at"], "lease_expires_at")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
|
|
}
|
|
lease, err := time.Parse(time.RFC3339, rawLease)
|
|
if err != nil || lease.Location() == nil {
|
|
return nil, fmt.Errorf("lease_expires_at must include a timezone")
|
|
}
|
|
workload, err := requireString(payload["workload"], "workload")
|
|
if err != nil || !workloadPattern.MatchString(workload) {
|
|
return nil, fmt.Errorf("workload must be a canonical name")
|
|
}
|
|
uri, err := safeCoordinatorURI(rawInput["uri"], "input.uri")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
|
|
}
|
|
digest, err := sha256Hex(rawInput["sha256"], "input.sha256")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid claimed-task response: %w", err)
|
|
}
|
|
parameters, ok := payload["parameters"].(map[string]any)
|
|
if !ok {
|
|
parameters = map[string]any{}
|
|
}
|
|
return &Task{
|
|
TaskID: taskID,
|
|
Attempt: int(rawAttempt),
|
|
LeaseExpiresAt: lease,
|
|
leaseExpiresRaw: rawLease,
|
|
Workload: workload,
|
|
Input: Input{URI: uri, SHA256: digest},
|
|
Parameters: parameters,
|
|
}, nil
|
|
}
|
|
|
|
// LeaseExpiresRaw returns the original lease timestamp string for
|
|
// round-tripping in heartbeat deadlines.
|
|
func (t *Task) LeaseExpiresRaw() string { return t.leaseExpiresRaw }
|
|
|
|
// ParseRegistered validates a registration response.
|
|
func ParseRegistered(payload map[string]any) (*RegisteredWorker, error) {
|
|
workerID, err := uuid(payload["worker_id"], "worker_id")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid worker registration response: %w", err)
|
|
}
|
|
interval, ok := payload["heartbeat_interval_seconds"].(float64)
|
|
if !ok || interval <= 0 {
|
|
return nil, fmt.Errorf("heartbeat_interval_seconds must be positive")
|
|
}
|
|
return &RegisteredWorker{WorkerID: workerID, HeartbeatIntervalSeconds: interval}, nil
|
|
}
|
|
|
|
// ParseUploaded validates an artifact upload response.
|
|
func ParseUploaded(payload map[string]any) (*Uploaded, error) {
|
|
artifactID, err := uuid(payload["artifact_id"], "artifact_id")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
|
|
}
|
|
uri, err := safeCoordinatorURI(payload["uri"], "uri")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
|
|
}
|
|
digest, err := sha256Hex(payload["sha256"], "sha256")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid artifact upload response: %w", err)
|
|
}
|
|
rawSize, ok := payload["size_bytes"].(float64)
|
|
if !ok || rawSize < 0 || rawSize != float64(int64(rawSize)) {
|
|
return nil, fmt.Errorf("artifact size_bytes must be a non-negative integer")
|
|
}
|
|
return &Uploaded{ArtifactID: artifactID, URI: uri, SHA256: digest, SizeBytes: int64(rawSize)}, nil
|
|
}
|
|
|
|
// TaskRunnerManifest is what the Python task entry writes on success.
|
|
type TaskRunnerManifest struct {
|
|
ArtifactPath string `json:"artifact_path"`
|
|
ContentType string `json:"content_type"`
|
|
Metrics map[string]any `json:"metrics"`
|
|
}
|
|
|
|
// Encode serializes a claim payload for /tasks/claim.
|
|
func Encode(v any) ([]byte, error) { return json.Marshal(v) }
|