diff --git a/agent/cmd/claw-agent/main.go b/agent/cmd/claw-agent/main.go
new file mode 100644
index 00000000..8bc5a948
--- /dev/null
+++ b/agent/cmd/claw-agent/main.go
@@ -0,0 +1,121 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/mojomast/uberclawcontrol/agent/internal/clawdeck"
+ "github.com/mojomast/uberclawcontrol/agent/internal/config"
+ "github.com/mojomast/uberclawcontrol/agent/internal/orchestrator"
+ "github.com/mojomast/uberclawcontrol/agent/internal/runner"
+)
+
+var (
+ version = "dev"
+)
+
+func main() {
+ showVersion := flag.Bool("version", false, "show version")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Printf("claw-agent %s\n", version)
+ os.Exit(0)
+ }
+
+ log.Printf("claw-agent %s starting", version)
+
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("failed to load config: %v", err)
+ }
+
+ client := clawdeck.NewClient(cfg.APIURL)
+
+ agentID, token, err := cfg.LoadPersistedToken()
+ if err != nil {
+ log.Printf("warning: failed to load persisted token: %v", err)
+ }
+
+ if token == "" && cfg.JoinToken == "" {
+ log.Fatal("no persisted token found and CLAWDECK_JOIN_TOKEN not set")
+ }
+
+ if token != "" {
+ client.SetToken(token)
+ client.SetAgentID(agentID)
+ log.Printf("using persisted token for agent %d", agentID)
+ } else {
+ log.Printf("registering agent with join token")
+ resp, err := client.Register(cfg.JoinToken, clawdeck.AgentInfo{
+ Name: cfg.AgentInfo.Name,
+ Hostname: cfg.AgentInfo.Hostname,
+ HostUID: cfg.AgentInfo.HostUID,
+ Platform: cfg.AgentInfo.Platform,
+ Version: version,
+ Tags: cfg.AgentInfo.Tags,
+ Metadata: cfg.AgentInfo.Metadata,
+ })
+ if err != nil {
+ log.Fatalf("failed to register: %v", err)
+ }
+
+ agentID = resp.Agent.ID
+ log.Printf("registered agent id=%d name=%s", resp.Agent.ID, resp.Agent.Name)
+
+ if err := cfg.SaveToken(resp.Agent.ID, resp.AgentToken); err != nil {
+ log.Printf("warning: failed to persist token: %v", err)
+ }
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+ go func() {
+ sig := <-sigChan
+ log.Printf("received signal %v, shutting down", sig)
+ cancel()
+ }()
+
+ heartbeatRunner := runner.NewHeartbeatRunner(client, agentID, cfg.HeartbeatDelay)
+ taskRunner := runner.NewTaskRunner(client, cfg.TaskPollDelay, runner.NewStubExecutor())
+ commandRunner := orchestrator.NewCommandRunner(client, cfg.CommandPollDelay)
+
+ errChan := make(chan error, 3)
+
+ go func() {
+ if err := heartbeatRunner.Run(ctx); err != nil && err != context.Canceled {
+ errChan <- fmt.Errorf("heartbeat: %w", err)
+ }
+ }()
+
+ go func() {
+ if err := taskRunner.Run(ctx); err != nil && err != context.Canceled {
+ errChan <- fmt.Errorf("task: %w", err)
+ }
+ }()
+
+ go func() {
+ if err := commandRunner.Run(ctx); err != nil && err != context.Canceled {
+ errChan <- fmt.Errorf("command: %w", err)
+ }
+ }()
+
+ select {
+ case <-ctx.Done():
+ log.Printf("shutting down")
+ case err := <-errChan:
+ log.Printf("runner error: %v", err)
+ cancel()
+ }
+
+ log.Printf("claw-agent stopped")
+}
diff --git a/agent/go.mod b/agent/go.mod
new file mode 100644
index 00000000..94338edf
--- /dev/null
+++ b/agent/go.mod
@@ -0,0 +1,9 @@
+module github.com/mojomast/uberclawcontrol/agent
+
+go 1.22
+
+require (
+ golang.org/x/term v0.17.0
+)
+
+require golang.org/x/sys v0.17.0 // indirect
diff --git a/agent/internal/clawdeck/client.go b/agent/internal/clawdeck/client.go
new file mode 100644
index 00000000..b4b45b98
--- /dev/null
+++ b/agent/internal/clawdeck/client.go
@@ -0,0 +1,202 @@
+package clawdeck
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+ token string
+ agentID int64
+}
+
+func NewClient(baseURL string) *Client {
+ return &Client{
+ baseURL: baseURL,
+ httpClient: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ }
+}
+
+func (c *Client) SetToken(token string) {
+ c.token = token
+}
+
+func (c *Client) SetAgentID(id int64) {
+ c.agentID = id
+}
+
+func (c *Client) AgentID() int64 {
+ return c.agentID
+}
+
+func (c *Client) Register(joinToken string, info AgentInfo) (*RegisterResponse, error) {
+ req := RegisterRequest{
+ JoinToken: joinToken,
+ Agent: info,
+ }
+
+ var resp RegisterResponse
+ if err := c.doRequest("POST", "/api/v1/agents/register", req, &resp, false); err != nil {
+ return nil, fmt.Errorf("register: %w", err)
+ }
+
+ c.token = resp.AgentToken
+ c.agentID = resp.Agent.ID
+
+ return &resp, nil
+}
+
+func (c *Client) Heartbeat(agentID int64, status string, metadata map[string]any) (*HeartbeatResponse, error) {
+ req := HeartbeatRequest{
+ Status: status,
+ Metadata: metadata,
+ }
+
+ var resp HeartbeatResponse
+ path := fmt.Sprintf("/api/v1/agents/%d/heartbeat", agentID)
+ if err := c.doRequest("POST", path, req, &resp, true); err != nil {
+ return nil, fmt.Errorf("heartbeat: %w", err)
+ }
+
+ return &resp, nil
+}
+
+func (c *Client) GetNextTask() (*Task, error) {
+ var resp Task
+ if err := c.doRequest("GET", "/api/v1/tasks/next", nil, &resp, true); err != nil {
+ if isNoContent(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get next task: %w", err)
+ }
+ return &resp, nil
+}
+
+func (c *Client) ClaimTask(taskID int64) (*Task, error) {
+ var resp Task
+ path := fmt.Sprintf("/api/v1/tasks/%d/claim", taskID)
+ if err := c.doRequest("PATCH", path, nil, &resp, true); err != nil {
+ return nil, fmt.Errorf("claim task: %w", err)
+ }
+ return &resp, nil
+}
+
+func (c *Client) UpdateTask(taskID int64, updates TaskUpdateRequest) (*Task, error) {
+ var resp Task
+ path := fmt.Sprintf("/api/v1/tasks/%d", taskID)
+ if err := c.doRequest("PATCH", path, updates, &resp, true); err != nil {
+ return nil, fmt.Errorf("update task: %w", err)
+ }
+ return &resp, nil
+}
+
+func (c *Client) CompleteTask(taskID int64) (*Task, error) {
+ var resp Task
+ path := fmt.Sprintf("/api/v1/tasks/%d/complete", taskID)
+ if err := c.doRequest("PATCH", path, nil, &resp, true); err != nil {
+ return nil, fmt.Errorf("complete task: %w", err)
+ }
+ return &resp, nil
+}
+
+func (c *Client) GetNextCommand() (*Command, error) {
+ var resp Command
+ if err := c.doRequest("GET", "/api/v1/agent_commands/next", nil, &resp, true); err != nil {
+ if isNoContent(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get next command: %w", err)
+ }
+ return &resp, nil
+}
+
+func (c *Client) AckCommand(commandID int64) (*Command, error) {
+ var resp Command
+ path := fmt.Sprintf("/api/v1/agent_commands/%d/ack", commandID)
+ if err := c.doRequest("PATCH", path, CommandAckRequest{}, &resp, true); err != nil {
+ return nil, fmt.Errorf("ack command: %w", err)
+ }
+ return &resp, nil
+}
+
+func (c *Client) CompleteCommand(commandID int64, result map[string]any) (*Command, error) {
+ req := CommandCompleteRequest{Result: result}
+ var resp Command
+ path := fmt.Sprintf("/api/v1/agent_commands/%d/complete", commandID)
+ if err := c.doRequest("PATCH", path, req, &resp, true); err != nil {
+ return nil, fmt.Errorf("complete command: %w", err)
+ }
+ return &resp, nil
+}
+
+type noContentError struct{}
+
+func (e *noContentError) Error() string {
+ return "no content"
+}
+
+func isNoContent(err error) bool {
+ _, ok := err.(*noContentError)
+ return ok
+}
+
+func (c *Client) doRequest(method, path string, body any, out any, requireAuth bool) error {
+ var reqBody io.Reader
+ if body != nil {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("marshaling request: %w", err)
+ }
+ reqBody = bytes.NewReader(data)
+ }
+
+ url := c.baseURL + path
+ req, err := http.NewRequest(method, url, reqBody)
+ if err != nil {
+ return fmt.Errorf("creating request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ if requireAuth && c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("executing request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNoContent {
+ return &noContentError{}
+ }
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("reading response: %w", err)
+ }
+
+ if resp.StatusCode >= 400 {
+ var errResp ErrorResponse
+ if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
+ return fmt.Errorf("api error (%d): %s", resp.StatusCode, errResp.Error)
+ }
+ return fmt.Errorf("api error (%d): %s", resp.StatusCode, string(respBody))
+ }
+
+ if out != nil && len(respBody) > 0 {
+ if err := json.Unmarshal(respBody, out); err != nil {
+ return fmt.Errorf("unmarshaling response: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/agent/internal/clawdeck/types.go b/agent/internal/clawdeck/types.go
new file mode 100644
index 00000000..e037adb9
--- /dev/null
+++ b/agent/internal/clawdeck/types.go
@@ -0,0 +1,110 @@
+package clawdeck
+
+import "time"
+
+type Agent struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Hostname string `json:"hostname"`
+ HostUID string `json:"host_uid"`
+ Platform string `json:"platform"`
+ Version string `json:"version"`
+ Tags []string `json:"tags"`
+ Metadata map[string]any `json:"metadata"`
+ LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type Task struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Priority string `json:"priority"`
+ Status string `json:"status"`
+ Blocked bool `json:"blocked"`
+ Tags []string `json:"tags"`
+ Completed bool `json:"completed"`
+ CompletedAt *time.Time `json:"completed_at"`
+ DueDate *time.Time `json:"due_date"`
+ Position int `json:"position"`
+ AssignedToAgent bool `json:"assigned_to_agent"`
+ AssignedAt *time.Time `json:"assigned_at"`
+ AssignedAgentID *int64 `json:"assigned_agent_id"`
+ AgentClaimedAt *time.Time `json:"agent_claimed_at"`
+ ClaimedByAgentID *int64 `json:"claimed_by_agent_id"`
+ BoardID int64 `json:"board_id"`
+ URL string `json:"url"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type Command struct {
+ ID int64 `json:"id"`
+ AgentID int64 `json:"agent_id"`
+ Kind string `json:"kind"`
+ Payload map[string]any `json:"payload"`
+ State string `json:"state"`
+ Result map[string]any `json:"result"`
+ RequestedByUserID int64 `json:"requested_by_user_id"`
+ AckedAt *time.Time `json:"acked_at"`
+ CompletedAt *time.Time `json:"completed_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type RegisterRequest struct {
+ JoinToken string `json:"join_token"`
+ Agent AgentInfo `json:"agent"`
+}
+
+type AgentInfo struct {
+ Name string `json:"name"`
+ Hostname string `json:"hostname"`
+ HostUID string `json:"host_uid"`
+ Platform string `json:"platform"`
+ Version string `json:"version"`
+ Tags []string `json:"tags"`
+ Metadata map[string]string `json:"metadata"`
+}
+
+type RegisterResponse struct {
+ Agent Agent `json:"agent"`
+ AgentToken string `json:"agent_token"`
+}
+
+type HeartbeatRequest struct {
+ Status string `json:"status"`
+ Version string `json:"version,omitempty"`
+ Platform string `json:"platform,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+}
+
+type HeartbeatResponse struct {
+ Agent Agent `json:"agent"`
+ DesiredState DesiredState `json:"desired_state"`
+}
+
+type DesiredState struct {
+ Action string `json:"action"`
+}
+
+type TaskUpdateRequest struct {
+ Status *string `json:"status,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Priority *string `json:"priority,omitempty"`
+ Blocked *bool `json:"blocked,omitempty"`
+ ActivityNote *string `json:"activity_note,omitempty"`
+}
+
+type CommandAckRequest struct{}
+
+type CommandCompleteRequest struct {
+ Result map[string]any `json:"result"`
+}
+
+type ErrorResponse struct {
+ Error string `json:"error"`
+}
diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go
new file mode 100644
index 00000000..c92c3b9b
--- /dev/null
+++ b/agent/internal/config/config.go
@@ -0,0 +1,136 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ DefaultAPIURL = "http://localhost:3000"
+ DefaultHeartbeatDelay = 30 * time.Second
+ DefaultTaskPollDelay = 5 * time.Second
+ DefaultCommandPollDelay = 5 * time.Second
+ TokenFilePermissions = 0600
+)
+
+type Config struct {
+ APIURL string
+ JoinToken string
+ AgentTokenPath string
+ HeartbeatDelay time.Duration
+ TaskPollDelay time.Duration
+ CommandPollDelay time.Duration
+ AgentInfo AgentInfo
+}
+
+type AgentInfo struct {
+ Name string `json:"name"`
+ Hostname string `json:"hostname"`
+ HostUID string `json:"host_uid"`
+ Platform string `json:"platform"`
+ Version string `json:"version"`
+ Tags []string `json:"tags"`
+ Metadata map[string]string `json:"metadata"`
+}
+
+type persistedToken struct {
+ AgentID int64 `json:"agent_id"`
+ Token string `json:"token"`
+ StoredAt string `json:"stored_at"`
+}
+
+func Load() (*Config, error) {
+ cfg := &Config{
+ APIURL: getEnv("CLAWDECK_API_URL", DefaultAPIURL),
+ JoinToken: os.Getenv("CLAWDECK_JOIN_TOKEN"),
+ AgentTokenPath: getEnv("CLAWDECK_AGENT_TOKEN_PATH", ""),
+ HeartbeatDelay: DefaultHeartbeatDelay,
+ TaskPollDelay: DefaultTaskPollDelay,
+ CommandPollDelay: DefaultCommandPollDelay,
+ AgentInfo: AgentInfo{
+ Name: getEnv("CLAWDECK_AGENT_NAME", "claw-agent"),
+ Hostname: getEnv("CLAWDECK_HOSTNAME", ""),
+ HostUID: getEnv("CLAWDECK_HOST_UID", ""),
+ Platform: getEnv("CLAWDECK_PLATFORM", ""),
+ Version: getEnv("CLAWDECK_VERSION", "0.1.0"),
+ },
+ }
+
+ if cfg.AgentInfo.Hostname == "" {
+ if h, err := os.Hostname(); err == nil {
+ cfg.AgentInfo.Hostname = h
+ }
+ }
+
+ return cfg, nil
+}
+
+func (c *Config) LoadPersistedToken() (agentID int64, token string, err error) {
+ if c.AgentTokenPath == "" {
+ return 0, "", nil
+ }
+
+ data, err := os.ReadFile(c.AgentTokenPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return 0, "", nil
+ }
+ return 0, "", fmt.Errorf("reading token file: %w", err)
+ }
+
+ var pt persistedToken
+ if err := json.Unmarshal(data, &pt); err != nil {
+ return 0, "", fmt.Errorf("parsing token file: %w", err)
+ }
+
+ return pt.AgentID, pt.Token, nil
+}
+
+func (c *Config) SaveToken(agentID int64, token string) error {
+ if c.AgentTokenPath == "" {
+ return nil
+ }
+
+ pt := persistedToken{
+ AgentID: agentID,
+ Token: token,
+ StoredAt: time.Now().UTC().Format(time.RFC3339),
+ }
+
+ data, err := json.MarshalIndent(pt, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling token: %w", err)
+ }
+
+ dir := filepath.Dir(c.AgentTokenPath)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return fmt.Errorf("creating token directory: %w", err)
+ }
+
+ if err := os.WriteFile(c.AgentTokenPath, data, TokenFilePermissions); err != nil {
+ return fmt.Errorf("writing token file: %w", err)
+ }
+
+ return nil
+}
+
+func (c *Config) ClearToken() error {
+ if c.AgentTokenPath == "" {
+ return nil
+ }
+
+ if err := os.Remove(c.AgentTokenPath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("removing token file: %w", err)
+ }
+ return nil
+}
+
+func getEnv(key, fallback string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return fallback
+}
diff --git a/agent/internal/orchestrator/command_loop.go b/agent/internal/orchestrator/command_loop.go
new file mode 100644
index 00000000..136f510e
--- /dev/null
+++ b/agent/internal/orchestrator/command_loop.go
@@ -0,0 +1,96 @@
+package orchestrator
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/mojomast/uberclawcontrol/agent/internal/clawdeck"
+)
+
+type CommandRunner struct {
+ client *clawdeck.Client
+ interval time.Duration
+ handlers map[string]CommandHandler
+}
+
+type CommandHandler func(ctx context.Context, cmd *clawdeck.Command) map[string]any
+
+func NewCommandRunner(client *clawdeck.Client, interval time.Duration) *CommandRunner {
+ if interval == 0 {
+ interval = 5 * time.Second
+ }
+ cr := &CommandRunner{
+ client: client,
+ interval: interval,
+ handlers: make(map[string]CommandHandler),
+ }
+ cr.registerDefaultHandlers()
+ return cr
+}
+
+func (c *CommandRunner) Run(ctx context.Context) error {
+ ticker := time.NewTicker(c.interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ log.Printf("command runner stopping: %v", ctx.Err())
+ return ctx.Err()
+ case <-ticker.C:
+ c.pollAndHandle(ctx)
+ }
+ }
+}
+
+func (c *CommandRunner) pollAndHandle(ctx context.Context) {
+ cmd, err := c.client.GetNextCommand()
+ if err != nil {
+ log.Printf("failed to get next command: %v", err)
+ return
+ }
+
+ if cmd == nil {
+ return
+ }
+
+ log.Printf("received command id=%d kind=%s state=%s", cmd.ID, cmd.Kind, cmd.State)
+
+ _, err = c.client.AckCommand(cmd.ID)
+ if err != nil {
+ log.Printf("failed to ack command %d: %v", cmd.ID, err)
+ return
+ }
+ log.Printf("acknowledged command id=%d", cmd.ID)
+
+ result := c.dispatch(ctx, cmd)
+
+ _, err = c.client.CompleteCommand(cmd.ID, result)
+ if err != nil {
+ log.Printf("failed to complete command %d: %v", cmd.ID, err)
+ return
+ }
+ log.Printf("completed command id=%d", cmd.ID)
+}
+
+func (c *CommandRunner) dispatch(ctx context.Context, cmd *clawdeck.Command) map[string]any {
+ handler, ok := c.handlers[cmd.Kind]
+ if !ok {
+ log.Printf("no handler for command kind=%s", cmd.Kind)
+ return map[string]any{"success": false, "error": "unknown command kind"}
+ }
+
+ return handler(ctx, cmd)
+}
+
+func (c *CommandRunner) RegisterHandler(kind string, handler CommandHandler) {
+ c.handlers[kind] = handler
+}
+
+func (c *CommandRunner) registerDefaultHandlers() {
+ c.RegisterHandler("drain", HandleDrain)
+ c.RegisterHandler("resume", HandleResume)
+ c.RegisterHandler("restart", HandleRestart)
+ c.RegisterHandler("upgrade", HandleUpgrade)
+}
diff --git a/agent/internal/orchestrator/handlers.go b/agent/internal/orchestrator/handlers.go
new file mode 100644
index 00000000..42b1a884
--- /dev/null
+++ b/agent/internal/orchestrator/handlers.go
@@ -0,0 +1,48 @@
+package orchestrator
+
+import (
+ "context"
+ "log"
+
+ "github.com/mojomast/uberclawcontrol/agent/internal/clawdeck"
+)
+
+func HandleDrain(ctx context.Context, cmd *clawdeck.Command) map[string]any {
+ log.Printf("handling drain command: %+v", cmd.Payload)
+ return map[string]any{
+ "success": true,
+ "message": "drain initiated",
+ }
+}
+
+func HandleResume(ctx context.Context, cmd *clawdeck.Command) map[string]any {
+ log.Printf("handling resume command: %+v", cmd.Payload)
+ return map[string]any{
+ "success": true,
+ "message": "resumed accepting tasks",
+ }
+}
+
+func HandleRestart(ctx context.Context, cmd *clawdeck.Command) map[string]any {
+ log.Printf("handling restart command: %+v", cmd.Payload)
+ return map[string]any{
+ "success": false,
+ "error": "restart not implemented in stub handler",
+ }
+}
+
+func HandleUpgrade(ctx context.Context, cmd *clawdeck.Command) map[string]any {
+ log.Printf("handling upgrade command: %+v", cmd.Payload)
+ version, _ := cmd.Payload["version"].(string)
+ if version == "" {
+ return map[string]any{
+ "success": false,
+ "error": "version required",
+ }
+ }
+ return map[string]any{
+ "success": false,
+ "error": "upgrade not implemented in stub handler",
+ "requested_version": version,
+ }
+}
diff --git a/agent/internal/runner/executor.go b/agent/internal/runner/executor.go
new file mode 100644
index 00000000..1d3fd38f
--- /dev/null
+++ b/agent/internal/runner/executor.go
@@ -0,0 +1,26 @@
+package runner
+
+import (
+ "context"
+
+ "github.com/mojomast/uberclawcontrol/agent/internal/clawdeck"
+)
+
+type Executor interface {
+ Execute(ctx context.Context, task *clawdeck.Task) ExecutionResult
+}
+
+type ExecutionResult struct {
+ Completed bool
+ Error error
+ Output string
+}
+
+type StubExecutor struct{}
+
+func (s *StubExecutor) Execute(ctx context.Context, task *clawdeck.Task) ExecutionResult {
+ return ExecutionResult{
+ Completed: true,
+ Output: "stub execution completed",
+ }
+}
diff --git a/agent/internal/runner/heartbeat.go b/agent/internal/runner/heartbeat.go
new file mode 100644
index 00000000..a813774c
--- /dev/null
+++ b/agent/internal/runner/heartbeat.go
@@ -0,0 +1,83 @@
+package runner
+
+import (
+ "context"
+ "log"
+ "runtime"
+ "time"
+
+ "github.com/mojomast/uberclawcontrol/agent/internal/clawdeck"
+)
+
+type HeartbeatRunner struct {
+ client *clawdeck.Client
+ interval time.Duration
+ agentID int64
+ lastStatus string
+}
+
+func NewHeartbeatRunner(client *clawdeck.Client, agentID int64, interval time.Duration) *HeartbeatRunner {
+ if interval == 0 {
+ interval = 30 * time.Second
+ }
+ return &HeartbeatRunner{
+ client: client,
+ interval: interval,
+ agentID: agentID,
+ }
+}
+
+func (h *HeartbeatRunner) Run(ctx context.Context) error {
+ ticker := time.NewTicker(h.interval)
+ defer ticker.Stop()
+
+ h.sendHeartbeat(ctx)
+
+ for {
+ select {
+ case <-ctx.Done():
+ log.Printf("heartbeat runner stopping: %v", ctx.Err())
+ return ctx.Err()
+ case <-ticker.C:
+ h.sendHeartbeat(ctx)
+ }
+ }
+}
+
+func (h *HeartbeatRunner) sendHeartbeat(ctx context.Context) {
+ metadata := h.collectMetadata()
+ status := "online"
+ if h.lastStatus != "" {
+ status = h.lastStatus
+ }
+
+ resp, err := h.client.Heartbeat(h.agentID, status, metadata)
+ if err != nil {
+ log.Printf("heartbeat failed: %v", err)
+ return
+ }
+
+ log.Printf("heartbeat ok: agent status=%s desired_state=%s",
+ resp.Agent.Status, resp.DesiredState.Action)
+
+ if resp.DesiredState.Action != "" && resp.DesiredState.Action != "none" {
+ log.Printf("desired state action: %s", resp.DesiredState.Action)
+ }
+}
+
+func (h *HeartbeatRunner) collectMetadata() map[string]any {
+ var memStats runtime.MemStats
+ runtime.ReadMemStats(&memStats)
+
+ return map[string]any{
+ "goroutines": runtime.NumGoroutine(),
+ "go_version": runtime.Version(),
+ "alloc_mb": memStats.Alloc / 1024 / 1024,
+ "sys_mb": memStats.Sys / 1024 / 1024,
+ "num_cpu": runtime.NumCPU(),
+ }
+}
+
+func (h *HeartbeatRunner) SetStatus(status string) {
+ h.lastStatus = status
+}
diff --git a/agent/internal/runner/task_loop.go b/agent/internal/runner/task_loop.go
new file mode 100644
index 00000000..b2f2668f
--- /dev/null
+++ b/agent/internal/runner/task_loop.go
@@ -0,0 +1,86 @@
+package runner
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/mojomast/uberclawcontrol/agent/internal/clawdeck"
+)
+
+type TaskRunner struct {
+ client *clawdeck.Client
+ interval time.Duration
+ executor Executor
+}
+
+func NewTaskRunner(client *clawdeck.Client, interval time.Duration, executor Executor) *TaskRunner {
+ if interval == 0 {
+ interval = 5 * time.Second
+ }
+ if executor == nil {
+ executor = &StubExecutor{}
+ }
+ return &TaskRunner{
+ client: client,
+ interval: interval,
+ executor: executor,
+ }
+}
+
+func (t *TaskRunner) Run(ctx context.Context) error {
+ ticker := time.NewTicker(t.interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ log.Printf("task runner stopping: %v", ctx.Err())
+ return ctx.Err()
+ case <-ticker.C:
+ t.pollAndExecute(ctx)
+ }
+ }
+}
+
+func (t *TaskRunner) pollAndExecute(ctx context.Context) {
+ task, err := t.client.GetNextTask()
+ if err != nil {
+ log.Printf("failed to get next task: %v", err)
+ return
+ }
+
+ if task == nil {
+ return
+ }
+
+ log.Printf("received task id=%d name=%q status=%s", task.ID, task.Name, task.Status)
+
+ if task.ClaimedByAgentID == nil {
+ claimed, err := t.client.ClaimTask(task.ID)
+ if err != nil {
+ log.Printf("failed to claim task %d: %v", task.ID, err)
+ return
+ }
+ task = claimed
+ log.Printf("claimed task id=%d", task.ID)
+ }
+
+ result := t.executor.Execute(ctx, task)
+
+ if result.Error != nil {
+ log.Printf("task %d execution failed: %v", task.ID, result.Error)
+ return
+ }
+
+ if result.Completed {
+ _, err = t.client.CompleteTask(task.ID)
+ if err != nil {
+ log.Printf("failed to complete task %d: %v", task.ID, err)
+ return
+ }
+ log.Printf("task %d completed", task.ID)
+ }
+}
+
+
diff --git a/app/controllers/agent_commands_controller.rb b/app/controllers/agent_commands_controller.rb
new file mode 100644
index 00000000..1d0ad6c3
--- /dev/null
+++ b/app/controllers/agent_commands_controller.rb
@@ -0,0 +1,25 @@
+class AgentCommandsController < ApplicationController
+ before_action :set_agent
+
+ def create
+ @command = @agent.agent_commands.build(command_params)
+ @command.requested_by_user = current_user
+ @command.state = :pending
+
+ if @command.save
+ redirect_to agent_path(@agent), notice: "#{@command.kind.humanize} command queued."
+ else
+ redirect_to agent_path(@agent), alert: "Failed to queue command: #{@command.errors.full_messages.join(', ')}"
+ end
+ end
+
+ private
+
+ def set_agent
+ @agent = current_user.agents.find(params[:agent_id])
+ end
+
+ def command_params
+ params.require(:agent_command).permit(:kind, :payload)
+ end
+end
diff --git a/app/controllers/agents_controller.rb b/app/controllers/agents_controller.rb
new file mode 100644
index 00000000..a007c494
--- /dev/null
+++ b/app/controllers/agents_controller.rb
@@ -0,0 +1,19 @@
+class AgentsController < ApplicationController
+ before_action :set_agent, only: [:show]
+
+ def index
+ @agents = current_user.agents.order(last_heartbeat_at: :desc)
+ end
+
+ def show
+ @commands = @agent.agent_commands.order(created_at: :desc).limit(20)
+ @tasks_assigned = @agent.assigned_tasks.order(updated_at: :desc).limit(10)
+ @tasks_claimed = @agent.claimed_tasks.order(updated_at: :desc).limit(10)
+ end
+
+ private
+
+ def set_agent
+ @agent = current_user.agents.find(params[:id])
+ end
+end
diff --git a/app/controllers/api/v1/agent_commands_controller.rb b/app/controllers/api/v1/agent_commands_controller.rb
new file mode 100644
index 00000000..da0477f2
--- /dev/null
+++ b/app/controllers/api/v1/agent_commands_controller.rb
@@ -0,0 +1,108 @@
+module Api
+ module V1
+ class AgentCommandsController < BaseController
+ before_action :set_agent, only: [ :enqueue ]
+ before_action :set_agent_command, only: [ :ack, :complete ]
+ before_action :require_current_agent!, only: [ :next, :ack, :complete ]
+ before_action :require_command_ownership!, only: [ :ack, :complete ]
+
+ def enqueue
+ unless current_user.admin? || current_user.id == @agent.user_id
+ render json: { error: "Forbidden" }, status: :forbidden
+ return
+ end
+
+ command = @agent.agent_commands.create!(
+ kind: params[:kind],
+ payload: params[:payload] || {},
+ requested_by_user: current_user
+ )
+
+ render json: agent_command_json(command), status: :created
+ end
+
+ def next
+ command = nil
+
+ AgentCommand.transaction do
+ command = current_agent.agent_commands.pending
+ .order(created_at: :asc)
+ .lock("FOR UPDATE SKIP LOCKED")
+ .first
+
+ if command
+ command.update!(state: :acknowledged, acked_at: Time.current)
+ end
+ end
+
+ if command
+ render json: agent_command_json(command)
+ else
+ head :no_content
+ end
+ end
+
+ def ack
+ unless @agent_command.pending?
+ render json: { error: "Command must be pending to acknowledge" }, status: :unprocessable_entity
+ return
+ end
+
+ @agent_command.update!(state: :acknowledged, acked_at: Time.current)
+ render json: agent_command_json(@agent_command)
+ end
+
+ def complete
+ unless @agent_command.acknowledged?
+ render json: { error: "Command must be acknowledged to complete" }, status: :unprocessable_entity
+ return
+ end
+
+ @agent_command.update!(
+ state: :completed,
+ completed_at: Time.current,
+ result: params[:result] || {}
+ )
+ render json: agent_command_json(@agent_command)
+ end
+
+ private
+
+ def set_agent
+ @agent = Agent.find(params[:id])
+ end
+
+ def set_agent_command
+ @agent_command = AgentCommand.find(params[:id])
+ end
+
+ def require_current_agent!
+ return if current_agent
+
+ render json: { error: "Unauthorized" }, status: :unauthorized
+ end
+
+ def require_command_ownership!
+ return if current_agent.id == @agent_command.agent_id
+
+ render json: { error: "Forbidden" }, status: :forbidden
+ end
+
+ def agent_command_json(command)
+ {
+ id: command.id,
+ agent_id: command.agent_id,
+ kind: command.kind,
+ payload: command.payload,
+ state: command.state,
+ result: command.result,
+ requested_by_user_id: command.requested_by_user_id,
+ acked_at: command.acked_at&.iso8601,
+ completed_at: command.completed_at&.iso8601,
+ created_at: command.created_at.iso8601,
+ updated_at: command.updated_at.iso8601
+ }
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/agents_controller.rb b/app/controllers/api/v1/agents_controller.rb
new file mode 100644
index 00000000..abc413f4
--- /dev/null
+++ b/app/controllers/api/v1/agents_controller.rb
@@ -0,0 +1,114 @@
+module Api
+ module V1
+ class AgentsController < BaseController
+ skip_before_action :authenticate_api_token, only: :register
+ before_action :set_agent, only: [ :show, :update ]
+ before_action :set_agent_for_heartbeat, only: [ :heartbeat ]
+ before_action :require_current_agent!, only: :heartbeat
+ before_action :require_agent_self!, only: :heartbeat
+
+ def register
+ join_token = JoinToken.consume!(register_join_token)
+ unless join_token
+ render json: { error: "Invalid join token" }, status: :unauthorized
+ return
+ end
+
+ agent = join_token.user.agents.new(register_params)
+ if agent.save
+ _agent_token, plaintext_token = AgentToken.issue!(agent: agent, name: "Bootstrap")
+ render json: { agent: agent_json(agent), agent_token: plaintext_token }, status: :created
+ else
+ render json: { error: agent.errors.full_messages.join(", ") }, status: :unprocessable_entity
+ end
+ end
+
+ def heartbeat
+ updates = {
+ last_heartbeat_at: Time.current,
+ status: params[:status].presence || :online
+ }
+ updates[:version] = params[:version] if params.key?(:version)
+ updates[:platform] = params[:platform] if params.key?(:platform)
+ updates[:metadata] = params[:metadata] if params.key?(:metadata)
+
+ @agent.update!(updates)
+ render json: {
+ agent: agent_json(@agent),
+ desired_state: { action: "none" }
+ }
+ end
+
+ def index
+ agents = current_user.agents.order(created_at: :desc)
+ render json: agents.map { |agent| agent_json(agent) }
+ end
+
+ def show
+ render json: agent_json(@agent)
+ end
+
+ def update
+ if @agent.update(update_params)
+ render json: agent_json(@agent)
+ else
+ render json: { error: @agent.errors.full_messages.join(", ") }, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def set_agent
+ @agent = current_user.agents.find(params[:id])
+ end
+
+ def set_agent_for_heartbeat
+ @agent = Agent.find(params[:id])
+ end
+
+ def require_current_agent!
+ return if current_agent
+
+ render json: { error: "Unauthorized" }, status: :unauthorized
+ end
+
+ def require_agent_self!
+ return if current_agent.id == @agent.id
+
+ render json: { error: "Forbidden" }, status: :forbidden
+ end
+
+ def register_join_token
+ params[:join_token] || params.dig(:agent, :join_token)
+ end
+
+ def register_params
+ params.fetch(:agent, ActionController::Parameters.new)
+ .permit(:name, :hostname, :host_uid, :platform, :version, tags: [], metadata: {})
+ end
+
+ def update_params
+ params.fetch(:agent, ActionController::Parameters.new)
+ .permit(:name, :status, tags: [], metadata: {})
+ end
+
+ def agent_json(agent)
+ {
+ id: agent.id,
+ user_id: agent.user_id,
+ name: agent.name,
+ status: agent.status,
+ hostname: agent.hostname,
+ host_uid: agent.host_uid,
+ platform: agent.platform,
+ version: agent.version,
+ tags: agent.tags || [],
+ metadata: agent.metadata || {},
+ last_heartbeat_at: agent.last_heartbeat_at&.iso8601,
+ created_at: agent.created_at.iso8601,
+ updated_at: agent.updated_at.iso8601
+ }
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb
index 83a717e3..05f5e26c 100644
--- a/app/controllers/api/v1/tasks_controller.rb
+++ b/app/controllers/api/v1/tasks_controller.rb
@@ -2,6 +2,7 @@ module Api
module V1
class TasksController < BaseController
before_action :set_task, only: [ :show, :update, :destroy, :complete, :claim, :unclaim, :assign, :unassign ]
+ before_action :require_current_agent!, only: [ :next, :claim, :unclaim ]
# GET /api/v1/tasks/next - get next task for agent to work on
# Returns highest priority unclaimed task in "up_next" status
@@ -13,10 +14,29 @@ def next
return
end
- @task = current_user.tasks
- .where(status: :up_next, blocked: false, agent_claimed_at: nil)
- .reorder(priority: :desc, position: :asc)
- .first
+ if current_agent.draining?
+ head :no_content
+ return
+ end
+
+ @task = nil
+
+ Task.transaction do
+ @task = current_user.tasks
+ .eligible_for_agent(current_agent)
+ .reorder(priority: :desc, position: :asc)
+ .lock("FOR UPDATE SKIP LOCKED")
+ .first
+
+ if @task
+ set_task_activity_info(@task)
+ @task.update!(
+ claimed_by_agent: current_agent,
+ agent_claimed_at: Time.current,
+ status: :in_progress
+ )
+ end
+ end
if @task
render json: task_json(@task)
@@ -44,14 +64,18 @@ def pending_attention
# PATCH /api/v1/tasks/:id/claim - agent claims a task
def claim
set_task_activity_info(@task)
- @task.update!(agent_claimed_at: Time.current, status: :in_progress)
+ @task.update!(
+ claimed_by_agent: current_agent,
+ agent_claimed_at: Time.current,
+ status: :in_progress
+ )
render json: task_json(@task)
end
# PATCH /api/v1/tasks/:id/unclaim - agent releases a task
def unclaim
set_task_activity_info(@task)
- @task.update!(agent_claimed_at: nil)
+ @task.update!(claimed_by_agent: nil, agent_claimed_at: nil)
render json: task_json(@task)
end
@@ -176,11 +200,19 @@ def set_task
def set_task_activity_info(task)
task.activity_source = "api"
+ task.actor_user = current_user
+ task.actor_agent = current_agent
task.actor_name = request.headers["X-Agent-Name"]
task.actor_emoji = request.headers["X-Agent-Emoji"]
task.activity_note = params[:activity_note] || params.dig(:task, :activity_note)
end
+ def require_current_agent!
+ return if current_agent
+
+ render json: { error: "Unauthorized" }, status: :unauthorized
+ end
+
def task_params
params.require(:task).permit(:name, :description, :priority, :due_date, :status, :blocked, :board_id, tags: [])
end
@@ -200,7 +232,9 @@ def task_json(task)
position: task.position,
assigned_to_agent: task.assigned_to_agent,
assigned_at: task.assigned_at&.iso8601,
+ assigned_agent_id: task.assigned_agent_id,
agent_claimed_at: task.agent_claimed_at&.iso8601,
+ claimed_by_agent_id: task.claimed_by_agent_id,
board_id: task.board_id,
url: "https://clawdeck.io/boards/#{task.board_id}/tasks/#{task.id}",
created_at: task.created_at.iso8601,
diff --git a/app/controllers/boards/tasks_controller.rb b/app/controllers/boards/tasks_controller.rb
index 884d47d6..99d13481 100644
--- a/app/controllers/boards/tasks_controller.rb
+++ b/app/controllers/boards/tasks_controller.rb
@@ -59,7 +59,17 @@ def destroy
def assign
@task.activity_source = "web"
- @task.assign_to_agent!
+ agent_id = params[:agent_id]
+ if agent_id.present? && agent_id != ""
+ @agent = current_user.agents.find_by(id: agent_id)
+ if @agent
+ @task.update!(assigned_agent_id: @agent.id, assigned_to_agent: true, assigned_at: Time.current)
+ else
+ @task.update!(assigned_agent_id: nil, assigned_to_agent: true, assigned_at: Time.current)
+ end
+ else
+ @task.update!(assigned_agent_id: nil, assigned_to_agent: true, assigned_at: Time.current)
+ end
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
@@ -73,7 +83,7 @@ def assign
def unassign
@task.activity_source = "web"
- @task.unassign_from_agent!
+ @task.update!(assigned_agent_id: nil, assigned_to_agent: false, assigned_at: nil)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
@@ -96,7 +106,7 @@ def set_task
end
def task_params
- permitted = params.require(:task).permit(:name, :title, :description, :priority, :status, :blocked, :due_date, :completed, :agent_hint, tags: [])
+ permitted = params.require(:task).permit(:name, :title, :description, :priority, :status, :blocked, :due_date, :completed, :agent_hint, :assigned_agent_id, tags: [])
# Allow 'title' as alias for 'name'
permitted[:name] = permitted.delete(:title) if permitted[:title].present? && permitted[:name].blank?
permitted
diff --git a/app/controllers/concerns/api/token_authentication.rb b/app/controllers/concerns/api/token_authentication.rb
index 1fe7d9de..0d062e8f 100644
--- a/app/controllers/concerns/api/token_authentication.rb
+++ b/app/controllers/concerns/api/token_authentication.rb
@@ -5,21 +5,29 @@ module TokenAuthentication
included do
before_action :authenticate_api_token
after_action :track_api_usage
- attr_reader :current_user
+ attr_reader :current_user, :current_agent
end
private
def authenticate_api_token
token = extract_token_from_header
- @current_user = ApiToken.authenticate(token)
+ agent_token = AgentToken.authenticate(token)
+
+ if agent_token
+ @current_agent = agent_token.agent
+ @current_user = @current_agent.user
+ else
+ @current_agent = nil
+ @current_user = ApiToken.authenticate(token)
+ end
unless @current_user
render json: { error: "Unauthorized" }, status: :unauthorized
return
end
- update_agent_info_from_headers
+ update_agent_info_from_headers if @current_agent.nil?
end
def extract_token_from_header
diff --git a/app/models/agent.rb b/app/models/agent.rb
new file mode 100644
index 00000000..7fbb23a0
--- /dev/null
+++ b/app/models/agent.rb
@@ -0,0 +1,30 @@
+class Agent < ApplicationRecord
+ belongs_to :user
+
+ has_many :assigned_tasks,
+ class_name: "Task",
+ foreign_key: :assigned_agent_id,
+ inverse_of: :assigned_agent,
+ dependent: :nullify
+ has_many :claimed_tasks,
+ class_name: "Task",
+ foreign_key: :claimed_by_agent_id,
+ inverse_of: :claimed_by_agent,
+ dependent: :nullify
+ has_many :agent_tokens, dependent: :destroy
+ has_many :agent_commands, dependent: :destroy
+ has_many :task_activities,
+ class_name: "TaskActivity",
+ foreign_key: :actor_agent_id,
+ inverse_of: :actor_agent,
+ dependent: :nullify
+
+ enum :status, {
+ offline: 0,
+ online: 1,
+ draining: 2,
+ disabled: 3
+ }, default: :offline
+
+ validates :name, presence: true
+end
diff --git a/app/models/agent_command.rb b/app/models/agent_command.rb
new file mode 100644
index 00000000..76b2d391
--- /dev/null
+++ b/app/models/agent_command.rb
@@ -0,0 +1,16 @@
+class AgentCommand < ApplicationRecord
+ belongs_to :agent
+ belongs_to :requested_by_user, class_name: "User", optional: true
+
+ enum :state, {
+ pending: 0,
+ acknowledged: 1,
+ completed: 2,
+ failed: 3
+ }, default: :pending
+
+ validates :kind, presence: true
+
+ scope :for_agent, ->(agent) { where(agent: agent) }
+ scope :pending_for, ->(agent) { for_agent(agent).pending }
+end
diff --git a/app/models/agent_token.rb b/app/models/agent_token.rb
new file mode 100644
index 00000000..6bdbb3fd
--- /dev/null
+++ b/app/models/agent_token.rb
@@ -0,0 +1,42 @@
+class AgentToken < ApplicationRecord
+ TOKEN_BYTES = 32
+
+ belongs_to :agent
+
+ validates :token_digest, presence: true, uniqueness: true
+
+ def self.issue!(agent:, name: nil)
+ plaintext_token = SecureRandom.hex(TOKEN_BYTES)
+
+ agent_token = create!(
+ agent: agent,
+ name: name,
+ token_digest: digest_token(plaintext_token)
+ )
+
+ [ agent_token, plaintext_token ]
+ end
+
+ def self.authenticate(plaintext_token)
+ return nil if plaintext_token.blank?
+
+ candidate_digest = digest_token(plaintext_token)
+ agent_token = find_by(token_digest: candidate_digest)
+ return nil unless agent_token
+ return nil unless secure_digest_compare(agent_token.token_digest, candidate_digest)
+
+ agent_token.touch(:last_used_at)
+ agent_token
+ end
+
+ def self.digest_token(plaintext_token)
+ OpenSSL::Digest::SHA256.hexdigest(plaintext_token.to_s)
+ end
+
+ def self.secure_digest_compare(stored_digest, candidate_digest)
+ return false if stored_digest.blank? || candidate_digest.blank?
+ return false unless stored_digest.bytesize == candidate_digest.bytesize
+
+ ActiveSupport::SecurityUtils.secure_compare(stored_digest, candidate_digest)
+ end
+end
diff --git a/app/models/join_token.rb b/app/models/join_token.rb
new file mode 100644
index 00000000..f91c568e
--- /dev/null
+++ b/app/models/join_token.rb
@@ -0,0 +1,52 @@
+class JoinToken < ApplicationRecord
+ TOKEN_BYTES = 32
+
+ belongs_to :user
+ belongs_to :created_by_user, class_name: "User", optional: true, inverse_of: :created_join_tokens
+
+ validates :token_digest, presence: true, uniqueness: true
+ validates :expires_at, presence: true
+
+ def self.issue!(user:, created_by_user: nil, expires_in: 24.hours)
+ plaintext_token = SecureRandom.hex(TOKEN_BYTES)
+
+ join_token = create!(
+ user: user,
+ created_by_user: created_by_user,
+ token_digest: digest_token(plaintext_token),
+ expires_at: Time.current + expires_in
+ )
+
+ [ join_token, plaintext_token ]
+ end
+
+ def self.consume!(plaintext_token, user: nil)
+ return nil if plaintext_token.blank?
+
+ candidate_digest = digest_token(plaintext_token)
+ join_token = find_by(token_digest: candidate_digest)
+ return nil unless join_token
+ return nil unless secure_digest_compare(join_token.token_digest, candidate_digest)
+
+ join_token.with_lock do
+ return nil if user.present? && join_token.user_id != user.id
+ return nil if join_token.used_at.present?
+ return nil if join_token.expires_at <= Time.current
+
+ join_token.update!(used_at: Time.current)
+ end
+
+ join_token
+ end
+
+ def self.digest_token(plaintext_token)
+ OpenSSL::Digest::SHA256.hexdigest(plaintext_token.to_s)
+ end
+
+ def self.secure_digest_compare(stored_digest, candidate_digest)
+ return false if stored_digest.blank? || candidate_digest.blank?
+ return false unless stored_digest.bytesize == candidate_digest.bytesize
+
+ ActiveSupport::SecurityUtils.secure_compare(stored_digest, candidate_digest)
+ end
+end
diff --git a/app/models/task.rb b/app/models/task.rb
index 20556c6a..c5eb209d 100644
--- a/app/models/task.rb
+++ b/app/models/task.rb
@@ -1,6 +1,8 @@
class Task < ApplicationRecord
belongs_to :user
belongs_to :board
+ belongs_to :assigned_agent, class_name: "Agent", optional: true
+ belongs_to :claimed_by_agent, class_name: "Agent", optional: true
has_many :activities, class_name: "TaskActivity", dependent: :destroy
has_many :subtasks, dependent: :destroy
@@ -12,7 +14,7 @@ class Task < ApplicationRecord
validates :status, inclusion: { in: statuses.keys }
# Activity tracking - must be declared before callbacks that use it
- attr_accessor :activity_source, :actor_name, :actor_emoji, :activity_note
+ attr_accessor :activity_source, :actor_name, :actor_emoji, :activity_note, :actor_user, :actor_agent
# Store activity_source before commit so it survives the transaction
before_save :store_activity_source_for_broadcast
@@ -35,6 +37,12 @@ class Task < ApplicationRecord
scope :completed, -> { where(completed: true).reorder(completed_at: :desc) }
scope :assigned_to_agent, -> { where(assigned_to_agent: true).reorder(assigned_at: :asc) }
scope :unassigned, -> { where(assigned_to_agent: false) }
+ scope :eligible_for_agent, ->(agent) do
+ return none unless agent
+
+ where(status: :up_next, blocked: false, claimed_by_agent_id: nil)
+ .where(assigned_agent_id: [ nil, agent.id ])
+ end
default_scope { order(completed: :asc, position: :asc) }
# Agent assignment methods
@@ -77,7 +85,15 @@ def track_completion_time
end
def record_creation_activity
- TaskActivity.record_creation(self, source: activity_source || "web", actor_name: actor_name, actor_emoji: actor_emoji, note: activity_note)
+ TaskActivity.record_creation(
+ self,
+ source: activity_source || "web",
+ actor_user: actor_user || user,
+ actor_agent: actor_agent,
+ actor_name: actor_name,
+ actor_emoji: actor_emoji,
+ note: activity_note
+ )
end
def record_update_activities
@@ -86,12 +102,33 @@ def record_update_activities
# Track status/column changes
if saved_change_to_status?
old_status, new_status = saved_change_to_status
- TaskActivity.record_status_change(self, old_status: old_status, new_status: new_status, source: source, actor_name: actor_name, actor_emoji: actor_emoji, note: activity_note)
+ TaskActivity.record_status_change(
+ self,
+ old_status: old_status,
+ new_status: new_status,
+ source: source,
+ actor_user: actor_user || user,
+ actor_agent: actor_agent,
+ actor_name: actor_name,
+ actor_emoji: actor_emoji,
+ note: activity_note
+ )
end
# Track field changes
tracked_changes = saved_changes.slice(*TaskActivity::TRACKED_FIELDS)
- TaskActivity.record_changes(self, tracked_changes, source: source, actor_name: actor_name, actor_emoji: actor_emoji, note: activity_note) if tracked_changes.any?
+ if tracked_changes.any?
+ TaskActivity.record_changes(
+ self,
+ tracked_changes,
+ source: source,
+ actor_user: actor_user || user,
+ actor_agent: actor_agent,
+ actor_name: actor_name,
+ actor_emoji: actor_emoji,
+ note: activity_note
+ )
+ end
end
# Turbo Streams broadcasts for real-time updates
diff --git a/app/models/task_activity.rb b/app/models/task_activity.rb
index 3eb8e1f1..0fd6eb4a 100644
--- a/app/models/task_activity.rb
+++ b/app/models/task_activity.rb
@@ -1,18 +1,20 @@
class TaskActivity < ApplicationRecord
belongs_to :task
belongs_to :user, optional: true
+ belongs_to :actor_agent, class_name: "Agent", optional: true
validates :action, presence: true
ACTIONS = %w[created updated moved].freeze
- TRACKED_FIELDS = %w[name due_date].freeze
+ TRACKED_FIELDS = %w[name due_date claimed_by_agent_id].freeze
scope :recent, -> { order(created_at: :desc) }
- def self.record_creation(task, source: "web", actor_name: nil, actor_emoji: nil, note: nil)
+ def self.record_creation(task, source: "web", actor_user: nil, actor_agent: nil, actor_name: nil, actor_emoji: nil, note: nil)
create!(
task: task,
- user: task.user,
+ user: actor_user || task.user,
+ actor_agent: actor_agent,
action: "created",
source: source,
actor_type: source == "api" ? "agent" : "user",
@@ -22,10 +24,11 @@ def self.record_creation(task, source: "web", actor_name: nil, actor_emoji: nil,
)
end
- def self.record_status_change(task, old_status:, new_status:, source: "web", actor_name: nil, actor_emoji: nil, note: nil)
+ def self.record_status_change(task, old_status:, new_status:, source: "web", actor_user: nil, actor_agent: nil, actor_name: nil, actor_emoji: nil, note: nil)
create!(
task: task,
- user: Current.user,
+ user: actor_user || Current.user || task.user,
+ actor_agent: actor_agent,
action: "moved",
field_name: "status",
old_value: old_status,
@@ -38,14 +41,15 @@ def self.record_status_change(task, old_status:, new_status:, source: "web", act
)
end
- def self.record_changes(task, changes, source: "web", actor_name: nil, actor_emoji: nil, note: nil)
+ def self.record_changes(task, changes, source: "web", actor_user: nil, actor_agent: nil, actor_name: nil, actor_emoji: nil, note: nil)
TRACKED_FIELDS.each do |field|
next unless changes.key?(field)
old_val, new_val = changes[field]
create!(
task: task,
- user: Current.user,
+ user: actor_user || Current.user || task.user,
+ actor_agent: actor_agent,
action: "updated",
field_name: field,
old_value: format_value(field, old_val),
diff --git a/app/models/user.rb b/app/models/user.rb
index 51c0ab35..e348b3c8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -4,7 +4,14 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy
has_many :boards, dependent: :destroy
has_many :tasks, dependent: :destroy
+ has_many :agents, dependent: :destroy
has_many :api_tokens, dependent: :destroy
+ has_many :join_tokens, dependent: :destroy
+ has_many :created_join_tokens,
+ class_name: "JoinToken",
+ foreign_key: :created_by_user_id,
+ dependent: :nullify,
+ inverse_of: :created_by_user
has_one_attached :avatar
# Primary API token for agent integration
diff --git a/app/views/agents/index.html.erb b/app/views/agents/index.html.erb
new file mode 100644
index 00000000..a3e7e3bc
--- /dev/null
+++ b/app/views/agents/index.html.erb
@@ -0,0 +1,72 @@
+<% content_for :title, "Agents - clawdeck" %>
+<% @board_page = false %>
+
+
+
+
+
+
Manage your fleet of OpenClaw agents
+
+ <%= link_to settings_path(anchor: "agents"), class: "inline-flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-semibold bg-white/[0.04] hover:bg-white/[0.08] text-[#888] transition-colors" do %>
+
+
+
+ Register Agent
+ <% end %>
+
+
+ <% if @agents.any? %>
+
+ <% @agents.each do |agent| %>
+ <%= link_to agent_path(agent), class: "block p-4 rounded-xl bg-white/[0.03] border border-white/[0.06] hover:bg-white/[0.05] hover:border-white/[0.1] transition-all" do %>
+
+
+
+ <%= agent.status == "online" ? "๐ข" : agent.status == "draining" ? "๐ก" : agent.status == "disabled" ? "๐ด" : "โซ" %>
+
+
+
+ <%= agent.name %>
+ "><%= agent.status.humanize %>
+
+
+ <% if agent.hostname.present? %>
+ <%= agent.hostname %>
+ <% end %>
+ <% if agent.version.present? %>
+ v<%= agent.version %>
+ <% end %>
+
+
+
+
+ <% if agent.last_heartbeat_at %>
+
+ <%= time_ago_in_words(agent.last_heartbeat_at) %> ago
+
+ <% else %>
+
Never connected
+ <% end %>
+ <% if agent.tags.any? %>
+
+ <% agent.tags.first(3).each do |tag| %>
+ <%= tag %>
+ <% end %>
+
+ <% end %>
+
+
+ <% end %>
+ <% end %>
+
+ <% else %>
+
+
๐ค
+
No agents registered
+
Get started by registering an agent using a join token.
+ <%= link_to settings_path(anchor: "agents"), class: "inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors" do %>
+ Register Agent
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/agents/show.html.erb b/app/views/agents/show.html.erb
new file mode 100644
index 00000000..c2b69610
--- /dev/null
+++ b/app/views/agents/show.html.erb
@@ -0,0 +1,157 @@
+<% content_for :title, "#{@agent.name} - Agents - clawdeck" %>
+<% @board_page = false %>
+
+
+ <%# Breadcrumb %>
+
+ <%= link_to "Agents", agents_path, class: "hover:text-[#888] transition-colors" %>
+
+
+
+
<%= @agent.name %>
+
+
+ <%# Header %>
+
+
+
+ <%= @agent.status == "online" ? "๐ข" : @agent.status == "draining" ? "๐ก" : @agent.status == "disabled" ? "๐ด" : "โซ" %>
+
+
+
+
+ "><%= @agent.status.humanize %>
+
+
+ <% if @agent.hostname.present? %><%= @agent.hostname %> <% end %>
+ <% if @agent.platform.present? %>ยท <%= @agent.platform %> <% end %>
+ <% if @agent.version.present? %>ยท v<%= @agent.version %> <% end %>
+
+
+
+
+ <%# Command buttons %>
+
+ <% if @agent.online? || @agent.draining? %>
+ <% if @agent.online? %>
+ <%= button_to agent_commands_path(@agent),
+ params: { agent_command: { kind: "drain", payload: { reason: "manual" }.to_json } },
+ class: "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-semibold bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition-colors",
+ data: { confirm: "Drain this agent? It will stop accepting new tasks." } do %>
+
+
+
+ Drain
+ <% end %>
+ <% end %>
+ <% if @agent.draining? %>
+ <%= button_to agent_commands_path(@agent),
+ params: { agent_command: { kind: "resume", payload: {}.to_json } },
+ class: "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-semibold bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors",
+ data: { confirm: "Resume this agent? It will start accepting new tasks." } do %>
+
+
+
+ Resume
+ <% end %>
+ <% end %>
+ <%= button_to agent_commands_path(@agent),
+ params: { agent_command: { kind: "restart", payload: {}.to_json } },
+ class: "inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-semibold bg-white/[0.04] text-[#888] hover:bg-white/[0.08] transition-colors",
+ data: { confirm: "Restart OpenClaw on this agent?" } do %>
+
+
+
+ Restart
+ <% end %>
+ <% end %>
+
+
+
+ <%# Info grid %>
+
+ <%# Last heartbeat %>
+
+
Last Heartbeat
+
+ <%= @agent.last_heartbeat_at ? time_ago_in_words(@agent.last_heartbeat_at) + " ago" : "Never" %>
+
+
+
+ <%# Tasks %>
+
+
Active Tasks
+
+ <%= @tasks_claimed.count %> claimed
+ / <%= @tasks_assigned.count %> assigned
+
+
+
+
+ <%# Tags %>
+ <% if @agent.tags.any? %>
+
+
Tags
+
+ <% @agent.tags.each do |tag| %>
+ <%= tag %>
+ <% end %>
+
+
+ <% end %>
+
+ <%# Metadata %>
+ <% if @agent.metadata.present? && @agent.metadata.any? %>
+
+
Metadata
+
+
<%= JSON.pretty_generate(@agent.metadata) %>
+
+
+ <% end %>
+
+ <%# Recent Commands %>
+
+
Recent Commands
+ <% if @commands.any? %>
+
+ <% @commands.each do |cmd| %>
+
+
+ "><%= cmd.state.humanize %>
+ <%= cmd.kind.humanize %>
+
+
<%= cmd.created_at.strftime("%b %d, %H:%M") %>
+
+ <% end %>
+
+ <% else %>
+
No commands yet
+ <% end %>
+
+
+ <%# Tasks %>
+ <% if @tasks_claimed.any? || @tasks_assigned.any? %>
+
+
Tasks
+
+ <% (@tasks_claimed + @tasks_assigned).uniq.first(10).each do |task| %>
+ <%= link_to board_path(task.board), class: "block p-3 rounded-lg bg-white/[0.03] border border-white/[0.06] hover:bg-white/[0.05] transition-colors" do %>
+
+
+
<%= task.name %>
+
+ <%= task.board.name %> ยท <%= task.status.humanize %>
+ <% if task.claimed_by_agent_id == @agent.id %>
+ Claimed
+ <% end %>
+
+
+
<%= task.status.humanize %>
+
+ <% end %>
+ <% end %>
+
+
+ <% end %>
+
diff --git a/app/views/boards/_task_card.html.erb b/app/views/boards/_task_card.html.erb
index f321468a..c009f4bd 100644
--- a/app/views/boards/_task_card.html.erb
+++ b/app/views/boards/_task_card.html.erb
@@ -20,27 +20,54 @@
<%# Agent Assignment %>
- <% agent_connected = current_user.agent_last_active_at.present? %>
- <% if agent_connected %>
- <% agent_emoji = current_user.agent_emoji || "๐ฆ" %>
- <% agent_name = current_user.agent_name || "Agent" %>
- <% if task.assigned_to_agent? %>
- <%= button_to unassign_board_task_path(task.board, task),
- method: :patch,
- data: { action: "click->dropdown#close" },
- class: "w-full flex items-center gap-2 px-3 py-2 text-sm text-content-secondary hover:bg-bg-hover transition-colors" do %>
-
<%= agent_emoji %>
- Unassign from <%= agent_name %>
- <% end %>
- <% else %>
- <%= button_to assign_board_task_path(task.board, task),
- method: :patch,
- data: { action: "click->dropdown#close" },
- class: "w-full flex items-center gap-2 px-3 py-2 text-sm text-content-secondary hover:bg-bg-hover transition-colors" do %>
-
<%= agent_emoji %>
- Assign to <%= agent_name %>
- <% end %>
- <% end %>
+ <% user_agents = current_user.agents.to_a %>
+ <% if user_agents.any? %>
+
+
+
+ ๐ค
+ Assign to Agent
+
+
+
+
+
+
+ <% if task.assigned_to_agent? || task.assigned_agent_id.present? %>
+ <%= button_to unassign_board_task_path(task.board, task),
+ method: :patch,
+ data: { action: "click->dropdown#close" },
+ class: "w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 transition-colors" do %>
+
โ
+ Unassign
+ <% end %>
+
+ <% end %>
+ <%= button_to assign_board_task_path(task.board, task),
+ method: :patch,
+ params: { agent_id: "" },
+ data: { action: "click->dropdown#close" },
+ class: "w-full flex items-center gap-2 px-3 py-2 text-sm text-content-secondary hover:bg-bg-hover transition-colors" do %>
+
โก
+ Auto / Any
+ <% end %>
+ <% user_agents.each do |agent| %>
+ <%= button_to assign_board_task_path(task.board, task),
+ method: :patch,
+ params: { agent_id: agent.id },
+ data: { action: "click->dropdown#close" },
+ class: "w-full flex items-center gap-2 px-3 py-2 text-sm text-content-secondary hover:bg-bg-hover transition-colors #{task.assigned_agent_id == agent.id ? 'bg-white/[0.04]' : ''}" do %>
+
๐ค
+
<%= agent.name %>
+ <% if task.assigned_agent_id == agent.id %>
+
+
+
+ <% end %>
+ <% end %>
+ <% end %>
+
+
<% end %>
@@ -124,6 +151,8 @@
show_priority = task.priority.present? && task.priority != "none" && priority_dot_config[task.priority]
p_cfg = priority_dot_config[task.priority]
has_agent = task.assigned_to_agent?
+ has_assigned_agent = task.assigned_agent_id.present? && task.assigned_agent.present?
+ has_claimed_agent = task.claimed_by_agent_id.present? && task.claimed_by_agent.present?
has_hint = task.respond_to?(:agent_hint) && task.agent_hint.present? && !is_done
has_metadata = show_priority || subtask_total > 0 || has_agent || has_hint
%>
@@ -157,11 +186,17 @@
<% end %>
<%# Agent status pill %>
- <% if has_agent %>
+ <% if has_agent || has_claimed_agent %>
<% agent_done = task.status == "done" || task.status == "in_review" %>
๐ค
-
<%= agent_done ? 'Review' : 'Working' %>
+ <% if has_claimed_agent %>
+
<%= task.claimed_by_agent.name.truncate(12) %> <%= agent_done ? 'โ' : 'โฏ' %>
+ <% elsif has_assigned_agent %>
+
<%= task.assigned_agent.name.truncate(12) %> (queued)
+ <% else %>
+
<%= agent_done ? 'Review' : 'Working' %>
+ <% end %>
<% unless agent_done %>
<% end %>
diff --git a/app/views/boards/tasks/_agent_assignment.html.erb b/app/views/boards/tasks/_agent_assignment.html.erb
index 6de3a0ce..63b3da25 100644
--- a/app/views/boards/tasks/_agent_assignment.html.erb
+++ b/app/views/boards/tasks/_agent_assignment.html.erb
@@ -1,31 +1,74 @@
-<%# Agent assignment button for task panel %>
-<% agent_connected = current_user.agent_last_active_at.present? %>
-<% agent_name = current_user.agent_name || "Agent" %>
-<% agent_emoji = agent_connected ? (current_user.agent_emoji || "๐ฆ") : "๐ฆ" %>
+<%# Agent assignment dropdown for task panel %>
+<% user_agents = current_user.agents.to_a %>
Agent
- <% if task.assigned_to_agent? %>
- <%= button_to unassign_board_task_path(board, task),
- method: :patch,
- class: "cursor-pointer inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-accent/20 text-accent hover:bg-accent/30 transition-colors",
- title: "Unassign from #{agent_name}" do %>
-
<%= agent_emoji %>
-
Assigned
- <% end %>
- <% elsif agent_connected %>
- <%= button_to assign_board_task_path(board, task),
- method: :patch,
- class: "cursor-pointer inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-bg-elevated hover:bg-bg-hover text-content-secondary transition-colors",
- title: "Assign to #{agent_name}" do %>
-
<%= agent_emoji %>
-
Assign
- <% end %>
+ <% if user_agents.any? %>
+
+
+ ๐ค
+ <% if task.assigned_agent.present? %>
+ <%= task.assigned_agent.name.truncate(12) %>
+ <% elsif task.assigned_to_agent? %>
+ Assigned
+ <% else %>
+ Assign
+ <% end %>
+
+
+
+
+
+ <% if task.assigned_to_agent? || task.assigned_agent_id.present? %>
+ <%= button_to unassign_board_task_path(board, task),
+ method: :patch,
+ data: { action: "click->dropdown#close" },
+ class: "w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 transition-colors" do %>
+
โ
+ Unassign
+ <% end %>
+
+ <% end %>
+ <%= button_to assign_board_task_path(board, task),
+ method: :patch,
+ params: { agent_id: "" },
+ data: { action: "click->dropdown#close" },
+ class: "w-full flex items-center gap-2 px-3 py-2 text-sm text-content-secondary hover:bg-bg-hover transition-colors" do %>
+
โก
+
Auto / Any
+ <% if task.assigned_to_agent? && task.assigned_agent_id.nil? %>
+
+
+
+ <% end %>
+ <% end %>
+ <% user_agents.each do |agent| %>
+ <%= button_to assign_board_task_path(board, task),
+ method: :patch,
+ params: { agent_id: agent.id },
+ data: { action: "click->dropdown#close" },
+ class: "w-full flex items-center gap-2 px-3 py-2 text-sm text-content-secondary hover:bg-bg-hover transition-colors" do %>
+
๐ค
+
<%= agent.name %>
+ <% if task.assigned_agent_id == agent.id %>
+
+
+
+ <% end %>
+ <% end %>
+ <% end %>
+
+
<% else %>
-
- ๐ฆ
- No agent
+
+ ๐ค
+ No agents
<% end %>
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb
index b66082d8..50da26a0 100644
--- a/app/views/shared/_navbar.html.erb
+++ b/app/views/shared/_navbar.html.erb
@@ -194,6 +194,13 @@
<%# Menu items %>
+ <%= link_to agents_path,
+ class: "flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] font-medium text-[#999] hover:bg-white/[0.04] transition-colors" do %>
+
+
+
+ Agents
+ <% end %>
<%= link_to settings_path,
class: "flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[13px] font-medium text-[#999] hover:bg-white/[0.04] transition-colors" do %>
diff --git a/config/database.yml b/config/database.yml
index fafc66b0..bb3f495d 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -41,6 +41,9 @@ development:
test:
<<: *default
database: clawdeck_test
+ host: <%= ENV.fetch("DATABASE_HOST", "localhost") %>
+ username: <%= ENV.fetch("DATABASE_USERNAME", "mojo") %>
+ password: <%= ENV.fetch("DATABASE_PASSWORD", "mojo") %>
# Production database configuration
# Uses DATABASE_URL from environment (Render, Railway, etc.)
diff --git a/config/routes.rb b/config/routes.rb
index 78852713..64d51b25 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -4,6 +4,28 @@
namespace :v1 do
resource :settings, only: [ :show, :update ]
+ resources :agents, only: [ :index, :show, :update ] do
+ collection do
+ post :register
+ end
+
+ member do
+ post :heartbeat
+ post :commands, to: "agent_commands#enqueue"
+ end
+ end
+
+ resources :agent_commands, only: [] do
+ collection do
+ get :next, to: "agent_commands#next"
+ end
+
+ member do
+ patch :ack, to: "agent_commands#ack"
+ patch :complete, to: "agent_commands#complete"
+ end
+ end
+
resources :boards, only: [ :index, :show, :create, :update, :destroy ]
resources :tasks, only: [ :index, :show, :create, :update, :destroy ] do
@@ -27,6 +49,10 @@
resources :users, only: [ :index ]
end
+ resources :agents, only: [ :index, :show ] do
+ resources :commands, only: [ :create ], controller: "agent_commands"
+ end
+
resource :session, only: [:new, :create, :destroy]
resource :registration, only: [:new, :create]
get "/auth/:provider/callback", to: "omniauth_callbacks#github", as: :omniauth_callback
diff --git a/db/migrate/20260222120010_create_agents.rb b/db/migrate/20260222120010_create_agents.rb
new file mode 100644
index 00000000..b7c49dab
--- /dev/null
+++ b/db/migrate/20260222120010_create_agents.rb
@@ -0,0 +1,22 @@
+class CreateAgents < ActiveRecord::Migration[8.1]
+ def change
+ create_table :agents do |t|
+ t.references :user, null: false, foreign_key: true
+ t.string :name, null: false
+ t.integer :status, null: false, default: 0
+ t.string :hostname
+ t.string :host_uid
+ t.string :platform
+ t.string :version
+ t.string :tags, null: false, default: [], array: true
+ t.datetime :last_heartbeat_at
+ t.jsonb :metadata, null: false, default: {}
+
+ t.timestamps
+ end
+
+ add_index :agents, [ :user_id, :status ]
+ add_index :agents, :last_heartbeat_at
+ add_index :agents, [ :user_id, :host_uid ], unique: true
+ end
+end
diff --git a/db/migrate/20260222120020_create_agent_tokens.rb b/db/migrate/20260222120020_create_agent_tokens.rb
new file mode 100644
index 00000000..f9e87485
--- /dev/null
+++ b/db/migrate/20260222120020_create_agent_tokens.rb
@@ -0,0 +1,14 @@
+class CreateAgentTokens < ActiveRecord::Migration[8.1]
+ def change
+ create_table :agent_tokens do |t|
+ t.references :agent, null: false, foreign_key: true
+ t.string :name
+ t.string :token_digest, null: false
+ t.datetime :last_used_at
+
+ t.timestamps
+ end
+
+ add_index :agent_tokens, :token_digest, unique: true
+ end
+end
diff --git a/db/migrate/20260222120030_add_agent_refs_to_tasks.rb b/db/migrate/20260222120030_add_agent_refs_to_tasks.rb
new file mode 100644
index 00000000..4ba079a8
--- /dev/null
+++ b/db/migrate/20260222120030_add_agent_refs_to_tasks.rb
@@ -0,0 +1,6 @@
+class AddAgentRefsToTasks < ActiveRecord::Migration[8.1]
+ def change
+ add_reference :tasks, :assigned_agent, foreign_key: { to_table: :agents }
+ add_reference :tasks, :claimed_by_agent, foreign_key: { to_table: :agents }
+ end
+end
diff --git a/db/migrate/20260222120040_add_actor_agent_id_to_task_activities.rb b/db/migrate/20260222120040_add_actor_agent_id_to_task_activities.rb
new file mode 100644
index 00000000..ba6bc1f4
--- /dev/null
+++ b/db/migrate/20260222120040_add_actor_agent_id_to_task_activities.rb
@@ -0,0 +1,5 @@
+class AddActorAgentIdToTaskActivities < ActiveRecord::Migration[8.1]
+ def change
+ add_reference :task_activities, :actor_agent, foreign_key: { to_table: :agents }
+ end
+end
diff --git a/db/migrate/20260222130010_create_join_tokens.rb b/db/migrate/20260222130010_create_join_tokens.rb
new file mode 100644
index 00000000..59997220
--- /dev/null
+++ b/db/migrate/20260222130010_create_join_tokens.rb
@@ -0,0 +1,15 @@
+class CreateJoinTokens < ActiveRecord::Migration[8.1]
+ def change
+ create_table :join_tokens do |t|
+ t.references :user, null: false, foreign_key: true
+ t.references :created_by_user, foreign_key: { to_table: :users }
+ t.string :token_digest, null: false
+ t.datetime :expires_at, null: false
+ t.datetime :used_at
+
+ t.timestamps
+ end
+
+ add_index :join_tokens, :token_digest, unique: true
+ end
+end
diff --git a/db/migrate/20260222140000_create_agent_commands.rb b/db/migrate/20260222140000_create_agent_commands.rb
new file mode 100644
index 00000000..63767012
--- /dev/null
+++ b/db/migrate/20260222140000_create_agent_commands.rb
@@ -0,0 +1,18 @@
+class CreateAgentCommands < ActiveRecord::Migration[8.1]
+ def change
+ create_table :agent_commands do |t|
+ t.references :agent, null: false, foreign_key: true
+ t.string :kind, null: false
+ t.jsonb :payload, null: false, default: {}
+ t.integer :state, null: false, default: 0
+ t.jsonb :result, null: false, default: {}
+ t.references :requested_by_user, foreign_key: { to_table: :users }
+ t.datetime :acked_at
+ t.datetime :completed_at
+
+ t.timestamps
+ end
+
+ add_index :agent_commands, [ :agent_id, :state ]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index db86aa7a..c4323aac 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_02_15_134003) do
+ActiveRecord::Schema[8.1].define(version: 2026_02_22_140000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -42,6 +42,52 @@
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
+ create_table "agent_commands", force: :cascade do |t|
+ t.datetime "acked_at"
+ t.bigint "agent_id", null: false
+ t.datetime "completed_at"
+ t.datetime "created_at", null: false
+ t.string "kind", null: false
+ t.jsonb "payload", default: {}, null: false
+ t.bigint "requested_by_user_id"
+ t.jsonb "result", default: {}, null: false
+ t.integer "state", default: 0, null: false
+ t.datetime "updated_at", null: false
+ t.index ["agent_id", "state"], name: "index_agent_commands_on_agent_id_and_state"
+ t.index ["agent_id"], name: "index_agent_commands_on_agent_id"
+ t.index ["requested_by_user_id"], name: "index_agent_commands_on_requested_by_user_id"
+ end
+
+ create_table "agent_tokens", force: :cascade do |t|
+ t.bigint "agent_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "last_used_at"
+ t.string "name"
+ t.string "token_digest", null: false
+ t.datetime "updated_at", null: false
+ t.index ["agent_id"], name: "index_agent_tokens_on_agent_id"
+ t.index ["token_digest"], name: "index_agent_tokens_on_token_digest", unique: true
+ end
+
+ create_table "agents", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "host_uid"
+ t.string "hostname"
+ t.datetime "last_heartbeat_at"
+ t.jsonb "metadata", default: {}, null: false
+ t.string "name", null: false
+ t.string "platform"
+ t.integer "status", default: 0, null: false
+ t.string "tags", default: [], null: false, array: true
+ t.datetime "updated_at", null: false
+ t.bigint "user_id", null: false
+ t.string "version"
+ t.index ["last_heartbeat_at"], name: "index_agents_on_last_heartbeat_at"
+ t.index ["user_id", "host_uid"], name: "index_agents_on_user_id_and_host_uid", unique: true
+ t.index ["user_id", "status"], name: "index_agents_on_user_id_and_status"
+ t.index ["user_id"], name: "index_agents_on_user_id"
+ end
+
create_table "api_tokens", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "last_used_at"
@@ -75,6 +121,19 @@
t.index ["user_id"], name: "index_boards_on_user_id"
end
+ create_table "join_tokens", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "created_by_user_id"
+ t.datetime "expires_at", null: false
+ t.string "token_digest", null: false
+ t.datetime "updated_at", null: false
+ t.datetime "used_at"
+ t.bigint "user_id", null: false
+ t.index ["created_by_user_id"], name: "index_join_tokens_on_created_by_user_id"
+ t.index ["token_digest"], name: "index_join_tokens_on_token_digest", unique: true
+ t.index ["user_id"], name: "index_join_tokens_on_user_id"
+ end
+
create_table "projects", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "description"
@@ -109,6 +168,138 @@
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
end
+ create_table "solid_cache_entries", force: :cascade do |t|
+ t.integer "byte_size", null: false
+ t.datetime "created_at", null: false
+ t.binary "key", null: false
+ t.bigint "key_hash", null: false
+ t.binary "value", null: false
+ t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
+ t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
+ t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
+ end
+
+ create_table "solid_queue_blocked_executions", force: :cascade do |t|
+ t.string "concurrency_key", null: false
+ t.datetime "created_at", null: false
+ t.datetime "expires_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
+ t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
+ t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_claimed_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.bigint "process_id"
+ t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
+ t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
+ end
+
+ create_table "solid_queue_failed_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.text "error"
+ t.bigint "job_id", null: false
+ t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
+ end
+
+ create_table "solid_queue_jobs", force: :cascade do |t|
+ t.string "active_job_id"
+ t.text "arguments"
+ t.string "class_name", null: false
+ t.string "concurrency_key"
+ t.datetime "created_at", null: false
+ t.datetime "finished_at"
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.datetime "scheduled_at"
+ t.datetime "updated_at", null: false
+ t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
+ t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
+ t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
+ t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering"
+ t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
+ end
+
+ create_table "solid_queue_pauses", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "queue_name", null: false
+ t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
+ end
+
+ create_table "solid_queue_processes", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "hostname"
+ t.string "kind", null: false
+ t.datetime "last_heartbeat_at", null: false
+ t.text "metadata"
+ t.string "name", null: false
+ t.integer "pid", null: false
+ t.bigint "supervisor_id"
+ t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
+ t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
+ t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
+ end
+
+ create_table "solid_queue_ready_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
+ t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
+ t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
+ end
+
+ create_table "solid_queue_recurring_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.datetime "run_at", null: false
+ t.string "task_key", null: false
+ t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
+ t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
+ end
+
+ create_table "solid_queue_recurring_tasks", force: :cascade do |t|
+ t.text "arguments"
+ t.string "class_name"
+ t.string "command", limit: 2048
+ t.datetime "created_at", null: false
+ t.text "description"
+ t.string "key", null: false
+ t.integer "priority", default: 0
+ t.string "queue_name"
+ t.string "schedule", null: false
+ t.boolean "static", default: true, null: false
+ t.datetime "updated_at", null: false
+ t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
+ t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
+ end
+
+ create_table "solid_queue_scheduled_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.datetime "scheduled_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
+ t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
+ end
+
+ create_table "solid_queue_semaphores", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "expires_at", null: false
+ t.string "key", null: false
+ t.datetime "updated_at", null: false
+ t.integer "value", default: 1, null: false
+ t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
+ t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
+ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
+ end
+
create_table "subtasks", force: :cascade do |t|
t.datetime "created_at", null: false
t.boolean "done", default: false
@@ -134,6 +325,7 @@
create_table "task_activities", force: :cascade do |t|
t.string "action", null: false
+ t.bigint "actor_agent_id"
t.string "actor_emoji"
t.string "actor_name"
t.string "actor_type"
@@ -146,6 +338,7 @@
t.bigint "task_id", null: false
t.datetime "updated_at", null: false
t.bigint "user_id"
+ t.index ["actor_agent_id"], name: "index_task_activities_on_actor_agent_id"
t.index ["task_id", "created_at"], name: "index_task_activities_on_task_id_and_created_at"
t.index ["task_id"], name: "index_task_activities_on_task_id"
t.index ["user_id"], name: "index_task_activities_on_user_id"
@@ -176,10 +369,12 @@
create_table "tasks", force: :cascade do |t|
t.datetime "agent_claimed_at"
t.text "agent_hint"
+ t.bigint "assigned_agent_id"
t.datetime "assigned_at"
t.boolean "assigned_to_agent", default: false, null: false
t.boolean "blocked", default: false, null: false
t.bigint "board_id", null: false
+ t.bigint "claimed_by_agent_id"
t.boolean "completed", default: false, null: false
t.datetime "completed_at"
t.integer "confidence", default: 0, null: false
@@ -199,9 +394,11 @@
t.bigint "task_list_id"
t.datetime "updated_at", null: false
t.integer "user_id"
+ t.index ["assigned_agent_id"], name: "index_tasks_on_assigned_agent_id"
t.index ["assigned_to_agent"], name: "index_tasks_on_assigned_to_agent"
t.index ["blocked"], name: "index_tasks_on_blocked"
t.index ["board_id"], name: "index_tasks_on_board_id"
+ t.index ["claimed_by_agent_id"], name: "index_tasks_on_claimed_by_agent_id"
t.index ["position"], name: "index_tasks_on_position"
t.index ["project_id"], name: "index_tasks_on_project_id"
t.index ["status"], name: "index_tasks_on_status"
@@ -237,20 +434,35 @@
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "agent_commands", "agents"
+ add_foreign_key "agent_commands", "users", column: "requested_by_user_id"
+ add_foreign_key "agent_tokens", "agents"
+ add_foreign_key "agents", "users"
add_foreign_key "api_tokens", "users"
add_foreign_key "api_usage_records", "users"
add_foreign_key "boards", "users"
+ add_foreign_key "join_tokens", "users"
+ add_foreign_key "join_tokens", "users", column: "created_by_user_id"
add_foreign_key "projects", "users"
add_foreign_key "sessions", "users"
+ add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
+ add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "subtasks", "tasks"
add_foreign_key "tags", "projects"
add_foreign_key "tags", "users"
+ add_foreign_key "task_activities", "agents", column: "actor_agent_id"
add_foreign_key "task_activities", "tasks"
add_foreign_key "task_activities", "users"
add_foreign_key "task_lists", "projects"
add_foreign_key "task_lists", "users"
add_foreign_key "task_tags", "tags"
add_foreign_key "task_tags", "tasks"
+ add_foreign_key "tasks", "agents", column: "assigned_agent_id"
+ add_foreign_key "tasks", "agents", column: "claimed_by_agent_id"
add_foreign_key "tasks", "boards"
add_foreign_key "tasks", "projects"
add_foreign_key "tasks", "task_lists"
diff --git a/devplan.md b/devplan.md
new file mode 100644
index 00000000..5f854cfd
--- /dev/null
+++ b/devplan.md
@@ -0,0 +1,623 @@
+# UberClawControl Dev Plan
+
+## Project Overview
+UberClawControl is a local-first fork of ClawDeck for orchestrating multiple OpenClaw agents across multiple hosts. The control-plane lives in the Rails app (agent identity, auth, scheduling, command orchestration, and fleet UI), while the data-plane is the distributed Go agent runtime on each host (register, heartbeat, task execution, and command handling).
+
+## Execution Manifest
+```yaml
+# Codex 5.3 Execution Manifest โ UberClawControl (local-first)
+# Target fork name (eventual remote): github.com/mojomast/uberclawcontrol
+# Work begins locally BEFORE fork exists on GitHub.
+# Repo origin to clone locally: https://github.com/clawdeckio/clawdeck
+#
+# Recommended workflow:
+# - Create local repo from upstream
+# - Implement batches sequentially on local branches
+# - Only after batches are stable: create GitHub fork + push branches + open PRs
+#
+# Progress tracking:
+# - Each task has a `status:` field (todo|doing|done|blocked) for the agent to update.
+# - Each batch has acceptance checks the agent MUST run and record.
+
+manifest_version: "1.0"
+project:
+ codename: "uberclawcontrol"
+ description: "ClawDeck fork supporting multi-host OpenClaw clusters via Go agents"
+ upstream_repo: "https://github.com/clawdeckio/clawdeck"
+ future_remote_repo: "https://github.com/mojomast/uberclawcontrol"
+ local_only: true
+
+execution:
+ strategy:
+ pr_granularity: "one_pr_per_batch" # alternative: "one_pr_per_milestone"
+ branching:
+ base_branch: "main"
+ batch_branch_prefix: "fleet/"
+ commit_convention:
+ format: "batch(): "
+ context_limits:
+ max_tokens_per_batch: 100000
+ guardrails:
+ - "Do not store plaintext tokens in DB (only return once; store digest/hash)."
+ - "Preserve backwards compatibility until explicit migration batch."
+ - "Every batch ends with tests + a short state report in docs/fleet/README.md."
+ - "Avoid large refactors across batches; keep diffs focused."
+
+roles:
+ orchestrator:
+ responsibilities:
+ - "Runs bootstrap and coordinates subagents"
+ - "Ensures acceptance checks executed"
+ - "Maintains docs/fleet/README.md state report after each batch"
+ subagents:
+ schema-agent: { scope: "DB schema, migrations, ActiveRecord models, associations" }
+ security-agent: { scope: "Token hashing, permissions, threat surface review" }
+ auth-agent: { scope: "API authentication paths, current_agent integration" }
+ api-agent: { scope: "Rails controllers, serializers, routes for new API endpoints" }
+ scheduler-agent: { scope: "Task selection/claiming logic, concurrency/race safety" }
+ orchestration-agent: { scope: "Command queue, server-side orchestration logic" }
+ ui-agent: { scope: "Rails views/Turbo UI for agents + assignment + commands" }
+ go-agent-core: { scope: "Go project scaffolding, config, ClawDeck API client" }
+ go-agent-runtime: { scope: "Task runner loop, status updates, execution interface" }
+ go-agent-ops: { scope: "Host ops handlers: drain/resume/restart/upgrade stubs" }
+ integration-agent: { scope: "E2E tests, simulated agent tooling, hardening checks" }
+
+paths:
+ local_repo_dir: "./uberclawcontrol" # agent may adjust if different
+ docs_dir: "docs/fleet"
+ go_agent_dir: "agent"
+
+bootstrap:
+ batch_id: "B0"
+ name: "Local bootstrap (no GitHub fork yet)"
+ owner: "orchestrator"
+ branch: "fleet/batch0-bootstrap"
+ objective: "Create local workspace from upstream; verify baseline; set up branches + docs scaffold"
+ tasks:
+ - id: "B0-T1"
+ status: "todo"
+ description: "Clone upstream repo into local working directory ./uberclawcontrol"
+ commands:
+ - "git clone https://github.com/clawdeckio/clawdeck ./uberclawcontrol"
+ - id: "B0-T2"
+ status: "todo"
+ description: "Create local branches for each batch"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "git checkout -b fleet/batch0-bootstrap"
+ - "git branch fleet/batch1-schema"
+ - "git branch fleet/batch2-auth"
+ - "git branch fleet/batch3-agent-api"
+ - "git branch fleet/batch4-scheduler"
+ - "git branch fleet/batch5-orchestration"
+ - "git branch fleet/batch6-go-agent"
+ - "git branch fleet/batch7-ui"
+ - "git branch fleet/batch8-integration"
+ - id: "B0-T3"
+ status: "todo"
+ description: "Run baseline boot + tests; record results"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "bin/rails test"
+ - "bin/dev"
+ notes: "If bin/dev is interactive/long-running, validate it boots and then stop it."
+ - id: "B0-T4"
+ status: "todo"
+ description: "Create docs scaffold: docs/fleet/README.md with architecture overview and batch ledger"
+ files:
+ - "docs/fleet/README.md"
+ content_guidance:
+ - "Include: goal, control-plane vs data-plane, planned endpoints summary (placeholder), and batch checklist table."
+ acceptance:
+ - "Baseline tests run (pass or logged failures)."
+ - "docs/fleet/README.md exists."
+ - "Batch branches exist locally."
+
+batches:
+ - batch_id: "B1"
+ name: "Database & Domain Foundation"
+ owner: "schema-agent"
+ collaborators: ["security-agent"]
+ branch: "fleet/batch1-schema"
+ objective: "Add first-class Agent model + agent tokens + task ownership fields. No behavior change yet."
+ dependencies: ["B0"]
+ outputs:
+ - "New tables: agents, agent_tokens"
+ - "Task fields: assigned_agent_id, claimed_by_agent_id"
+ - "TaskActivity field: actor_agent_id"
+ - "Models + associations"
+ tasks:
+ - id: "B1-T1"
+ status: "todo"
+ description: "Add migration: create agents table with status enum + host metadata"
+ files:
+ - "db/migrate/*_create_agents.rb"
+ - "app/models/agent.rb"
+ - id: "B1-T2"
+ status: "todo"
+ description: "Add migration: create agent_tokens table storing token_digest + last_used_at"
+ files:
+ - "db/migrate/*_create_agent_tokens.rb"
+ - "app/models/agent_token.rb"
+ - id: "B1-T3"
+ status: "todo"
+ description: "Add migration: modify tasks to include assigned_agent_id + claimed_by_agent_id (keep legacy assigned_to_agent)"
+ files:
+ - "db/migrate/*_add_agent_refs_to_tasks.rb"
+ - id: "B1-T4"
+ status: "todo"
+ description: "Add migration: modify task_activities to include actor_agent_id"
+ files:
+ - "db/migrate/*_add_actor_agent_id_to_task_activities.rb"
+ - id: "B1-T5"
+ status: "todo"
+ description: "Add associations: Task assigned_agent/claimed_by_agent; TaskActivity actor_agent"
+ files:
+ - "app/models/task.rb"
+ - "app/models/task_activity.rb"
+ - id: "B1-T6"
+ status: "todo"
+ description: "Implement AgentToken digest helpers (no plaintext storage); add minimal unit tests"
+ files:
+ - "app/models/agent_token.rb"
+ - "test/models/agent_token_test.rb"
+ - id: "B1-T7"
+ status: "todo"
+ description: "Update docs/fleet/README.md: schema changes ledger"
+ files:
+ - "docs/fleet/README.md"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "bin/rails db:migrate"
+ - "bin/rails test"
+ acceptance:
+ - "Migrations apply cleanly."
+ - "Tests pass."
+ - "No app boot regression."
+ - "docs ledger updated with new tables/fields."
+
+ - batch_id: "B2"
+ name: "Auth Refactor (current_agent)"
+ owner: "auth-agent"
+ collaborators: ["security-agent"]
+ branch: "fleet/batch2-auth"
+ objective: "Add agent-scoped auth alongside existing user ApiToken auth. Set current_agent when agent token used."
+ dependencies: ["B1"]
+ outputs:
+ - "current_agent support in API auth concern"
+ - "join_tokens table/model for registration bootstrap"
+ - "tests for both auth flows"
+ tasks:
+ - id: "B2-T1"
+ status: "todo"
+ description: "Add join_tokens table/model with token_digest, expires_at, used_at"
+ files:
+ - "db/migrate/*_create_join_tokens.rb"
+ - "app/models/join_token.rb"
+ - id: "B2-T2"
+ status: "todo"
+ description: "Update API token authentication concern: try AgentToken first; set current_agent + current_user; fallback to ApiToken"
+ files:
+ - "app/controllers/concerns/api/token_authentication.rb"
+ - id: "B2-T3"
+ status: "todo"
+ description: "Ensure last_used_at updated for AgentToken; avoid logging tokens; constant-time compare"
+ files:
+ - "app/models/agent_token.rb"
+ - "app/controllers/concerns/api/token_authentication.rb"
+ - id: "B2-T4"
+ status: "todo"
+ description: "Add tests verifying: user token still works; agent token sets current_agent; forbidden cross-user access"
+ files:
+ - "test/controllers/api/*"
+ - "test/models/*"
+ - id: "B2-T5"
+ status: "todo"
+ description: "Update docs/fleet/README.md: auth flows and token types"
+ files:
+ - "docs/fleet/README.md"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "bin/rails db:migrate"
+ - "bin/rails test"
+ acceptance:
+ - "Existing API token auth works unchanged."
+ - "Agent token auth works and sets current_agent."
+ - "Cross-user agent access blocked."
+ - "Docs updated."
+
+ - batch_id: "B3"
+ name: "Agent API (register + heartbeat + management)"
+ owner: "api-agent"
+ branch: "fleet/batch3-agent-api"
+ objective: "Implement agent lifecycle endpoints: register, heartbeat, list, show, patch."
+ dependencies: ["B2"]
+ outputs:
+ - "Agent registration endpoint issuing agent token once"
+ - "Heartbeat endpoint"
+ - "Agent list/show/patch endpoints"
+ - "Docs with curl examples"
+ tasks:
+ - id: "B3-T1"
+ status: "todo"
+ description: "Add routes for agent endpoints under /api/v1"
+ files:
+ - "config/routes.rb"
+ - id: "B3-T2"
+ status: "todo"
+ description: "Implement POST /api/v1/agents/register (consume join token; create agent; return plaintext token once)"
+ files:
+ - "app/controllers/api/v1/agents_controller.rb"
+ - "app/models/join_token.rb"
+ - "app/models/agent_token.rb"
+ - id: "B3-T3"
+ status: "todo"
+ description: "Implement POST /api/v1/agents/:id/heartbeat (agent-only; updates last_heartbeat_at/status/versions; returns desired_state placeholder)"
+ files:
+ - "app/controllers/api/v1/agents_controller.rb"
+ - id: "B3-T4"
+ status: "todo"
+ description: "Implement GET /api/v1/agents, GET /api/v1/agents/:id, PATCH /api/v1/agents/:id with ownership checks"
+ files:
+ - "app/controllers/api/v1/agents_controller.rb"
+ - id: "B3-T5"
+ status: "todo"
+ description: "Add request tests for register/heartbeat/list/show/patch"
+ files:
+ - "test/controllers/api/v1/agents_controller_test.rb"
+ - id: "B3-T6"
+ status: "todo"
+ description: "Docs: add curl examples for register + heartbeat"
+ files:
+ - "docs/fleet/README.md"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "bin/rails test"
+ acceptance:
+ - "Register consumes join token and returns agent_token once."
+ - "Heartbeat updates last_heartbeat_at and status."
+ - "List/show/patch restricted to owner."
+ - "Docs include working curl examples."
+
+ - batch_id: "B4"
+ name: "Multi-Agent Scheduler & Claim Correctness"
+ owner: "scheduler-agent"
+ collaborators: ["api-agent"]
+ branch: "fleet/batch4-scheduler"
+ objective: "Make task selection + claiming agent-aware and race-safe."
+ dependencies: ["B3"]
+ outputs:
+ - "Agent-aware /tasks/next"
+ - "Claim/unclaim writes claimed_by_agent_id"
+ - "Concurrency tests preventing double-claim"
+ tasks:
+ - id: "B4-T1"
+ status: "todo"
+ description: "Update /api/v1/tasks/next to select eligible tasks for current_agent (assigned_agent_id matches OR null; exclude claimed; exclude draining agents)"
+ files:
+ - "app/controllers/api/v1/tasks_controller.rb"
+ - "app/models/task.rb"
+ - id: "B4-T2"
+ status: "todo"
+ description: "Implement row-locking strategy (transaction + FOR UPDATE SKIP LOCKED or equivalent) to prevent race duplicates"
+ files:
+ - "app/controllers/api/v1/tasks_controller.rb"
+ - "app/models/task.rb"
+ - id: "B4-T3"
+ status: "todo"
+ description: "Update claim/unclaim endpoints to set/clear claimed_by_agent_id and record TaskActivity.actor_agent_id"
+ files:
+ - "app/controllers/api/v1/tasks_controller.rb"
+ - "app/models/task_activity.rb"
+ - id: "B4-T4"
+ status: "todo"
+ description: "Add automated test simulating two agents racing /tasks/next; ensure never same task"
+ files:
+ - "test/controllers/api/v1/tasks_controller_test.rb"
+ - id: "B4-T5"
+ status: "todo"
+ description: "Docs: add section on task eligibility/assignment rules"
+ files:
+ - "docs/fleet/README.md"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "bin/rails test"
+ acceptance:
+ - "Two agents cannot obtain the same task concurrently."
+ - "Assigned tasks route only to assigned agent."
+ - "Draining agents do not receive new tasks (if implemented here; otherwise defer to B5 and document)."
+
+ - batch_id: "B5"
+ name: "Orchestration Commands"
+ owner: "orchestration-agent"
+ collaborators: ["api-agent"]
+ branch: "fleet/batch5-orchestration"
+ objective: "Add command queue so admins can orchestrate hosts; agents poll/ack/complete."
+ dependencies: ["B4"]
+ outputs:
+ - "agent_commands table + model"
+ - "Command endpoints"
+ - "Drain semantics supported"
+ tasks:
+ - id: "B5-T1"
+ status: "todo"
+ description: "Add migration/model for agent_commands (kind, payload, state, result, requested_by_user_id)"
+ files:
+ - "db/migrate/*_create_agent_commands.rb"
+ - "app/models/agent_command.rb"
+ - id: "B5-T2"
+ status: "todo"
+ description: "Add routes + controllers for command lifecycle endpoints"
+ files:
+ - "config/routes.rb"
+ - "app/controllers/api/v1/agent_commands_controller.rb"
+ - id: "B5-T3"
+ status: "todo"
+ description: "Implement POST agents/:id/commands (admin-only), GET commands/next (agent-only), ack, complete"
+ files:
+ - "app/controllers/api/v1/agent_commands_controller.rb"
+ - id: "B5-T4"
+ status: "todo"
+ description: "Ensure scheduler respects draining (if not done in B4): do not dispatch new tasks to draining agents"
+ files:
+ - "app/controllers/api/v1/tasks_controller.rb"
+ - "app/models/agent.rb"
+ - id: "B5-T5"
+ status: "todo"
+ description: "Add tests for command queue state transitions"
+ files:
+ - "test/controllers/api/v1/agent_commands_controller_test.rb"
+ - id: "B5-T6"
+ status: "todo"
+ description: "Docs: command kinds + sample payloads"
+ files:
+ - "docs/fleet/README.md"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "bin/rails db:migrate"
+ - "bin/rails test"
+ acceptance:
+ - "Commands can be enqueued and consumed by agent."
+ - "Ack and complete transitions work."
+ - "Drain prevents new task dispatch."
+
+ - batch_id: "B6"
+ name: "Go Agent Daemon (register/heartbeat/tasks/commands)"
+ owner: "go-agent-core"
+ collaborators: ["go-agent-runtime", "go-agent-ops"]
+ branch: "fleet/batch6-go-agent"
+ objective: "Implement the Go daemon that integrates with the new API."
+ dependencies: ["B5"]
+ outputs:
+ - "agent/ Go module"
+ - "Config + token persistence"
+ - "Register + heartbeat loop"
+ - "Task loop MVP"
+ - "Command loop MVP"
+ tasks:
+ - id: "B6-T1"
+ status: "todo"
+ description: "Create Go module under ./agent with cmd/claw-agent and internal packages"
+ files:
+ - "agent/go.mod"
+ - "agent/cmd/claw-agent/main.go"
+ - "agent/internal/config/*"
+ - "agent/internal/clawdeck/*"
+ - id: "B6-T2"
+ status: "todo"
+ description: "Implement ClawDeck API client: register, heartbeat, tasks/next, task updates, commands poll/ack/complete"
+ files:
+ - "agent/internal/clawdeck/client.go"
+ - "agent/internal/clawdeck/types.go"
+ - id: "B6-T3"
+ status: "todo"
+ description: "Implement config loading (env + optional file) and token persistence to disk with safe permissions"
+ files:
+ - "agent/internal/config/config.go"
+ - id: "B6-T4"
+ status: "todo"
+ description: "Heartbeat loop every N seconds; include status + versions + basic metadata"
+ files:
+ - "agent/internal/runner/heartbeat.go"
+ - id: "B6-T5"
+ status: "todo"
+ description: "Task loop MVP: poll tasks/next; claim if needed; post activity; stub execute; complete"
+ files:
+ - "agent/internal/runner/task_loop.go"
+ - "agent/internal/openclaw/executor.go"
+ - id: "B6-T6"
+ status: "todo"
+ description: "Command loop MVP: poll commands; ack; execute handlers (drain/resume/restart stubs); complete"
+ files:
+ - "agent/internal/orchestrator/command_loop.go"
+ - "agent/internal/orchestrator/handlers.go"
+ - id: "B6-T7"
+ status: "todo"
+ description: "Docs: how to run 2 agents locally with two join tokens (or reuse join tokens safely) and see task distribution"
+ files:
+ - "docs/fleet/README.md"
+ commands:
+ - "cd ./uberclawcontrol/agent"
+ - "go test ./..."
+ - "go build ./cmd/claw-agent"
+ acceptance:
+ - "Agent can register and persist token."
+ - "Agent heartbeats show online in DB."
+ - "Two agents can run concurrently and split tasks."
+ - "Agent can consume orchestration commands and report completion."
+
+ - batch_id: "B7"
+ name: "UI for Fleet Management (Agents + Assignment + Commands)"
+ owner: "ui-agent"
+ branch: "fleet/batch7-ui"
+ objective: "Admin UI for managing agents, issuing commands, and assigning tasks to agents."
+ dependencies: ["B6"]
+ outputs:
+ - "Agents list/detail pages"
+ - "Task assignment dropdown"
+ - "Command actions and history"
+ tasks:
+ - id: "B7-T1"
+ status: "todo"
+ description: "Create Agents UI: index (status/heartbeat/host/tags/versions) and show (metrics/errors/commands)"
+ files:
+ - "app/controllers/agents_controller.rb"
+ - "app/views/agents/index.html.*"
+ - "app/views/agents/show.html.*"
+ - id: "B7-T2"
+ status: "todo"
+ description: "Add orchestration buttons: Drain/Resume/Restart OpenClaw (create AgentCommand via Rails controller)"
+ files:
+ - "app/controllers/agent_commands_controller.rb"
+ - "app/views/agents/show.html.*"
+ - id: "B7-T3"
+ status: "todo"
+ description: "Replace legacy 'assigned_to_agent' UI with assignment dropdown: Auto/Any + specific agent"
+ files:
+ - "app/views/*task*"
+ - "app/controllers/*task*"
+ - "app/models/task.rb"
+ - id: "B7-T4"
+ status: "todo"
+ description: "Visual indicators on task cards: assigned agent + claimed agent"
+ files:
+ - "app/views/*task*"
+ - id: "B7-T5"
+ status: "todo"
+ description: "Update docs with screenshots or descriptions of UI flows (optional if no screenshot tooling)"
+ files:
+ - "docs/fleet/README.md"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "bin/rails test"
+ - "bin/dev"
+ acceptance:
+ - "Agents appear in UI with live-ish status."
+ - "Admin can issue commands from UI."
+ - "Admin can assign tasks to a specific agent and only that agent receives it."
+
+ - batch_id: "B8"
+ name: "Integration & Hardening"
+ owner: "integration-agent"
+ collaborators: ["security-agent"]
+ branch: "fleet/batch8-integration"
+ objective: "Add end-to-end tests, race hardening, security review, and operational docs."
+ dependencies: ["B7"]
+ outputs:
+ - "E2E / concurrency tests"
+ - "Security checklist + token rotation plan"
+ - "Local multi-agent dev harness"
+ tasks:
+ - id: "B8-T1"
+ status: "todo"
+ description: "Add concurrency tests for /tasks/next (race), command consumption, and auth boundaries"
+ files:
+ - "test/*"
+ - id: "B8-T2"
+ status: "todo"
+ description: "Add 'simulated agent' harness (Ruby or Go) for automated E2E"
+ files:
+ - "test/support/*"
+ - "agent/internal/* (optional)"
+ - id: "B8-T3"
+ status: "todo"
+ description: "Security review: token storage, rotation endpoint design (can be stub), command allowlist enforcement"
+ files:
+ - "docs/fleet/SECURITY.md"
+ - id: "B8-T4"
+ status: "todo"
+ description: "Docs: local dev guide for 2-3 agents + upgrade path from legacy 'assigned_to_agent' boolean"
+ files:
+ - "docs/fleet/README.md"
+ commands:
+ - "cd ./uberclawcontrol"
+ - "bin/rails test"
+ - "cd ./agent && go test ./..."
+ acceptance:
+ - "All tests green."
+ - "Race tests demonstrate no double-claim."
+ - "Security doc exists and rotation plan recorded."
+ - "Local dev guide complete."
+
+notes_for_codex:
+ start_here:
+ - "Execute bootstrap B0 first."
+ - "Then execute batches in order B1..B8."
+ local_only_guidance:
+ - "Do not attempt to push to GitHub until user confirms fork creation."
+ - "Use local branches as specified."
+ progress_marking:
+ - "Update each task's status as you complete it: todo -> doing -> done (or blocked)."
+ reporting:
+ - "After each batch, append a 'Batch Report' section to docs/fleet/README.md including: what changed, commands run, and any known issues."
+
+```
+
+## Progress
+
+### B0
+- [x] B0-T1
+- [x] B0-T2
+- [x] B0-T3
+- [x] B0-T4
+
+### B1
+- [x] B1-T1
+- [x] B1-T2
+- [x] B1-T3
+- [x] B1-T4
+- [x] B1-T5
+- [x] B1-T6
+- [x] B1-T7
+
+### B2
+- [x] B2-T1
+- [x] B2-T2
+- [x] B2-T3
+- [x] B2-T4
+- [x] B2-T5
+
+### B3
+- [x] B3-T1
+- [x] B3-T2
+- [x] B3-T3
+- [x] B3-T4
+- [x] B3-T5
+- [x] B3-T6
+
+### B4
+- [x] B4-T1
+- [x] B4-T2
+- [x] B4-T3
+- [x] B4-T4
+- [x] B4-T5
+
+### B5
+- [x] B5-T1
+- [x] B5-T2
+- [x] B5-T3
+- [x] B5-T4
+- [x] B5-T5
+- [x] B5-T6
+
+### B6
+- [x] B6-T1
+- [x] B6-T2
+- [x] B6-T3
+- [x] B6-T4
+- [x] B6-T5
+- [x] B6-T6
+- [x] B6-T7
+
+### B7
+- [x] B7-T1
+- [x] B7-T2
+- [x] B7-T3
+- [x] B7-T4
+- [x] B7-T5
+
+### B8
+- [x] B8-T1
+- [x] B8-T2
+- [x] B8-T3
+- [x] B8-T4
diff --git a/docs/fleet/README.md b/docs/fleet/README.md
new file mode 100644
index 00000000..be92f966
--- /dev/null
+++ b/docs/fleet/README.md
@@ -0,0 +1,495 @@
+# UberClawControl Fleet Plan
+
+## Goal
+
+UberClawControl extends the current Rails app into a control plane for managing distributed agents that can claim work, execute commands, and report state safely.
+
+## Control Plane vs Data Plane
+
+### Control Plane
+
+- Owns agent registration, authentication, assignment, and orchestration.
+- Exposes APIs and UI for operators to assign tasks and issue commands.
+- Tracks queue state, leases, command lifecycle, and audit events.
+
+### Data Plane
+
+- Runs on agent hosts where work actually executes.
+- Polls/streams for assignments, performs task execution, and sends heartbeats/results.
+- Handles host-local operations (drain, resume, restart, upgrade) through constrained handlers.
+
+## Planned Endpoints (Placeholder)
+
+- `POST /api/v1/agents/register`
+- `POST /api/v1/agents/authenticate`
+- `GET /api/v1/agents/:id/assignments`
+- `POST /api/v1/tasks/:id/claim`
+- `POST /api/v1/tasks/:id/release`
+- `POST /api/v1/commands`
+- `GET /api/v1/commands/:id`
+
+## Batch Checklist
+
+| Batch | Task IDs | Status |
+| --- | --- | --- |
+| B0 | B0-T1..B0-T4 | done |
+| B1 | B1-T1..B1-T7 | done |
+| B2 | B2-T1..B2-T5 | done |
+| B3 | B3-T1..B3-T6 | done |
+| B4 | B4-T1..B4-T5 | done |
+| B5 | B5-T1..B5-T6 | done |
+| B6 | B6-T1..B6-T7 | done |
+| B7 | B7-T1..B7-T5 | done |
+| B8 | B8-T1..B8-T4 | done |
+
+## B1 Schema Ledger
+
+### New tables
+
+| Table | Purpose | Key columns |
+| --- | --- | --- |
+| `agents` | First-class agent identity and host/runtime metadata | `user_id`, `name`, `status`, `hostname`, `host_uid`, `platform`, `version`, `tags`, `last_heartbeat_at`, `metadata` |
+| `agent_tokens` | Agent auth tokens with digest-only persistence | `agent_id`, `name`, `token_digest`, `last_used_at` |
+
+### Table updates
+
+| Table | Added columns | Compatibility notes |
+| --- | --- | --- |
+| `tasks` | `assigned_agent_id`, `claimed_by_agent_id` | Keeps existing `assigned_to_agent` boolean for backwards compatibility |
+| `task_activities` | `actor_agent_id` | Optional foreign key to `agents` for agent-attributed activity events |
+
+## B2 Auth Flow and Token Types
+
+### Token types
+
+| Token type | Stored value | Principal resolved | Notes |
+| --- | --- | --- | --- |
+| `ApiToken` | Plain token (legacy) | `current_user` | Backwards-compatible user API auth path |
+| `AgentToken` | SHA-256 digest only | `current_agent` + `current_user` from agent owner | Agent token `last_used_at` updated by `AgentToken.authenticate` |
+| `JoinToken` | SHA-256 digest only | Registration bootstrap to a specific `user` | One-time use with `expires_at` + `used_at` enforcement |
+
+### Authentication flow
+
+- Read bearer token from `Authorization` header.
+- Try `AgentToken.authenticate` first; if valid set both `current_agent` and owner `current_user`.
+- Fallback to `ApiToken.authenticate` when no agent token matches.
+- Return existing `401 Unauthorized` response when neither path authenticates.
+- Keep user agent header updates for user-token flow only.
+
+## B3 Agent Lifecycle API
+
+### Implemented endpoints
+
+- `POST /api/v1/agents/register`
+- `POST /api/v1/agents/:id/heartbeat`
+- `GET /api/v1/agents`
+- `GET /api/v1/agents/:id`
+- `PATCH /api/v1/agents/:id`
+
+### Register example
+
+```bash
+curl -X POST "http://localhost:3000/api/v1/agents/register" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "join_token": "",
+ "agent": {
+ "name": "worker-01",
+ "hostname": "worker-01.local",
+ "host_uid": "host-uid-01",
+ "platform": "linux-amd64",
+ "version": "0.1.0",
+ "tags": ["edge", "gpu"],
+ "metadata": {"region": "us-east"}
+ }
+ }'
+```
+
+Returns a one-time plaintext `agent_token` in the response body.
+
+### Heartbeat example
+
+```bash
+curl -X POST "http://localhost:3000/api/v1/agents//heartbeat" \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "status": "online",
+ "version": "0.1.1",
+ "platform": "linux-amd64",
+ "metadata": {"load": 0.42}
+ }'
+```
+
+Returns updated agent fields plus a `desired_state` placeholder payload.
+
+## B4 Task Dispatch Eligibility and Assignment Rules
+
+- `GET /api/v1/tasks/next` is agent-scoped and only serves requests authenticated with an `AgentToken`.
+- Dispatch eligibility requires all of the following:
+ - task `status = up_next`
+ - `blocked = false`
+ - `claimed_by_agent_id IS NULL`
+ - assignment scope passes: `assigned_agent_id = current_agent.id` OR `assigned_agent_id IS NULL`
+- Agents with `status = draining` do not receive new tasks from `/tasks/next`.
+- Selection and claim are atomic: scheduler uses transaction + row lock (`FOR UPDATE SKIP LOCKED`) before setting:
+ - `claimed_by_agent_id = current_agent.id`
+ - `agent_claimed_at = now`
+ - `status = in_progress` (current API behavior)
+- `PATCH /api/v1/tasks/:id/claim` and `PATCH /api/v1/tasks/:id/unclaim` write claim fields and create activity rows attributed to `task_activities.actor_agent_id`.
+
+## B5 Orchestration Commands
+
+### Command lifecycle endpoints
+
+- `POST /api/v1/agents/:id/commands` - Enqueue a command for an agent (admin or owner only)
+- `GET /api/v1/agent_commands/next` - Poll next pending command for current agent (agent-only)
+- `PATCH /api/v1/agent_commands/:id/ack` - Acknowledge a command (agent-only, self ownership)
+- `PATCH /api/v1/agent_commands/:id/complete` - Complete a command with result (agent-only, self ownership)
+
+### State transitions
+
+```
+pending -> acknowledged -> completed
+ \-> failed
+```
+
+### Command kinds and sample payloads
+
+| Kind | Payload | Result | Description |
+| --- | --- | --- | --- |
+| `drain` | `{ "reason": "maintenance" }` | `{ "success": true }` | Stop accepting new tasks |
+| `resume` | `{}` | `{ "success": true }` | Resume accepting tasks |
+| `restart` | `{ "delay_seconds": 30 }` | `{ "success": true, "restarted_at": "..." }` | Restart the OpenClaw process |
+| `upgrade` | `{ "version": "1.2.3", "force": false }` | `{ "success": true, "previous_version": "1.2.2" }` | Upgrade to specified version |
+| `shell` | `{ "command": "echo hello" }` | `{ "success": true, "output": "hello\n", "exit_code": 0 }` | Execute shell command |
+
+### Enqueue example
+
+```bash
+curl -X POST "http://localhost:3000/api/v1/agents//commands" \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{ "kind": "drain", "payload": { "reason": "scheduled maintenance" } }'
+```
+
+### Agent poll and ack example
+
+```bash
+# Poll for next command
+curl -X GET "http://localhost:3000/api/v1/agent_commands/next" \
+ -H "Authorization: Bearer "
+
+# Acknowledge command
+curl -X PATCH "http://localhost:3000/api/v1/agent_commands//ack" \
+ -H "Authorization: Bearer "
+
+# Complete command with result
+curl -X PATCH "http://localhost:3000/api/v1/agent_commands//complete" \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{ "result": { "success": true } }'
+```
+
+## B6 Go Agent Daemon
+
+### Building
+
+```bash
+cd agent
+go build ./cmd/claw-agent
+```
+
+The binary will be created at `./claw-agent`.
+
+### Running two agents locally
+
+1. Create join tokens for your user in the Rails console:
+
+```bash
+bin/rails console
+```
+
+```ruby
+user = User.first
+jt1 = JoinToken.create!(user: user)
+jt2 = JoinToken.create!(user: user)
+puts "Token 1: #{jt1.token}"
+puts "Token 2: #{jt2.token}"
+```
+
+2. Start the Rails server:
+
+```bash
+bin/rails server
+```
+
+3. Run the first agent:
+
+```bash
+CLAWDECK_API_URL=http://localhost:3000 \
+CLAWDECK_JOIN_TOKEN= \
+CLAWDECK_AGENT_TOKEN_PATH=/tmp/claw-agent-1-token.json \
+CLAWDECK_AGENT_NAME=agent-1 \
+./agent/claw-agent
+```
+
+4. Run the second agent in another terminal:
+
+```bash
+CLAWDECK_API_URL=http://localhost:3000 \
+CLAWDECK_JOIN_TOKEN= \
+CLAWDECK_AGENT_TOKEN_PATH=/tmp/claw-agent-2-token.json \
+CLAWDECK_AGENT_NAME=agent-2 \
+./agent/claw-agent
+```
+
+### Package structure
+
+| Package | Purpose |
+| --- | --- |
+| `cmd/claw-agent` | CLI entrypoint, loads config, starts all loops |
+| `internal/config` | Config loading from env vars, token persistence to disk (0600) |
+| `internal/clawdeck` | HTTP client for ClawDeck API (register, heartbeat, tasks, commands) |
+| `internal/runner` | Heartbeat loop and task execution loop |
+| `internal/orchestrator` | Command loop and command handlers (drain/resume/restart/upgrade) |
+
+### Environment variables
+
+| Variable | Default | Description |
+| --- | --- | --- |
+| `CLAWDECK_API_URL` | `http://localhost:3000` | ClawDeck API base URL |
+| `CLAWDECK_JOIN_TOKEN` | (required if no token) | Join token for initial registration |
+| `CLAWDECK_AGENT_TOKEN_PATH` | (none) | Path to persist agent token |
+| `CLAWDECK_AGENT_NAME` | `claw-agent` | Agent name |
+| `CLAWDECK_HOSTNAME` | (system hostname) | Agent hostname |
+| `CLAWDECK_HOST_UID` | (none) | Unique host identifier |
+| `CLAWDECK_PLATFORM` | (none) | Platform string (e.g., linux-amd64) |
+| `CLAWDECK_VERSION` | `0.1.0` | Agent version |
+
+## Batch Report - B0
+
+### Commands run
+
+- `git branch --list`
+- `git branch fleet/batch1-schema`
+- `git branch fleet/batch2-auth`
+- `git branch fleet/batch3-agent-api`
+- `git branch fleet/batch4-scheduler`
+- `git branch fleet/batch5-orchestration`
+- `git branch fleet/batch6-go-agent`
+- `git branch fleet/batch7-ui`
+- `git branch fleet/batch8-integration`
+- `bin/rails test`
+- `timeout 45s bin/dev`
+
+### Known issues
+
+- `bin/rails test` fails immediately with `/usr/bin/env: 'ruby': Permission denied`.
+- `bin/dev` fails to boot due to `gem` and `foreman` permission errors.
+
+## B7 Admin UI for Fleet Management
+
+### Agents List Page
+
+Navigate to `/agents` to see all registered agents with:
+- Status indicator (online/draining/offline/disabled)
+- Last heartbeat timestamp
+- Hostname, platform, and version
+- Tags associated with the agent
+
+### Agent Detail Page
+
+Click on an agent to see:
+- Full agent details (hostname, platform, version, tags, metadata)
+- Orchestration action buttons:
+ - **Drain**: Stop accepting new tasks (agent transitions to draining state)
+ - **Resume**: Resume accepting tasks (agent transitions back to online)
+ - **Restart**: Restart the OpenClaw process on the agent
+- Recent command history with state (pending/acknowledged/completed/failed)
+- Active tasks (claimed and assigned)
+
+### Task Assignment Dropdown
+
+On task cards (right-click context menu) and in the task panel:
+- **Auto / Any**: Task can be claimed by any available agent
+- **Specific Agent**: Task is assigned to a specific agent and only that agent can claim it
+- Legacy `assigned_to_agent` boolean is kept in sync for backwards compatibility
+
+### Visual Indicators on Task Cards
+
+Tasks with agent activity show:
+- Agent badge with status (claimed vs queued)
+- Agent name when assigned or claimed
+- Pulsing indicator for active work
+- Green badge for tasks ready for review
+
+### Navigation
+
+- Access Agents from the user dropdown menu in the navbar
+- Link to register new agents from the agents list page (goes to Settings โ Agents section)
+
+## B8 Integration & Hardening
+
+### Running 2-3 Agents Locally for Testing
+
+#### Quick Start
+
+1. Start the Rails server:
+
+```bash
+bin/rails server
+```
+
+2. Create join tokens in Rails console:
+
+```ruby
+user = User.first
+tokens = 3.times.map { JoinToken.create!(user: user).tap { |jt| puts "Token: #{jt.token}" } }
+```
+
+3. Run agents in separate terminals:
+
+```bash
+# Terminal 1
+CLAWDECK_API_URL=http://localhost:3000 \
+CLAWDECK_JOIN_TOKEN= \
+CLAWDECK_AGENT_TOKEN_PATH=/tmp/agent1-token.json \
+CLAWDECK_AGENT_NAME=agent-1 \
+./agent/claw-agent
+
+# Terminal 2
+CLAWDECK_API_URL=http://localhost:3000 \
+CLAWDECK_JOIN_TOKEN= \
+CLAWDECK_AGENT_TOKEN_PATH=/tmp/agent2-token.json \
+CLAWDECK_AGENT_NAME=agent-2 \
+./agent/claw-agent
+
+# Terminal 3
+CLAWDECK_API_URL=http://localhost:3000 \
+CLAWDECK_JOIN_TOKEN= \
+CLAWDECK_AGENT_TOKEN_PATH=/tmp/agent3-token.json \
+CLAWDECK_AGENT_NAME=agent-3 \
+./agent/claw-agent
+```
+
+### Viewing Task Distribution
+
+1. Navigate to `/agents` to see all running agents
+2. Click on an agent to see its claimed tasks
+3. Tasks will be distributed across agents automatically
+
+To see task distribution in real-time:
+
+```ruby
+# Rails console
+Task.where.not(claimed_by_agent_id: nil).group(:claimed_by_agent_id).count
+```
+
+### Using the Simulated Agent Harness (Ruby)
+
+For E2E testing without the Go binary:
+
+```ruby
+require "test/support/simulated_agent"
+
+# Create join token
+jt = JoinToken.create!(user: User.first)
+
+# Initialize agent
+agent = SimulatedAgent.new(
+ api_url: "http://localhost:3000",
+ join_token: jt.token
+)
+
+# Register
+agent.register(name: "test-agent", hostname: "test.local")
+
+# Heartbeat
+agent.heartbeat(status: "online")
+
+# Poll for tasks
+result = agent.poll_task
+if result[:task]
+ agent.complete_task(result[:task]["id"], status: "done")
+end
+
+# Poll for commands
+cmd = agent.poll_command
+if cmd[:command]
+ agent.ack_command(cmd[:command]["id"])
+ agent.complete_command(cmd[:command]["id"], result: { success: true })
+end
+
+# Run automated task loop
+agent.run_task_loop(duration_seconds: 60, poll_interval: 2)
+```
+
+### Upgrade Path from Legacy `assigned_to_agent` Boolean
+
+The system maintains backwards compatibility with the legacy `assigned_to_agent` boolean field while supporting the new `assigned_agent_id` relationship.
+
+#### Migration Strategy
+
+1. **Phase 1 (Current)**: Both fields exist and can be used
+ - `assigned_to_agent` boolean: Legacy field, kept for backwards compatibility
+ - `assigned_agent_id`: New field for specific agent assignment
+
+2. **Phase 2**: Sync both fields during writes
+ - Setting `assigned_agent_id` automatically sets `assigned_to_agent = true`
+ - Setting `assigned_to_agent = false` clears `assigned_agent_id`
+
+3. **Phase 3 (Future)**: Deprecate legacy field
+ - Add deprecation warning when `assigned_to_agent` is used directly
+ - Provide migration script to backfill `assigned_agent_id` from boolean
+
+#### Code Examples
+
+```ruby
+# Old way (still works)
+task.update!(assigned_to_agent: true)
+
+# New way (recommended)
+task.update!(assigned_agent: specific_agent)
+
+# Query patterns
+Task.where(assigned_to_agent: true) # Legacy query
+Task.where.not(assigned_agent_id: nil) # New query
+Task.where(assigned_agent: current_agent) # Agent-scoped query
+```
+
+#### Data Migration Script
+
+To migrate existing boolean data to agent assignments:
+
+```ruby
+# For tasks marked assigned_to_agent=true but no assigned_agent_id,
+# you may want to create a default agent or leave null for "any agent"
+Task.where(assigned_to_agent: true, assigned_agent_id: nil).find_each do |task|
+ # Option 1: Leave null (any agent can claim)
+ # Option 2: Assign to a default agent
+ # task.update!(assigned_agent: default_agent)
+end
+```
+
+### Concurrency Testing
+
+The test suite includes concurrency tests that verify:
+
+1. **No double-claim**: Two agents cannot claim the same task via `/tasks/next`
+2. **Race safety**: Simultaneous claims never result in conflicts
+3. **Auth boundaries**: Agents cannot access other users' resources
+
+Run concurrency tests:
+
+```bash
+bin/rails test test/integration/agent_concurrency_test.rb
+```
+
+### Security Documentation
+
+See [SECURITY.md](./SECURITY.md) for:
+- Token storage strategy
+- Token rotation plan
+- Command allowlist enforcement
+- Cross-user isolation details
diff --git a/docs/fleet/SECURITY.md b/docs/fleet/SECURITY.md
new file mode 100644
index 00000000..4da8f13f
--- /dev/null
+++ b/docs/fleet/SECURITY.md
@@ -0,0 +1,221 @@
+# UberClawControl Security Documentation
+
+## Overview
+
+This document covers security considerations for the multi-agent orchestration system, including token management, access control, and operational security.
+
+## Token Storage
+
+### Token Types and Storage Strategy
+
+| Token Type | Storage | Lifetime | Use Case |
+|------------|---------|----------|----------|
+| Join Token | SHA-256 digest only | Single use, 24h expiry | Agent registration bootstrap |
+| Agent Token | SHA-256 digest only | Long-lived, manual revocation | Agent authentication |
+| API Token | Plaintext (legacy) | Long-lived | User API access |
+
+### Why Digest-Only Storage
+
+Agent tokens and join tokens are stored as SHA-256 digests, never in plaintext:
+
+```ruby
+# token_digest is computed during creation
+token = SecureRandom.hex(32)
+digest = Digest::SHA256.hexdigest(token)
+# Only the digest is stored; plaintext token is returned once to the caller
+```
+
+This approach ensures:
+- Database compromise does not expose usable tokens
+- Tokens cannot be recovered from stored data
+- Constant-time comparison prevents timing attacks
+
+### Token Lifecycle
+
+1. **Join Token Creation**: Admin creates token via console or UI
+2. **Agent Registration**: Agent presents join token, receives agent token
+3. **Token Persistence**: Agent stores token locally (file with 0600 permissions)
+4. **Ongoing Auth**: Token digest comparison on each request
+5. **Revocation**: Admin can disable agent or revoke tokens
+
+## Token Rotation Plan
+
+### Current State
+
+Token rotation requires manual intervention:
+1. Create new join token for user
+2. Register new agent (creates new agent token)
+3. Disable old agent
+
+### Future Rotation Endpoint (Stub Design)
+
+```
+POST /api/v1/agents/:id/rotate_token
+```
+
+Response:
+```json
+{
+ "agent_token": "new_plaintext_token_returned_once",
+ "previous_token_revoked_at": "2026-02-22T12:00:00Z"
+}
+```
+
+Rotation flow:
+1. Agent requests rotation (authenticated with current token)
+2. New token generated, old token invalidated
+3. New token returned once
+4. Agent must persist new token immediately
+
+### Grace Period Design (Future)
+
+For zero-downtime rotation:
+1. New token issued with old token still valid
+2. Both tokens work for configurable grace period (e.g., 5 minutes)
+3. After grace period, old token automatically revoked
+4. Agent must complete rotation within grace window
+
+## Command Allowlist Enforcement
+
+### Supported Command Kinds
+
+| Command | Payload Fields | Agent Behavior |
+|---------|---------------|----------------|
+| `drain` | `reason` (optional) | Stop accepting new tasks |
+| `resume` | none | Resume accepting tasks |
+| `restart` | `delay_seconds` (optional) | Restart OpenClaw process |
+| `upgrade` | `version`, `force` | Upgrade to specified version |
+| `shell` | `command` | Execute shell command (restricted) |
+
+### Validation
+
+Commands are validated at the controller level:
+
+```ruby
+# app/controllers/api/v1/agent_commands_controller.rb
+VALID_KINDS = %w[drain resume restart upgrade shell].freeze
+
+def enqueue
+ unless VALID_KINDS.include?(params[:kind])
+ render json: { error: "Invalid command kind" }, status: :unprocessable_entity
+ return
+ end
+ # ...
+end
+```
+
+### Shell Command Restrictions
+
+The `shell` command kind should be:
+- Disabled by default in production
+- Restricted to a configurable allowlist of commands
+- Logged with full audit trail
+
+Recommended configuration:
+
+```yaml
+# config/fleet.yml
+production:
+ shell_commands:
+ enabled: false
+ allowlist: []
+development:
+ shell_commands:
+ enabled: true
+ allowlist:
+ - "echo *"
+ - "date"
+ - "uptime"
+```
+
+## Cross-User Isolation
+
+### Data Access Boundaries
+
+Every API request is scoped to the authenticated principal:
+
+1. **User API Token**: Access limited to `current_user` resources
+2. **Agent Token**: Access limited to `current_agent.user` resources
+
+### Implementation
+
+```ruby
+# app/controllers/concerns/api/token_authentication.rb
+def authenticate_api_token
+ token = extract_token_from_header
+ agent_token = AgentToken.authenticate(token)
+
+ if agent_token
+ @current_agent = agent_token.agent
+ @current_user = @current_agent.user # Agent acts within owner scope
+ else
+ @current_user = ApiToken.authenticate(token)
+ end
+end
+```
+
+### Ownership Checks
+
+**Tasks**: All queries use `current_user.tasks` scope
+
+```ruby
+# app/controllers/api/v1/tasks_controller.rb
+def set_task
+ @task = current_user.tasks.find(params[:id]) # Raises RecordNotFound if cross-user
+end
+```
+
+**Agents**: Only owner can view/manage
+
+```ruby
+# app/controllers/api/v1/agents_controller.rb
+def index
+ @agents = current_user.agents
+end
+```
+
+**Commands**: Agent can only ack/complete its own commands
+
+```ruby
+# app/controllers/api/v1/agent_commands_controller.rb
+def require_command_ownership!
+ return if current_agent.id == @agent_command.agent_id
+ render json: { error: "Forbidden" }, status: :forbidden
+end
+```
+
+### Concurrency Safety
+
+Race conditions are prevented via database-level locking:
+
+```ruby
+# app/controllers/api/v1/tasks_controller.rb
+Task.transaction do
+ @task = current_user.tasks
+ .eligible_for_agent(current_agent)
+ .lock("FOR UPDATE SKIP LOCKED")
+ .first
+
+ if @task
+ @task.update!(claimed_by_agent: current_agent, ...)
+ end
+end
+```
+
+This ensures:
+- Two agents cannot claim the same task
+- No double-dispatch under concurrent load
+- Atomic claim-and-return operation
+
+## Security Checklist
+
+- [x] Tokens stored as digests, never plaintext
+- [x] Constant-time token comparison
+- [x] Cross-user access blocked at model scope level
+- [x] Agent-to-agent isolation enforced
+- [x] Command kinds validated against allowlist
+- [x] Last used timestamp updated on each token use
+- [ ] Token rotation endpoint implemented (future)
+- [ ] Audit logging for sensitive operations (future)
+- [ ] Rate limiting per agent (future)
+- [ ] Token expiry for agent tokens (future)
diff --git a/test/controllers/api/v1/agent_commands_controller_test.rb b/test/controllers/api/v1/agent_commands_controller_test.rb
new file mode 100644
index 00000000..c8fa69e1
--- /dev/null
+++ b/test/controllers/api/v1/agent_commands_controller_test.rb
@@ -0,0 +1,158 @@
+require "test_helper"
+
+class Api::V1::AgentCommandsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = users(:one)
+ @other_user = users(:two)
+ @admin = users(:admin)
+ @user_token = api_tokens(:one).token
+ @admin_token = api_tokens(:admin).token
+
+ @agent = Agent.create!(
+ user: @user,
+ name: "Command Worker",
+ hostname: "cmd-worker.local",
+ host_uid: "uid-cmd-worker",
+ platform: "linux",
+ version: "1.0.0"
+ )
+ _agent_token, @agent_plaintext_token = AgentToken.issue!(agent: @agent, name: "Primary")
+
+ @other_agent = Agent.create!(
+ user: @other_user,
+ name: "Other Worker",
+ hostname: "other-worker.local",
+ host_uid: "uid-other-worker",
+ platform: "linux",
+ version: "1.0.0"
+ )
+ _other_agent_token, @other_agent_plaintext_token = AgentToken.issue!(agent: @other_agent, name: "Secondary")
+ end
+
+ test "admin can enqueue command" do
+ assert_difference "AgentCommand.count", 1 do
+ post "/api/v1/agents/#{@agent.id}/commands",
+ headers: auth_header(@admin_token),
+ params: { kind: "drain", payload: { reason: "maintenance" } }
+ end
+
+ assert_response :created
+ body = response.parsed_body
+ assert_equal "drain", body["kind"]
+ assert_equal "pending", body["state"]
+ assert_equal @admin.id, body["requested_by_user_id"]
+ end
+
+ test "owner can enqueue command" do
+ assert_difference "AgentCommand.count", 1 do
+ post "/api/v1/agents/#{@agent.id}/commands",
+ headers: auth_header(@user_token),
+ params: { kind: "restart" }
+ end
+
+ assert_response :created
+ body = response.parsed_body
+ assert_equal "restart", body["kind"]
+ assert_equal @user.id, body["requested_by_user_id"]
+ end
+
+ test "non-owner cannot enqueue command" do
+ assert_no_difference "AgentCommand.count" do
+ post "/api/v1/agents/#{@agent.id}/commands",
+ headers: auth_header(api_tokens(:two).token),
+ params: { kind: "drain" }
+ end
+
+ assert_response :forbidden
+ end
+
+ test "agent can poll next command" do
+ @agent.agent_commands.create!(kind: "drain", payload: {})
+
+ get "/api/v1/agent_commands/next", headers: auth_header(@agent_plaintext_token)
+
+ assert_response :success
+ body = response.parsed_body
+ assert_equal "drain", body["kind"]
+ assert_equal "acknowledged", body["state"]
+ end
+
+ test "next returns no content when no pending commands" do
+ get "/api/v1/agent_commands/next", headers: auth_header(@agent_plaintext_token)
+
+ assert_response :no_content
+ end
+
+ test "next requires agent token" do
+ @agent.agent_commands.create!(kind: "drain", payload: {})
+
+ get "/api/v1/agent_commands/next", headers: auth_header(@user_token)
+
+ assert_response :unauthorized
+ end
+
+ test "agent can ack own command" do
+ command = @agent.agent_commands.create!(kind: "drain", payload: {})
+
+ patch "/api/v1/agent_commands/#{command.id}/ack", headers: auth_header(@agent_plaintext_token)
+
+ assert_response :success
+ command.reload
+ assert_equal "acknowledged", command.state
+ assert command.acked_at.present?
+ end
+
+ test "agent can complete own command" do
+ command = @agent.agent_commands.create!(kind: "drain", payload: {}, state: :acknowledged, acked_at: Time.current)
+
+ patch "/api/v1/agent_commands/#{command.id}/complete",
+ headers: auth_header(@agent_plaintext_token).merge("Content-Type" => "application/json"),
+ params: { result: { "success" => true, "message" => "Drained" } }.to_json
+
+ assert_response :success
+ command.reload
+ assert_equal "completed", command.state
+ assert command.completed_at.present?
+ assert_equal({ "success" => true, "message" => "Drained" }, command.result)
+ end
+
+ test "ack requires pending state" do
+ command = @agent.agent_commands.create!(kind: "drain", payload: {}, state: :acknowledged)
+
+ patch "/api/v1/agent_commands/#{command.id}/ack", headers: auth_header(@agent_plaintext_token)
+
+ assert_response :unprocessable_entity
+ end
+
+ test "complete requires acknowledged state" do
+ command = @agent.agent_commands.create!(kind: "drain", payload: {})
+
+ patch "/api/v1/agent_commands/#{command.id}/complete", headers: auth_header(@agent_plaintext_token)
+
+ assert_response :unprocessable_entity
+ end
+
+ test "cross-agent access blocked for ack" do
+ command = @agent.agent_commands.create!(kind: "drain", payload: {})
+
+ patch "/api/v1/agent_commands/#{command.id}/ack", headers: auth_header(@other_agent_plaintext_token)
+
+ assert_response :forbidden
+ assert_equal "pending", command.reload.state
+ end
+
+ test "cross-agent access blocked for complete" do
+ command = @agent.agent_commands.create!(kind: "drain", payload: {}, state: :acknowledged, acked_at: Time.current)
+
+ patch "/api/v1/agent_commands/#{command.id}/complete", headers: auth_header(@other_agent_plaintext_token)
+
+ assert_response :forbidden
+ assert_equal "acknowledged", command.reload.state
+ end
+
+ private
+
+ def auth_header(token)
+ { "Authorization" => "Bearer #{token}" }
+ end
+end
diff --git a/test/controllers/api/v1/agents_controller_test.rb b/test/controllers/api/v1/agents_controller_test.rb
new file mode 100644
index 00000000..d94679e0
--- /dev/null
+++ b/test/controllers/api/v1/agents_controller_test.rb
@@ -0,0 +1,174 @@
+require "test_helper"
+
+class Api::V1::AgentsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = users(:one)
+ @other_user = users(:two)
+ @user_token = api_tokens(:one).token
+
+ @agent = Agent.create!(
+ user: @user,
+ name: "Worker One",
+ hostname: "worker-one.local",
+ host_uid: "uid-worker-one",
+ platform: "linux",
+ version: "1.0.0"
+ )
+ _agent_token, @agent_plaintext_token = AgentToken.issue!(agent: @agent, name: "Primary")
+
+ @other_agent = Agent.create!(
+ user: @other_user,
+ name: "Worker Two",
+ hostname: "worker-two.local",
+ host_uid: "uid-worker-two",
+ platform: "linux",
+ version: "1.0.0"
+ )
+ AgentToken.issue!(agent: @other_agent, name: "Secondary")
+ end
+
+ test "register consumes join token and returns plaintext agent token" do
+ join_token, plaintext_join_token = JoinToken.issue!(user: @user, created_by_user: @user)
+
+ assert_difference "Agent.count", 1 do
+ assert_difference "AgentToken.count", 1 do
+ post "/api/v1/agents/register", params: {
+ join_token: plaintext_join_token,
+ agent: {
+ name: "Batch Worker",
+ hostname: "batch-worker.local",
+ host_uid: "uid-batch-worker",
+ platform: "linux-amd64",
+ version: "2.4.0",
+ tags: [ "blue", "runner" ],
+ metadata: { region: "us-east" }
+ }
+ }
+ end
+ end
+
+ assert_response :created
+ body = response.parsed_body
+ assert body["agent_token"].present?
+ assert_equal "Batch Worker", body.dig("agent", "name")
+ assert_equal @user.id, body.dig("agent", "user_id")
+ assert join_token.reload.used_at.present?
+ end
+
+ test "register rejects invalid join token" do
+ assert_no_difference "Agent.count" do
+ post "/api/v1/agents/register", params: {
+ join_token: "invalid-token",
+ agent: { name: "Invalid Worker" }
+ }
+ end
+
+ assert_response :unauthorized
+ end
+
+ test "heartbeat requires agent token" do
+ post "/api/v1/agents/#{@agent.id}/heartbeat", headers: auth_header(@user_token)
+ assert_response :unauthorized
+ end
+
+ test "heartbeat allows agent to update itself" do
+ post "/api/v1/agents/#{@agent.id}/heartbeat",
+ headers: auth_header(@agent_plaintext_token).merge("Content-Type" => "application/json"),
+ params: {
+ status: "draining",
+ version: "2.0.0",
+ platform: "linux-arm64",
+ metadata: { "load" => 0.5 }
+ }.to_json
+
+ assert_response :success
+ @agent.reload
+ assert_equal "draining", @agent.status
+ assert_equal "2.0.0", @agent.version
+ assert_equal "linux-arm64", @agent.platform
+ assert_equal({ "load" => 0.5 }, @agent.metadata)
+ assert @agent.last_heartbeat_at.present?
+ assert_equal "none", response.parsed_body.dig("desired_state", "action")
+ end
+
+ test "heartbeat defaults status to online" do
+ @agent.update!(status: :offline)
+
+ post "/api/v1/agents/#{@agent.id}/heartbeat", headers: auth_header(@agent_plaintext_token)
+
+ assert_response :success
+ assert_equal "online", @agent.reload.status
+ end
+
+ test "heartbeat forbids cross-agent updates" do
+ post "/api/v1/agents/#{@other_agent.id}/heartbeat", headers: auth_header(@agent_plaintext_token)
+ assert_response :forbidden
+ end
+
+ test "index returns only current user agents" do
+ get "/api/v1/agents", headers: auth_header(@user_token)
+
+ assert_response :success
+ ids = response.parsed_body.map { |agent| agent["id"] }
+ assert_includes ids, @agent.id
+ assert_not_includes ids, @other_agent.id
+ end
+
+ test "index works for agent token within owner scope" do
+ get "/api/v1/agents", headers: auth_header(@agent_plaintext_token)
+
+ assert_response :success
+ ids = response.parsed_body.map { |agent| agent["id"] }
+ assert_includes ids, @agent.id
+ assert_not_includes ids, @other_agent.id
+ end
+
+ test "show is restricted to owner scope" do
+ get "/api/v1/agents/#{@other_agent.id}", headers: auth_header(@user_token)
+ assert_response :not_found
+ end
+
+ test "show works with agent token in owner scope" do
+ get "/api/v1/agents/#{@agent.id}", headers: auth_header(@agent_plaintext_token)
+
+ assert_response :success
+ assert_equal @agent.id, response.parsed_body["id"]
+ end
+
+ test "patch updates only safe fields" do
+ patch "/api/v1/agents/#{@agent.id}",
+ headers: auth_header(@user_token),
+ params: {
+ agent: {
+ name: "Renamed Worker",
+ tags: [ "nightly" ],
+ status: "disabled",
+ metadata: { role: "worker" },
+ host_uid: "hijack-attempt"
+ }
+ }
+
+ assert_response :success
+ @agent.reload
+ assert_equal "Renamed Worker", @agent.name
+ assert_equal [ "nightly" ], @agent.tags
+ assert_equal "disabled", @agent.status
+ assert_equal({ "role" => "worker" }, @agent.metadata)
+ assert_equal "uid-worker-one", @agent.host_uid
+ end
+
+ test "patch is restricted to owner scope" do
+ patch "/api/v1/agents/#{@other_agent.id}",
+ headers: auth_header(@user_token),
+ params: { agent: { name: "Should Not Update" } }
+
+ assert_response :not_found
+ assert_not_equal "Should Not Update", @other_agent.reload.name
+ end
+
+ private
+
+ def auth_header(token)
+ { "Authorization" => "Bearer #{token}" }
+ end
+end
diff --git a/test/controllers/api/v1/tasks_controller_test.rb b/test/controllers/api/v1/tasks_controller_test.rb
index b5be0407..116f3fcd 100644
--- a/test/controllers/api/v1/tasks_controller_test.rb
+++ b/test/controllers/api/v1/tasks_controller_test.rb
@@ -6,6 +6,10 @@ class Api::V1::TasksControllerTest < ActionDispatch::IntegrationTest
@api_token = api_tokens(:one)
@task = tasks(:one)
@auth_header = { "Authorization" => "Bearer #{@api_token.token}" }
+
+ @agent = Agent.create!(user: @user, name: "Worker One")
+ @agent_token, @agent_plaintext_token = AgentToken.issue!(agent: @agent, name: "Primary")
+ @agent_auth_header = { "Authorization" => "Bearer #{@agent_plaintext_token}" }
end
# Authentication tests
@@ -14,7 +18,106 @@ class Api::V1::TasksControllerTest < ActionDispatch::IntegrationTest
assert_response :unauthorized
end
+ test "user API token still authenticates" do
+ get api_v1_tasks_url, headers: @auth_header
+ assert_response :success
+ end
+
+ test "user API token still updates user agent header info" do
+ @user.update_columns(agent_name: nil, agent_emoji: nil)
+
+ get api_v1_tasks_url,
+ headers: @auth_header.merge("X-Agent-Name" => "CLI Client", "X-Agent-Emoji" => "CC")
+
+ assert_response :success
+ assert_equal "CLI Client", @user.reload.agent_name
+ assert_equal "CC", @user.agent_emoji
+ end
+
+ test "agent token authenticates and uses agent flow" do
+ @user.update_columns(agent_name: nil, agent_emoji: nil)
+
+ get api_v1_tasks_url,
+ headers: @agent_auth_header.merge("X-Agent-Name" => "Spoofed", "X-Agent-Emoji" => "ZZ")
+
+ assert_response :success
+ assert @agent_token.reload.last_used_at.present?
+ assert_nil @user.reload.agent_name
+ assert_nil @user.agent_emoji
+ end
+
+ test "cross-user access is blocked for agent token" do
+ other_agent = Agent.create!(user: users(:two), name: "Worker Two")
+ _other_token, other_plaintext = AgentToken.issue!(agent: other_agent, name: "Secondary")
+
+ get api_v1_task_url(@task), headers: { "Authorization" => "Bearer #{other_plaintext}" }
+
+ assert_response :not_found
+ end
+
# Index tests
+ test "next claims different tasks for two agents" do
+ second_agent = Agent.create!(user: @user, name: "Worker Two")
+ _token, second_plaintext_token = AgentToken.issue!(agent: second_agent, name: "Secondary")
+
+ first_task = create_up_next_task(name: "First up")
+ second_task = create_up_next_task(name: "Second up")
+
+ get next_api_v1_tasks_url, headers: @agent_auth_header
+ assert_response :success
+ first_claim_id = response.parsed_body["id"]
+
+ get next_api_v1_tasks_url, headers: { "Authorization" => "Bearer #{second_plaintext_token}" }
+ assert_response :success
+ second_claim_id = response.parsed_body["id"]
+
+ assert_not_equal first_claim_id, second_claim_id
+
+ claimed_ids = [ first_task.reload.claimed_by_agent_id, second_task.reload.claimed_by_agent_id ].compact
+ assert_includes claimed_ids, @agent.id
+ assert_includes claimed_ids, second_agent.id
+ end
+
+ test "next returns assigned task only to assigned agent" do
+ other_agent = Agent.create!(user: @user, name: "Worker Two")
+ _token, other_plaintext_token = AgentToken.issue!(agent: other_agent, name: "Secondary")
+ assigned_task = create_up_next_task(name: "Assigned", assigned_agent: @agent)
+
+ get next_api_v1_tasks_url, headers: { "Authorization" => "Bearer #{other_plaintext_token}" }
+ assert_response :no_content
+
+ get next_api_v1_tasks_url, headers: @agent_auth_header
+ assert_response :success
+ assert_equal assigned_task.id, response.parsed_body["id"]
+ end
+
+ test "next returns no task for draining agent" do
+ @agent.update!(status: :draining)
+ task = create_up_next_task(name: "Should not dispatch")
+
+ get next_api_v1_tasks_url, headers: @agent_auth_header
+ assert_response :no_content
+ assert_nil task.reload.claimed_by_agent_id
+ end
+
+ test "claim and unclaim attribute activity to current agent" do
+ task = create_up_next_task(name: "Claim me")
+
+ patch claim_api_v1_task_url(task), headers: @agent_auth_header
+ assert_response :success
+ task.reload
+ assert_equal @agent.id, task.claimed_by_agent_id
+ assert task.agent_claimed_at.present?
+ assert_equal @agent.id, task.activities.order(:created_at).last.actor_agent_id
+
+ patch unclaim_api_v1_task_url(task), headers: @agent_auth_header
+ assert_response :success
+ task.reload
+ assert_nil task.claimed_by_agent_id
+ assert_nil task.agent_claimed_at
+ assert_equal @agent.id, task.activities.order(:created_at).last.actor_agent_id
+ end
+
test "index returns user tasks" do
get api_v1_tasks_url, headers: @auth_header
assert_response :success
@@ -160,7 +263,7 @@ class Api::V1::TasksControllerTest < ActionDispatch::IntegrationTest
end
test "complete toggles completed task back to incomplete" do
- @task.update!(completed: true, completed_at: Time.current)
+ @task.update!(status: :done, completed_at: Time.current)
patch complete_api_v1_task_url(@task), headers: @auth_header
assert_response :success
@@ -182,4 +285,20 @@ class Api::V1::TasksControllerTest < ActionDispatch::IntegrationTest
assert_match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, task["updated_at"])
assert_match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, task["completed_at"])
end
+
+ private
+
+ def create_up_next_task(name:, assigned_agent: nil)
+ board = @user.boards.first || @user.boards.create!(name: "Test Board", icon: "๐", color: "gray")
+
+ Task.create!(
+ user: @user,
+ board: board,
+ name: name,
+ status: :up_next,
+ blocked: false,
+ assigned_agent: assigned_agent,
+ priority: :none
+ )
+ end
end
diff --git a/test/fixtures/api_tokens.yml b/test/fixtures/api_tokens.yml
index 0b09ffa7..900768fb 100644
--- a/test/fixtures/api_tokens.yml
+++ b/test/fixtures/api_tokens.yml
@@ -10,3 +10,9 @@ two:
name: Test Token Two
user: two
last_used_at: nil
+
+admin:
+ token: test_token_admin_abc123xyz789
+ name: Admin Token
+ user: admin
+ last_used_at: nil
diff --git a/test/fixtures/boards.yml b/test/fixtures/boards.yml
new file mode 100644
index 00000000..6bff124f
--- /dev/null
+++ b/test/fixtures/boards.yml
@@ -0,0 +1,7 @@
+one:
+ name: Test Board One
+ user: one
+
+two:
+ name: Test Board Two
+ user: two
diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml
deleted file mode 100644
index d377017b..00000000
--- a/test/fixtures/comments.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
-
-one:
- task: one
- author_type: MyString
- author_name: MyString
- body: MyText
-
-two:
- task: two
- author_type: MyString
- author_name: MyString
- body: MyText
diff --git a/test/fixtures/tasks.yml b/test/fixtures/tasks.yml
index b8f7b449..65ab7b22 100644
--- a/test/fixtures/tasks.yml
+++ b/test/fixtures/tasks.yml
@@ -1,8 +1,7 @@
-# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
-
one:
name: Test Task One
user: one
+ board: one
status: inbox
priority: none
completed: false
@@ -10,6 +9,7 @@ one:
two:
name: Test Task Two
user: two
+ board: two
status: in_progress
priority: high
completed: false
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index 1a628da4..418c2692 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -10,3 +10,8 @@ github_user:
email_address: github@example.com
provider: github
uid: "12345"
+
+admin:
+ email_address: admin@example.com
+ password_digest: <%= BCrypt::Password.create('password123') %>
+ admin: true
diff --git a/test/integration/agent_concurrency_test.rb b/test/integration/agent_concurrency_test.rb
new file mode 100644
index 00000000..0abad5fa
--- /dev/null
+++ b/test/integration/agent_concurrency_test.rb
@@ -0,0 +1,229 @@
+require "test_helper"
+require "concurrent"
+
+class AgentConcurrencyTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = users(:one)
+ @board = @user.boards.first || @user.boards.create!(name: "Test Board", icon: "๐", color: "gray")
+
+ @agent1 = Agent.create!(user: @user, name: "Worker One")
+ @agent2 = Agent.create!(user: @user, name: "Worker Two")
+ @agent3 = Agent.create!(user: @user, name: "Worker Three")
+
+ _, @token1 = AgentToken.issue!(agent: @agent1, name: "Primary")
+ _, @token2 = AgentToken.issue!(agent: @agent2, name: "Primary")
+ _, @token3 = AgentToken.issue!(agent: @agent3, name: "Primary")
+
+ @other_user = users(:two)
+ @other_agent = Agent.create!(user: @other_user, name: "Other Worker")
+ _, @other_token = AgentToken.issue!(agent: @other_agent, name: "Primary")
+ end
+
+ test "two agents cannot claim the same task via /tasks/next race condition" do
+ task1 = create_up_next_task(name: "Task 1")
+ task2 = create_up_next_task(name: "Task 2")
+ task3 = create_up_next_task(name: "Task 3")
+
+ results = Concurrent::Array.new
+ threads = []
+
+ 10.times do |i|
+ token = [ @token1, @token2, @token3 ][i % 3]
+ threads << Thread.new do
+ results << poll_next_task(token)
+ end
+ end
+
+ threads.each(&:join)
+
+ claimed_task_ids = results.map { |r| r[:task_id] }.compact
+ unique_claimed = claimed_task_ids.uniq
+
+ assert_equal claimed_task_ids.length, unique_claimed.length,
+ "Duplicate task claims detected: #{claimed_task_ids.tally}"
+
+ task1.reload
+ task2.reload
+ task3.reload
+
+ claimants = [ task1.claimed_by_agent_id, task2.claimed_by_agent_id, task3.claimed_by_agent_id ].compact
+ assert_equal 3, claimants.length, "Expected 3 tasks to be claimed"
+ end
+
+ test "simultaneous task claims never result in double-claim" do
+ tasks = 20.times.map { |i| create_up_next_task(name: "Task #{i}") }
+
+ results = Concurrent::Array.new
+ mutex = Mutex.new
+ threads = []
+
+ 40.times do |i|
+ token = [ @token1, @token2, @token3 ][i % 3]
+ threads << Thread.new do
+ sleep(rand(0.001..0.005))
+ results << poll_next_task(token)
+ end
+ end
+
+ threads.each(&:join)
+
+ claimed_ids = results.map { |r| r[:task_id] }.compact
+
+ tally = claimed_ids.tally
+ duplicates = tally.select { |_, count| count > 1 }
+
+ assert_empty duplicates, "Double-claims detected: #{duplicates}"
+ end
+
+ test "command consumption is race-safe" do
+ 5.times do |i|
+ @agent1.agent_commands.create!(kind: "drain", payload: { reason: "test #{i}" })
+ end
+
+ results = Concurrent::Array.new
+ threads = []
+
+ 10.times do
+ threads << Thread.new do
+ results << poll_next_command(@token1)
+ end
+ end
+
+ threads.each(&:join)
+
+ command_ids = results.map { |r| r[:command_id] }.compact
+ unique_ids = command_ids.uniq
+
+ assert_equal command_ids.length, unique_ids.length,
+ "Duplicate command consumption: #{command_ids.tally}"
+ end
+
+ test "agent cannot access other users tasks" do
+ other_board = @other_user.boards.first || @other_user.boards.create!(name: "Other Board", icon: "๐", color: "gray")
+ other_task = Task.create!(
+ user: @other_user,
+ board: other_board,
+ name: "Private Task",
+ status: :up_next,
+ blocked: false
+ )
+
+ get api_v1_task_url(other_task), headers: auth_header(@token1)
+ assert_response :not_found
+
+ patch claim_api_v1_task_url(other_task), headers: auth_header(@token1)
+ assert_response :not_found
+
+ other_task.reload
+ assert_nil other_task.claimed_by_agent_id
+ end
+
+ test "agent cannot access other users agents" do
+ get api_v1_agent_url(@other_agent), headers: auth_header(@token1)
+ assert_response :not_found
+
+ patch api_v1_agent_url(@other_agent),
+ headers: auth_header(@token1),
+ params: { agent: { name: "Hijacked" } }
+ assert_response :not_found
+
+ @other_agent.reload
+ assert_not_equal "Hijacked", @other_agent.name
+ end
+
+ test "agent cannot ack or complete other agents commands" do
+ command = @other_agent.agent_commands.create!(kind: "drain", payload: {})
+
+ patch "/api/v1/agent_commands/#{command.id}/ack", headers: auth_header(@token1)
+ assert_response :forbidden
+
+ command.reload
+ assert_equal "pending", command.state
+
+ command.update!(state: :acknowledged, acked_at: Time.current)
+
+ patch "/api/v1/agent_commands/#{command.id}/complete",
+ headers: auth_header(@token1),
+ params: { result: { success: true } }
+ assert_response :forbidden
+
+ command.reload
+ assert_equal "acknowledged", command.state
+ end
+
+ test "agent token cannot access tasks from different user" do
+ other_board = @other_user.boards.first || @other_user.boards.create!(name: "Other Board", icon: "๐", color: "gray")
+ _other_task = Task.create!(
+ user: @other_user,
+ board: other_board,
+ name: "Should Not Appear",
+ status: :up_next,
+ blocked: false
+ )
+
+ get api_v1_tasks_url, headers: auth_header(@token1)
+ assert_response :success
+
+ task_names = response.parsed_body.map { |t| t["name"] }
+ assert_not_includes task_names, "Should Not Appear"
+ end
+
+ test "heartbeat to other agent is forbidden" do
+ post "/api/v1/agents/#{@agent2.id}/heartbeat",
+ headers: auth_header(@token1),
+ params: { status: "draining" }
+
+ assert_response :forbidden
+
+ @agent2.reload
+ assert_not_equal "draining", @agent2.status
+ end
+
+ test "cross-user agent cannot claim assigned task" do
+ assigned_task = create_up_next_task(name: "Assigned Task", assigned_agent: @agent1)
+
+ get next_api_v1_tasks_url, headers: auth_header(@other_token)
+ assert_response :no_content
+
+ assigned_task.reload
+ assert_nil assigned_task.claimed_by_agent_id
+ end
+
+ private
+
+ def create_up_next_task(name:, assigned_agent: nil)
+ Task.create!(
+ user: @user,
+ board: @board,
+ name: name,
+ status: :up_next,
+ blocked: false,
+ assigned_agent: assigned_agent,
+ priority: :none
+ )
+ end
+
+ def auth_header(token)
+ { "Authorization" => "Bearer #{token}" }
+ end
+
+ def poll_next_task(token)
+ get next_api_v1_tasks_url, headers: auth_header(token)
+
+ if response.successful? && response.status != 204
+ { task_id: response.parsed_body["id"], status: response.status }
+ else
+ { task_id: nil, status: response.status }
+ end
+ end
+
+ def poll_next_command(token)
+ get "/api/v1/agent_commands/next", headers: auth_header(token)
+
+ if response.successful? && response.status != 204
+ { command_id: response.parsed_body["id"], status: response.status }
+ else
+ { command_id: nil, status: response.status }
+ end
+ end
+end
diff --git a/test/models/agent_token_test.rb b/test/models/agent_token_test.rb
new file mode 100644
index 00000000..0784c48e
--- /dev/null
+++ b/test/models/agent_token_test.rb
@@ -0,0 +1,31 @@
+require "test_helper"
+
+class AgentTokenTest < ActiveSupport::TestCase
+ test "issue persists digest and returns plaintext token once" do
+ agent = Agent.create!(user: users(:one), name: "Builder")
+
+ agent_token, plaintext_token = AgentToken.issue!(agent: agent, name: "Primary")
+
+ assert plaintext_token.present?
+ assert_equal agent, agent_token.agent
+ assert agent_token.token_digest.present?
+ assert_not_equal plaintext_token, agent_token.token_digest
+ assert_equal AgentToken.digest_token(plaintext_token), agent_token.token_digest
+ end
+
+ test "authenticate returns token and updates last_used_at for valid plaintext token" do
+ agent = Agent.create!(user: users(:one), name: "Runner")
+ agent_token, plaintext_token = AgentToken.issue!(agent: agent)
+
+ assert_nil agent_token.last_used_at
+
+ authenticated_token = AgentToken.authenticate(plaintext_token)
+
+ assert_equal agent_token, authenticated_token
+ assert authenticated_token.last_used_at.present?
+ end
+
+ test "does not store plaintext token column" do
+ assert_not_includes AgentToken.column_names, "token"
+ end
+end
diff --git a/test/models/join_token_test.rb b/test/models/join_token_test.rb
new file mode 100644
index 00000000..d0bf4843
--- /dev/null
+++ b/test/models/join_token_test.rb
@@ -0,0 +1,37 @@
+require "test_helper"
+
+class JoinTokenTest < ActiveSupport::TestCase
+ test "issue stores digest and returns plaintext token" do
+ join_token, plaintext_token = JoinToken.issue!(user: users(:one), created_by_user: users(:two))
+
+ assert plaintext_token.present?
+ assert_equal users(:one), join_token.user
+ assert_equal users(:two), join_token.created_by_user
+ assert join_token.token_digest.present?
+ assert_not_equal plaintext_token, join_token.token_digest
+ assert_equal JoinToken.digest_token(plaintext_token), join_token.token_digest
+ end
+
+ test "consume marks token as used for matching user" do
+ join_token, plaintext_token = JoinToken.issue!(user: users(:one), expires_in: 2.hours)
+
+ consumed = JoinToken.consume!(plaintext_token, user: users(:one))
+
+ assert_equal join_token, consumed
+ assert consumed.used_at.present?
+ end
+
+ test "consume rejects expired token" do
+ join_token, plaintext_token = JoinToken.issue!(user: users(:one), expires_in: 1.hour)
+ join_token.update!(expires_at: 1.minute.ago)
+
+ assert_nil JoinToken.consume!(plaintext_token, user: users(:one))
+ end
+
+ test "consume rejects already used token" do
+ join_token, plaintext_token = JoinToken.issue!(user: users(:one), expires_in: 1.hour)
+ join_token.update!(used_at: Time.current)
+
+ assert_nil JoinToken.consume!(plaintext_token, user: users(:one))
+ end
+end
diff --git a/test/support/simulated_agent.rb b/test/support/simulated_agent.rb
new file mode 100644
index 00000000..1cad2948
--- /dev/null
+++ b/test/support/simulated_agent.rb
@@ -0,0 +1,232 @@
+require "net/http"
+require "uri"
+require "json"
+require "socket"
+require "securerandom"
+
+class SimulatedAgent
+ attr_reader :api_url, :agent_id, :agent_token
+
+ def initialize(api_url:, join_token: nil, agent_token: nil)
+ @api_url = api_url.chomp("/")
+ @join_token = join_token
+ @agent_token = agent_token
+ @agent_id = nil
+ @http = Net::HTTP.new(URI.parse(@api_url).host, URI.parse(@api_url).port)
+ end
+
+ def register(name:, hostname: nil, host_uid: nil, platform: nil, version: "0.1.0", tags: [], metadata: {})
+ raise "Already registered or no join token" unless @join_token && @agent_token.nil?
+
+ response = post("/api/v1/agents/register", {
+ join_token: @join_token,
+ agent: {
+ name: name,
+ hostname: hostname || Socket.gethostname,
+ host_uid: host_uid || SecureRandom.uuid,
+ platform: platform || RUBY_PLATFORM,
+ version: version,
+ tags: tags,
+ metadata: metadata
+ }
+ })
+
+ if response.code.to_i == 201
+ body = JSON.parse(response.body)
+ @agent_token = body["agent_token"]
+ @agent_id = body["agent"]["id"]
+ { success: true, agent_id: @agent_id }
+ else
+ { success: false, error: response.body, status: response.code }
+ end
+ end
+
+ def heartbeat(status: "online", version: nil, platform: nil, metadata: nil)
+ raise "Not registered" unless @agent_id && @agent_token
+
+ params = { status: status }
+ params[:version] = version if version
+ params[:platform] = platform if platform
+ params[:metadata] = metadata if metadata
+
+ response = post("/api/v1/agents/#{@agent_id}/heartbeat", params)
+
+ if response.code.to_i == 200
+ body = JSON.parse(response.body)
+ { success: true, desired_state: body["desired_state"] }
+ else
+ { success: false, error: response.body, status: response.code }
+ end
+ end
+
+ def poll_task
+ raise "Not registered" unless @agent_token
+
+ response = get("/api/v1/tasks/next")
+
+ if response.code.to_i == 200
+ body = JSON.parse(response.body)
+ { success: true, task: body }
+ elsif response.code.to_i == 204
+ { success: true, task: nil }
+ else
+ { success: false, error: response.body, status: response.code }
+ end
+ end
+
+ def claim_task(task_id)
+ raise "Not registered" unless @agent_token
+
+ response = patch("/api/v1/tasks/#{task_id}/claim", {})
+
+ if response.code.to_i == 200
+ { success: true, task: JSON.parse(response.body) }
+ else
+ { success: false, error: response.body, status: response.code }
+ end
+ end
+
+ def complete_task(task_id, status: "done", activity_note: nil)
+ raise "Not registered" unless @agent_token
+
+ params = { task: { status: status } }
+ params[:activity_note] = activity_note if activity_note
+
+ response = patch("/api/v1/tasks/#{task_id}", params)
+
+ if response.code.to_i == 200
+ { success: true, task: JSON.parse(response.body) }
+ else
+ { success: false, error: response.body, status: response.code }
+ end
+ end
+
+ def poll_command
+ raise "Not registered" unless @agent_token
+
+ response = get("/api/v1/agent_commands/next")
+
+ if response.code.to_i == 200
+ body = JSON.parse(response.body)
+ { success: true, command: body }
+ elsif response.code.to_i == 204
+ { success: true, command: nil }
+ else
+ { success: false, error: response.body, status: response.code }
+ end
+ end
+
+ def ack_command(command_id)
+ raise "Not registered" unless @agent_token
+
+ response = patch("/api/v1/agent_commands/#{command_id}/ack", {})
+
+ if response.code.to_i == 200
+ { success: true, command: JSON.parse(response.body) }
+ else
+ { success: false, error: response.body, status: response.code }
+ end
+ end
+
+ def complete_command(command_id, result: {})
+ raise "Not registered" unless @agent_token
+
+ response = patch("/api/v1/agent_commands/#{command_id}/complete", { result: result })
+
+ if response.code.to_i == 200
+ { success: true, command: JSON.parse(response.body) }
+ else
+ { success: false, error: response.body, status: response.code }
+ end
+ end
+
+ def run_task_loop(duration_seconds: 60, poll_interval: 2)
+ raise "Not registered" unless @agent_token
+
+ start_time = Time.current
+ tasks_completed = 0
+
+ while Time.current - start_time < duration_seconds
+ result = poll_task
+
+ if result[:success] && result[:task]
+ task = result[:task]
+ sleep(rand(0.5..2.0))
+
+ complete_task(task["id"], status: "done", activity_note: "Completed by simulated agent")
+ tasks_completed += 1
+ end
+
+ sleep(poll_interval)
+ end
+
+ { tasks_completed: tasks_completed }
+ end
+
+ def run_command_loop(duration_seconds: 60, poll_interval: 5)
+ raise "Not registered" unless @agent_token
+
+ start_time = Time.current
+ commands_processed = 0
+
+ while Time.current - start_time < duration_seconds
+ result = poll_command
+
+ if result[:success] && result[:command]
+ command = result[:command]
+ ack_command(command["id"])
+
+ handle_command(command)
+
+ complete_command(command["id"], result: { success: true })
+ commands_processed += 1
+ end
+
+ sleep(poll_interval)
+ end
+
+ { commands_processed: commands_processed }
+ end
+
+ private
+
+ def handle_command(command)
+ case command["kind"]
+ when "drain"
+ heartbeat(status: "draining")
+ when "resume"
+ heartbeat(status: "online")
+ when "restart"
+ sleep(1)
+ when "upgrade"
+ sleep(2)
+ end
+ end
+
+ def get(path)
+ uri = URI.parse("#{@api_url}#{path}")
+ request = Net::HTTP::Get.new(uri)
+ add_auth_header(request)
+ @http.request(request)
+ end
+
+ def post(path, body)
+ uri = URI.parse("#{@api_url}#{path}")
+ request = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
+ request.body = body.to_json
+ add_auth_header(request) if @agent_token
+ @http.request(request)
+ end
+
+ def patch(path, body)
+ uri = URI.parse("#{@api_url}#{path}")
+ request = Net::HTTP::Patch.new(uri, "Content-Type" => "application/json")
+ request.body = body.to_json
+ add_auth_header(request)
+ @http.request(request)
+ end
+
+ def add_auth_header(request)
+ request["Authorization"] = "Bearer #{@agent_token}" if @agent_token
+ end
+end