cli/docs/plans/2026-02-13-bugseti-hub-service-plan.md
Snider cb017b014f docs: add BugSETI HubService implementation plan
10 tasks covering Go client + Laravel auth endpoint.
TDD approach with httptest mocks.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-16 05:53:52 +00:00

40 KiB

BugSETI HubService Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a HubService to BugSETI that coordinates issue claiming, stats sync, and leaderboard with the agentic portal API.

Architecture: Thin HTTP client (net/http) in internal/bugseti/hub.go talking directly to the portal's /api/bugseti/* endpoints. Auto-registers via forge token to get an ak_ bearer token. Offline-first with pending-ops queue.

Tech Stack: Go stdlib (net/http, encoding/json), Laravel 12 (portal endpoint), httptest (Go tests)


Task 1: Config Fields

Add hub-related fields to the Config struct so HubService can persist its state.

Files:

  • Modify: internal/bugseti/config.go
  • Test: internal/bugseti/fetcher_test.go (uses testConfigService)

Step 1: Add config fields

In internal/bugseti/config.go, add these fields to the Config struct after the ForgeToken field:

// Hub coordination (agentic portal)
HubURL     string `json:"hubUrl,omitempty"`     // Portal API base URL (e.g. https://leth.in)
HubToken   string `json:"hubToken,omitempty"`   // Cached ak_ bearer token
ClientID   string `json:"clientId,omitempty"`   // UUID, generated once on first run
ClientName string `json:"clientName,omitempty"` // Display name for leaderboard

Step 2: Add getters/setters

After the GetForgeToken() method, add:

// GetHubURL returns the hub portal URL.
func (c *ConfigService) GetHubURL() string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.config.HubURL
}

// SetHubURL sets the hub portal URL.
func (c *ConfigService) SetHubURL(url string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.config.HubURL = url
	return c.saveUnsafe()
}

// GetHubToken returns the cached hub API token.
func (c *ConfigService) GetHubToken() string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.config.HubToken
}

// SetHubToken caches the hub API token.
func (c *ConfigService) SetHubToken(token string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.config.HubToken = token
	return c.saveUnsafe()
}

// GetClientID returns the persistent client UUID.
func (c *ConfigService) GetClientID() string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.config.ClientID
}

// SetClientID sets the persistent client UUID.
func (c *ConfigService) SetClientID(id string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.config.ClientID = id
	return c.saveUnsafe()
}

// GetClientName returns the display name.
func (c *ConfigService) GetClientName() string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.config.ClientName
}

// SetClientName sets the display name.
func (c *ConfigService) SetClientName(name string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.config.ClientName = name
	return c.saveUnsafe()
}

Step 3: Run tests

Run: go test ./internal/bugseti/... -count=1 Expected: All existing tests pass (config fields are additive, no breakage).

Step 4: Commit

git add internal/bugseti/config.go
git commit -m "feat(bugseti): add hub config fields (HubURL, HubToken, ClientID, ClientName)"

Task 2: HubService Core — Types and Constructor

Create the HubService with data types, constructor, and ServiceName.

Files:

  • Create: internal/bugseti/hub.go
  • Create: internal/bugseti/hub_test.go

Step 1: Write failing tests

Create internal/bugseti/hub_test.go:

package bugseti

import (
	"testing"
)

func testHubService(t *testing.T, serverURL string) *HubService {
	t.Helper()
	cfg := testConfigService(t, nil, nil)
	if serverURL != "" {
		cfg.config.HubURL = serverURL
	}
	return NewHubService(cfg)
}

// --- Constructor / ServiceName ---

func TestNewHubService_Good(t *testing.T) {
	h := testHubService(t, "")
	if h == nil {
		t.Fatal("expected non-nil HubService")
	}
	if h.config == nil {
		t.Fatal("expected config to be set")
	}
}

func TestHubServiceName_Good(t *testing.T) {
	h := testHubService(t, "")
	if got := h.ServiceName(); got != "HubService" {
		t.Fatalf("expected HubService, got %s", got)
	}
}

func TestNewHubService_Good_GeneratesClientID(t *testing.T) {
	h := testHubService(t, "")
	id := h.GetClientID()
	if id == "" {
		t.Fatal("expected client ID to be generated")
	}
	if len(id) < 32 {
		t.Fatalf("expected UUID-length client ID, got %d chars", len(id))
	}
}

func TestNewHubService_Good_ReusesClientID(t *testing.T) {
	cfg := testConfigService(t, nil, nil)
	cfg.config.ClientID = "existing-id-12345"
	h := NewHubService(cfg)
	if h.GetClientID() != "existing-id-12345" {
		t.Fatal("expected existing client ID to be preserved")
	}
}

Step 2: Run tests to verify they fail

Run: go test ./internal/bugseti/... -run TestNewHubService -count=1 Expected: FAIL — NewHubService not defined.

Step 3: Write HubService core

Create internal/bugseti/hub.go:

// Package bugseti provides services for the BugSETI distributed bug fixing application.
package bugseti

import (
	"bytes"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"runtime"
	"sync"
	"time"
)

// HubService coordinates with the agentic portal for issue claiming,
// stats sync, and leaderboard.
type HubService struct {
	config     *ConfigService
	httpClient *http.Client
	mu         sync.Mutex
	connected  bool
	pendingOps []PendingOp
}

// PendingOp represents a failed write operation queued for retry.
type PendingOp struct {
	Method    string    `json:"method"`
	Path      string    `json:"path"`
	Body      []byte    `json:"body"`
	CreatedAt time.Time `json:"createdAt"`
}

// HubClaim represents an issue claim from the portal.
type HubClaim struct {
	IssueID     string    `json:"issue_id"`
	Repo        string    `json:"repo"`
	IssueNumber int       `json:"issue_number"`
	Title       string    `json:"issue_title"`
	URL         string    `json:"issue_url"`
	Status      string    `json:"status"`
	ClaimedAt   time.Time `json:"claimed_at"`
	PRUrl       string    `json:"pr_url,omitempty"`
	PRNumber    int       `json:"pr_number,omitempty"`
}

// LeaderboardEntry represents a single entry on the leaderboard.
type LeaderboardEntry struct {
	Rank            int    `json:"rank"`
	ClientName      string `json:"client_name"`
	ClientVersion   string `json:"client_version,omitempty"`
	IssuesCompleted int    `json:"issues_completed"`
	PRsSubmitted    int    `json:"prs_submitted"`
	PRsMerged       int    `json:"prs_merged"`
	CurrentStreak   int    `json:"current_streak"`
	LongestStreak   int    `json:"longest_streak"`
}

// GlobalStats represents aggregate stats from the portal.
type GlobalStats struct {
	TotalParticipants    int `json:"total_participants"`
	ActiveParticipants   int `json:"active_participants"`
	TotalIssuesAttempted int `json:"total_issues_attempted"`
	TotalIssuesCompleted int `json:"total_issues_completed"`
	TotalPRsSubmitted    int `json:"total_prs_submitted"`
	TotalPRsMerged       int `json:"total_prs_merged"`
	ActiveClaims         int `json:"active_claims"`
	CompletedClaims      int `json:"completed_claims"`
}

// NewHubService creates a new HubService.
func NewHubService(config *ConfigService) *HubService {
	h := &HubService{
		config: config,
		httpClient: &http.Client{
			Timeout: 10 * time.Second,
		},
	}

	// Ensure a persistent client ID exists
	if config.GetClientID() == "" {
		id := generateClientID()
		if err := config.SetClientID(id); err != nil {
			log.Printf("Warning: could not persist client ID: %v", err)
		}
	}

	// Load pending ops from disk
	h.loadPendingOps()

	return h
}

// ServiceName returns the service name for Wails.
func (h *HubService) ServiceName() string {
	return "HubService"
}

// GetClientID returns the persistent client identifier.
func (h *HubService) GetClientID() string {
	return h.config.GetClientID()
}

// IsConnected returns whether the last hub request succeeded.
func (h *HubService) IsConnected() bool {
	h.mu.Lock()
	defer h.mu.Unlock()
	return h.connected
}

// generateClientID creates a random hex client identifier.
func generateClientID() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		// Fallback to timestamp-based ID
		return fmt.Sprintf("bugseti-%d", time.Now().UnixNano())
	}
	return hex.EncodeToString(b)
}

Step 4: Run tests

Run: go test ./internal/bugseti/... -run TestNewHubService -count=1 && go test ./internal/bugseti/... -run TestHubServiceName -count=1 Expected: PASS

Step 5: Commit

git add internal/bugseti/hub.go internal/bugseti/hub_test.go
git commit -m "feat(bugseti): add HubService types and constructor"

Task 3: HTTP Request Helpers

Add the internal doRequest and doJSON methods that all API calls use.

Files:

  • Modify: internal/bugseti/hub.go
  • Modify: internal/bugseti/hub_test.go

Step 1: Write failing tests

Add to hub_test.go:

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestDoRequest_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != "Bearer test-token" {
			t.Fatal("expected bearer token")
		}
		if r.Header.Get("Content-Type") != "application/json" {
			t.Fatal("expected JSON content type")
		}
		w.WriteHeader(200)
		w.Write([]byte(`{"ok":true}`))
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "test-token"

	resp, err := h.doRequest("GET", "/test", nil)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
}

func TestDoRequest_Bad_NoHubURL(t *testing.T) {
	h := testHubService(t, "")
	_, err := h.doRequest("GET", "/test", nil)
	if err == nil {
		t.Fatal("expected error when hub URL is empty")
	}
}

func TestDoRequest_Bad_NetworkError(t *testing.T) {
	h := testHubService(t, "http://127.0.0.1:1") // Nothing listening
	h.config.config.HubToken = "test-token"

	_, err := h.doRequest("GET", "/test", nil)
	if err == nil {
		t.Fatal("expected network error")
	}
}

Step 2: Run to verify failure

Run: go test ./internal/bugseti/... -run TestDoRequest -count=1 Expected: FAIL — doRequest not defined.

Step 3: Implement helpers

Add to hub.go:

// doRequest performs an HTTP request to the hub API.
// Returns the response (caller must close body) or an error.
func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) {
	hubURL := h.config.GetHubURL()
	if hubURL == "" {
		return nil, fmt.Errorf("hub URL not configured")
	}

	fullURL := hubURL + "/api/bugseti" + path

	var bodyReader io.Reader
	if body != nil {
		data, err := json.Marshal(body)
		if err != nil {
			return nil, fmt.Errorf("failed to marshal request body: %w", err)
		}
		bodyReader = bytes.NewReader(data)
	}

	req, err := http.NewRequest(method, fullURL, bodyReader)
	if err != nil {
		return nil, fmt.Errorf("failed to create request: %w", err)
	}

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	if token := h.config.GetHubToken(); token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}

	resp, err := h.httpClient.Do(req)
	if err != nil {
		h.mu.Lock()
		h.connected = false
		h.mu.Unlock()
		return nil, fmt.Errorf("hub request failed: %w", err)
	}

	h.mu.Lock()
	h.connected = true
	h.mu.Unlock()

	return resp, nil
}

// doJSON performs a request and decodes the JSON response into dest.
func (h *HubService) doJSON(method, path string, body interface{}, dest interface{}) error {
	resp, err := h.doRequest(method, path, body)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode == 401 {
		return fmt.Errorf("unauthorised")
	}
	if resp.StatusCode == 409 {
		return &ConflictError{StatusCode: resp.StatusCode}
	}
	if resp.StatusCode == 404 {
		return &NotFoundError{StatusCode: resp.StatusCode}
	}
	if resp.StatusCode >= 400 {
		bodyBytes, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(bodyBytes))
	}

	if dest != nil {
		if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
			return fmt.Errorf("failed to decode response: %w", err)
		}
	}

	return nil
}

// ConflictError indicates a 409 response (e.g. issue already claimed).
type ConflictError struct {
	StatusCode int
}

func (e *ConflictError) Error() string {
	return fmt.Sprintf("conflict (HTTP %d)", e.StatusCode)
}

// NotFoundError indicates a 404 response.
type NotFoundError struct {
	StatusCode int
}

func (e *NotFoundError) Error() string {
	return fmt.Sprintf("not found (HTTP %d)", e.StatusCode)
}

Step 4: Run tests

Run: go test ./internal/bugseti/... -run TestDoRequest -count=1 Expected: PASS

Step 5: Commit

git add internal/bugseti/hub.go internal/bugseti/hub_test.go
git commit -m "feat(bugseti): add hub HTTP request helpers with error types"

Task 4: Auto-Register via Forge Token

Implement the auth flow: send forge token to portal, receive ak_ token.

Files:

  • Modify: internal/bugseti/hub.go
  • Modify: internal/bugseti/hub_test.go

Step 1: Write failing tests

Add to hub_test.go:

func TestAutoRegister_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/bugseti/auth/forge" {
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
		if r.Method != "POST" {
			t.Fatalf("expected POST, got %s", r.Method)
		}

		var body map[string]string
		json.NewDecoder(r.Body).Decode(&body)

		if body["forge_url"] == "" || body["forge_token"] == "" {
			w.WriteHeader(400)
			return
		}

		w.WriteHeader(201)
		json.NewEncoder(w).Encode(map[string]string{
			"api_key": "ak_test123456789012345678901234",
		})
	}))
	defer server.Close()

	cfg := testConfigService(t, nil, nil)
	cfg.config.HubURL = server.URL
	cfg.config.ForgeURL = "https://forge.lthn.io"
	cfg.config.ForgeToken = "forge-test-token"
	h := NewHubService(cfg)

	err := h.AutoRegister()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if cfg.GetHubToken() != "ak_test123456789012345678901234" {
		t.Fatalf("expected token to be cached, got %q", cfg.GetHubToken())
	}
}

func TestAutoRegister_Bad_NoForgeToken(t *testing.T) {
	cfg := testConfigService(t, nil, nil)
	cfg.config.HubURL = "http://localhost"
	h := NewHubService(cfg)

	err := h.AutoRegister()
	if err == nil {
		t.Fatal("expected error when forge token is missing")
	}
}

func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) {
	cfg := testConfigService(t, nil, nil)
	cfg.config.HubToken = "ak_existing_token"
	h := NewHubService(cfg)

	err := h.AutoRegister()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	// Token should remain unchanged
	if cfg.GetHubToken() != "ak_existing_token" {
		t.Fatal("existing token should not be overwritten")
	}
}

Step 2: Run to verify failure

Run: go test ./internal/bugseti/... -run TestAutoRegister -count=1 Expected: FAIL — AutoRegister not defined.

Step 3: Implement AutoRegister

Add to hub.go:

// AutoRegister exchanges forge credentials for a hub API key.
// Skips if a token is already cached. On 401, clears cached token.
func (h *HubService) AutoRegister() error {
	// Skip if already registered
	if h.config.GetHubToken() != "" {
		return nil
	}

	hubURL := h.config.GetHubURL()
	if hubURL == "" {
		return fmt.Errorf("hub URL not configured")
	}

	forgeURL := h.config.GetForgeURL()
	forgeToken := h.config.GetForgeToken()

	// Fall back to pkg/forge config resolution
	if forgeURL == "" || forgeToken == "" {
		resolvedURL, resolvedToken, err := resolveForgeConfig(forgeURL, forgeToken)
		if err != nil {
			return fmt.Errorf("failed to resolve forge config: %w", err)
		}
		forgeURL = resolvedURL
		forgeToken = resolvedToken
	}

	if forgeToken == "" {
		return fmt.Errorf("forge token not configured — cannot auto-register with hub")
	}

	body := map[string]string{
		"forge_url":   forgeURL,
		"forge_token": forgeToken,
		"client_id":   h.GetClientID(),
	}

	var result struct {
		APIKey string `json:"api_key"`
	}

	data, err := json.Marshal(body)
	if err != nil {
		return fmt.Errorf("failed to marshal register body: %w", err)
	}

	resp, err := h.httpClient.Post(
		hubURL+"/api/bugseti/auth/forge",
		"application/json",
		bytes.NewReader(data),
	)
	if err != nil {
		return fmt.Errorf("hub auto-register failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != 201 && resp.StatusCode != 200 {
		bodyBytes, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("hub auto-register failed (HTTP %d): %s", resp.StatusCode, string(bodyBytes))
	}

	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return fmt.Errorf("failed to decode register response: %w", err)
	}

	if result.APIKey == "" {
		return fmt.Errorf("hub returned empty API key")
	}

	// Cache the token
	if err := h.config.SetHubToken(result.APIKey); err != nil {
		return fmt.Errorf("failed to cache hub token: %w", err)
	}

	log.Printf("Hub: registered with portal, token cached")
	return nil
}

// resolveForgeConfig gets forge URL/token from pkg/forge config chain.
func resolveForgeConfig(flagURL, flagToken string) (string, string, error) {
	// Import forge package for config resolution
	// This uses the same resolution chain: config.yaml → env vars → flags
	forgeURL, forgeToken, err := forgeResolveConfig(flagURL, flagToken)
	if err != nil {
		return "", "", err
	}
	return forgeURL, forgeToken, nil
}

Note: resolveForgeConfig wraps forge.ResolveConfig — we'll use the import directly in the real code. For the plan, this shows the intent.

Step 4: Run tests

Run: go test ./internal/bugseti/... -run TestAutoRegister -count=1 Expected: PASS

Step 5: Commit

git add internal/bugseti/hub.go internal/bugseti/hub_test.go
git commit -m "feat(bugseti): hub auto-register via forge token"

Task 5: Write Operations — Register, Heartbeat, Claim, Update, Release, SyncStats

Implement all write API methods.

Files:

  • Modify: internal/bugseti/hub.go
  • Modify: internal/bugseti/hub_test.go

Step 1: Write failing tests

Add to hub_test.go:

func TestRegister_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/bugseti/register" {
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
		var body map[string]string
		json.NewDecoder(r.Body).Decode(&body)
		if body["client_id"] == "" || body["name"] == "" {
			w.WriteHeader(400)
			return
		}
		w.WriteHeader(201)
		json.NewEncoder(w).Encode(map[string]interface{}{"client": body})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"
	h.config.config.ClientName = "Test Mac"

	err := h.Register()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestHeartbeat_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	err := h.Heartbeat()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestClaimIssue_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(201)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"claim": map[string]interface{}{
				"issue_id": "owner/repo#42",
				"status":   "claimed",
			},
		})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	claim, err := h.ClaimIssue(&Issue{
		ID: "owner/repo#42", Repo: "owner/repo", Number: 42,
		Title: "Fix bug", URL: "https://forge.lthn.io/owner/repo/issues/42",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if claim == nil || claim.IssueID != "owner/repo#42" {
		t.Fatal("expected claim with correct issue ID")
	}
}

func TestClaimIssue_Bad_Conflict(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(409)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"error": "Issue already claimed",
		})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	_, err := h.ClaimIssue(&Issue{ID: "owner/repo#42", Repo: "owner/repo", Number: 42})
	if err == nil {
		t.Fatal("expected conflict error")
	}
	if _, ok := err.(*ConflictError); !ok {
		t.Fatalf("expected ConflictError, got %T", err)
	}
}

func TestUpdateStatus_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "PATCH" {
			t.Fatalf("expected PATCH, got %s", r.Method)
		}
		w.WriteHeader(200)
		json.NewEncoder(w).Encode(map[string]interface{}{"claim": map[string]string{"status": "completed"}})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	err := h.UpdateStatus("owner/repo#42", "completed", "https://forge.lthn.io/pr/1", 1)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestSyncStats_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		json.NewEncoder(w).Encode(map[string]interface{}{"synced": true})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	err := h.SyncStats(&Stats{
		IssuesCompleted: 5,
		PRsSubmitted:    3,
		PRsMerged:       2,
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
}

Step 2: Run to verify failure

Run: go test ./internal/bugseti/... -run "TestRegister_Good|TestHeartbeat|TestClaimIssue|TestUpdateStatus|TestSyncStats" -count=1 Expected: FAIL

Step 3: Implement write methods

Add to hub.go:

// Register sends client registration to the hub portal.
func (h *HubService) Register() error {
	h.drainPendingOps()

	name := h.config.GetClientName()
	if name == "" {
		name = fmt.Sprintf("BugSETI-%s", h.GetClientID()[:8])
	}

	body := map[string]string{
		"client_id": h.GetClientID(),
		"name":      name,
		"version":   GetVersion(),
		"os":        runtime.GOOS,
		"arch":      runtime.GOARCH,
	}

	return h.doJSON("POST", "/register", body, nil)
}

// Heartbeat sends a heartbeat to the hub portal.
func (h *HubService) Heartbeat() error {
	body := map[string]string{
		"client_id": h.GetClientID(),
	}
	return h.doJSON("POST", "/heartbeat", body, nil)
}

// ClaimIssue claims an issue on the hub portal.
// Returns the claim on success, ConflictError if already claimed.
func (h *HubService) ClaimIssue(issue *Issue) (*HubClaim, error) {
	if issue == nil {
		return nil, fmt.Errorf("issue is nil")
	}

	h.drainPendingOps()

	body := map[string]interface{}{
		"client_id":    h.GetClientID(),
		"issue_id":     issue.ID,
		"repo":         issue.Repo,
		"issue_number": issue.Number,
		"title":        issue.Title,
		"url":          issue.URL,
	}

	var result struct {
		Claim *HubClaim `json:"claim"`
	}

	if err := h.doJSON("POST", "/issues/claim", body, &result); err != nil {
		return nil, err
	}

	return result.Claim, nil
}

// UpdateStatus updates the status of a claimed issue.
func (h *HubService) UpdateStatus(issueID, status, prURL string, prNumber int) error {
	body := map[string]interface{}{
		"client_id": h.GetClientID(),
		"status":    status,
	}
	if prURL != "" {
		body["pr_url"] = prURL
		body["pr_number"] = prNumber
	}

	encodedID := url.PathEscape(issueID)
	return h.doJSON("PATCH", "/issues/"+encodedID+"/status", body, nil)
}

// ReleaseClaim releases a claim on an issue.
func (h *HubService) ReleaseClaim(issueID string) error {
	body := map[string]string{
		"client_id": h.GetClientID(),
	}

	encodedID := url.PathEscape(issueID)
	return h.doJSON("DELETE", "/issues/"+encodedID+"/claim", body, nil)
}

// SyncStats uploads local stats to the hub portal.
func (h *HubService) SyncStats(stats *Stats) error {
	if stats == nil {
		return fmt.Errorf("stats is nil")
	}

	repoNames := make([]string, 0, len(stats.ReposContributed))
	for name := range stats.ReposContributed {
		repoNames = append(repoNames, name)
	}

	body := map[string]interface{}{
		"client_id": h.GetClientID(),
		"stats": map[string]interface{}{
			"issues_attempted":   stats.IssuesAttempted,
			"issues_completed":   stats.IssuesCompleted,
			"issues_skipped":     stats.IssuesSkipped,
			"prs_submitted":      stats.PRsSubmitted,
			"prs_merged":         stats.PRsMerged,
			"prs_rejected":       stats.PRsRejected,
			"current_streak":     stats.CurrentStreak,
			"longest_streak":     stats.LongestStreak,
			"total_time_minutes": int(stats.TotalTimeSpent.Minutes()),
			"repos_contributed":  repoNames,
		},
	}

	return h.doJSON("POST", "/stats/sync", body, nil)
}

Step 4: Run tests

Run: go test ./internal/bugseti/... -run "TestRegister_Good|TestHeartbeat|TestClaimIssue|TestUpdateStatus|TestSyncStats" -count=1 Expected: PASS

Step 5: Commit

git add internal/bugseti/hub.go internal/bugseti/hub_test.go
git commit -m "feat(bugseti): hub write operations (register, heartbeat, claim, update, sync)"

Task 6: Read Operations — IsIssueClaimed, ListClaims, GetLeaderboard, GetGlobalStats

Files:

  • Modify: internal/bugseti/hub.go
  • Modify: internal/bugseti/hub_test.go

Step 1: Write failing tests

Add to hub_test.go:

func TestIsIssueClaimed_Good_Claimed(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"claim": map[string]interface{}{"issue_id": "o/r#1", "status": "claimed"},
		})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	claim, err := h.IsIssueClaimed("o/r#1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if claim == nil {
		t.Fatal("expected claim")
	}
}

func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(404)
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	claim, err := h.IsIssueClaimed("o/r#1")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if claim != nil {
		t.Fatal("expected nil claim for unclaimed issue")
	}
}

func TestGetLeaderboard_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("limit") != "10" {
			t.Fatalf("expected limit=10, got %s", r.URL.Query().Get("limit"))
		}
		w.WriteHeader(200)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"leaderboard":        []map[string]interface{}{{"rank": 1, "client_name": "Alice"}},
			"total_participants": 5,
		})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	entries, total, err := h.GetLeaderboard(10)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(entries) != 1 || total != 5 {
		t.Fatalf("expected 1 entry, 5 total; got %d, %d", len(entries), total)
	}
}

func TestGetGlobalStats_Good(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"global": map[string]interface{}{
				"total_participants": 11,
				"active_claims":      3,
			},
		})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	stats, err := h.GetGlobalStats()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if stats.TotalParticipants != 11 {
		t.Fatalf("expected 11 participants, got %d", stats.TotalParticipants)
	}
}

Step 2: Run to verify failure, then implement

Add to hub.go:

// IsIssueClaimed checks if an issue is claimed on the hub.
// Returns the claim if found, nil if not claimed.
func (h *HubService) IsIssueClaimed(issueID string) (*HubClaim, error) {
	var result struct {
		Claim *HubClaim `json:"claim"`
	}

	encodedID := url.PathEscape(issueID)
	err := h.doJSON("GET", "/issues/"+encodedID, nil, &result)
	if err != nil {
		if _, ok := err.(*NotFoundError); ok {
			return nil, nil // Not claimed
		}
		return nil, err
	}

	return result.Claim, nil
}

// ListClaims returns active claims from the hub, with optional filters.
func (h *HubService) ListClaims(status, repo string) ([]*HubClaim, error) {
	path := "/issues/claimed"
	params := url.Values{}
	if status != "" {
		params.Set("status", status)
	}
	if repo != "" {
		params.Set("repo", repo)
	}
	if len(params) > 0 {
		path += "?" + params.Encode()
	}

	var result struct {
		Claims []*HubClaim `json:"claims"`
	}

	if err := h.doJSON("GET", path, nil, &result); err != nil {
		return nil, err
	}

	return result.Claims, nil
}

// GetLeaderboard returns the leaderboard from the hub portal.
func (h *HubService) GetLeaderboard(limit int) ([]LeaderboardEntry, int, error) {
	if limit <= 0 {
		limit = 20
	}

	path := fmt.Sprintf("/leaderboard?limit=%d", limit)

	var result struct {
		Leaderboard       []LeaderboardEntry `json:"leaderboard"`
		TotalParticipants int                `json:"total_participants"`
	}

	if err := h.doJSON("GET", path, nil, &result); err != nil {
		return nil, 0, err
	}

	return result.Leaderboard, result.TotalParticipants, nil
}

// GetGlobalStats returns aggregate stats from the hub portal.
func (h *HubService) GetGlobalStats() (*GlobalStats, error) {
	var result struct {
		Global *GlobalStats `json:"global"`
	}

	if err := h.doJSON("GET", "/stats", nil, &result); err != nil {
		return nil, err
	}

	return result.Global, nil
}

Step 3: Run tests

Run: go test ./internal/bugseti/... -run "TestIsIssueClaimed|TestGetLeaderboard|TestGetGlobalStats" -count=1 Expected: PASS

Step 4: Commit

git add internal/bugseti/hub.go internal/bugseti/hub_test.go
git commit -m "feat(bugseti): hub read operations (claims, leaderboard, global stats)"

Task 7: Pending Operations Queue

Implement offline-first: queue failed writes, persist to disk, drain on reconnect.

Files:

  • Modify: internal/bugseti/hub.go
  • Modify: internal/bugseti/hub_test.go

Step 1: Write failing tests

Add to hub_test.go:

func TestPendingOps_Good_QueueAndDrain(t *testing.T) {
	callCount := 0
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		callCount++
		if r.URL.Path == "/api/bugseti/register" {
			// First register drains pending ops — the heartbeat will come first
			w.WriteHeader(200)
			json.NewEncoder(w).Encode(map[string]interface{}{"client": nil})
			return
		}
		w.WriteHeader(200)
		json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
	}))
	defer server.Close()

	h := testHubService(t, server.URL)
	h.config.config.HubToken = "ak_test"

	// Manually add a pending op
	h.mu.Lock()
	h.pendingOps = append(h.pendingOps, PendingOp{
		Method:    "POST",
		Path:      "/heartbeat",
		Body:      []byte(`{"client_id":"test"}`),
		CreatedAt: time.Now(),
	})
	h.mu.Unlock()

	// Register should drain the pending heartbeat first
	err := h.Register()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if callCount < 2 {
		t.Fatalf("expected at least 2 calls (drain + register), got %d", callCount)
	}
}

func TestPendingOps_Good_PersistAndLoad(t *testing.T) {
	cfg := testConfigService(t, nil, nil)
	h1 := NewHubService(cfg)

	// Add pending op
	h1.mu.Lock()
	h1.pendingOps = append(h1.pendingOps, PendingOp{
		Method:    "POST",
		Path:      "/heartbeat",
		Body:      []byte(`{"test":true}`),
		CreatedAt: time.Now(),
	})
	h1.mu.Unlock()
	h1.savePendingOps()

	// Create new service — should load persisted ops
	h2 := NewHubService(cfg)
	h2.mu.Lock()
	count := len(h2.pendingOps)
	h2.mu.Unlock()

	if count != 1 {
		t.Fatalf("expected 1 pending op after reload, got %d", count)
	}
}

Step 2: Implement pending ops

Add to hub.go:

// queueOp adds a failed write to the pending queue.
func (h *HubService) queueOp(method, path string, body interface{}) {
	data, _ := json.Marshal(body)
	h.mu.Lock()
	h.pendingOps = append(h.pendingOps, PendingOp{
		Method:    method,
		Path:      path,
		Body:      data,
		CreatedAt: time.Now(),
	})
	h.mu.Unlock()
	h.savePendingOps()
}

// drainPendingOps replays queued operations. Called before write methods.
func (h *HubService) drainPendingOps() {
	h.mu.Lock()
	ops := h.pendingOps
	h.pendingOps = nil
	h.mu.Unlock()

	if len(ops) == 0 {
		return
	}

	log.Printf("Hub: draining %d pending operations", len(ops))
	var failed []PendingOp

	for _, op := range ops {
		resp, err := h.doRequest(op.Method, op.Path, json.RawMessage(op.Body))
		if err != nil {
			failed = append(failed, op)
			continue
		}
		resp.Body.Close()
		if resp.StatusCode >= 500 {
			failed = append(failed, op)
		}
		// 4xx errors are dropped (stale data)
	}

	if len(failed) > 0 {
		h.mu.Lock()
		h.pendingOps = append(failed, h.pendingOps...)
		h.mu.Unlock()
	}

	h.savePendingOps()
}

// savePendingOps persists the pending queue to disk.
func (h *HubService) savePendingOps() {
	dataDir := h.config.GetDataDir()
	if dataDir == "" {
		return
	}

	h.mu.Lock()
	ops := h.pendingOps
	h.mu.Unlock()

	data, err := json.Marshal(ops)
	if err != nil {
		return
	}

	path := filepath.Join(dataDir, "hub_pending.json")
	os.WriteFile(path, data, 0600)
}

// loadPendingOps loads persisted pending operations from disk.
func (h *HubService) loadPendingOps() {
	dataDir := h.config.GetDataDir()
	if dataDir == "" {
		return
	}

	path := filepath.Join(dataDir, "hub_pending.json")
	data, err := os.ReadFile(path)
	if err != nil {
		return
	}

	var ops []PendingOp
	if err := json.Unmarshal(data, &ops); err != nil {
		return
	}

	h.mu.Lock()
	h.pendingOps = ops
	h.mu.Unlock()
}

// PendingCount returns the number of queued operations.
func (h *HubService) PendingCount() int {
	h.mu.Lock()
	defer h.mu.Unlock()
	return len(h.pendingOps)
}

Also add "os" and "path/filepath" to the imports in hub.go.

Step 3: Run tests

Run: go test ./internal/bugseti/... -run TestPendingOps -count=1 Expected: PASS

Step 4: Commit

git add internal/bugseti/hub.go internal/bugseti/hub_test.go
git commit -m "feat(bugseti): hub pending operations queue with disk persistence"

Task 8: Integration — main.go and Wails Registration

Wire HubService into the app lifecycle.

Files:

  • Modify: cmd/bugseti/main.go

Step 1: Create HubService in main.go

After the submitService creation, add:

hubService := bugseti.NewHubService(configService)

Add to the services slice:

application.NewService(hubService),

After log.Println("Starting BugSETI..."), add:

// Attempt hub registration (non-blocking, logs warnings on failure)
if hubURL := configService.GetHubURL(); hubURL != "" {
	if err := hubService.AutoRegister(); err != nil {
		log.Printf("Hub: auto-register skipped: %v", err)
	} else if err := hubService.Register(); err != nil {
		log.Printf("Hub: registration failed: %v", err)
	}
}

Step 2: Build and verify

Run: task bugseti:build Expected: Builds successfully.

Run: go test ./internal/bugseti/... -count=1 Expected: All tests pass.

Step 3: Commit

git add cmd/bugseti/main.go
git commit -m "feat(bugseti): wire HubService into app lifecycle"

Task 9: Laravel Auth/Forge Endpoint

Create the portal-side endpoint that exchanges a forge token for an ak_ API key.

Files:

  • Create: agentic/app/Mod/BugSeti/Controllers/AuthController.php
  • Modify: agentic/app/Mod/BugSeti/Routes/api.php

Step 1: Create AuthController

Create agentic/app/Mod/BugSeti/Controllers/AuthController.php:

<?php

declare(strict_types=1);

namespace Mod\BugSeti\Controllers;

use Core\Agentic\Models\AgentApiKey;
use Core\Agentic\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class AuthController
{
    /**
     * Exchange a Forgejo token for a BugSETI API key.
     *
     * POST /api/bugseti/auth/forge
     * No authentication required (this IS the bootstrap).
     */
    public function forge(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'forge_url' => 'required|url|max:500',
            'forge_token' => 'required|string|max:255',
            'client_id' => 'required|string|max:64',
        ]);

        // Validate the forge token against the Forgejo API
        $response = Http::withToken($validated['forge_token'])
            ->timeout(10)
            ->get(rtrim($validated['forge_url'], '/') . '/api/v1/user');

        if (! $response->ok()) {
            return response()->json([
                'error' => 'Invalid Forgejo token — could not verify identity.',
            ], 401);
        }

        $forgeUser = $response->json();
        $forgeName = $forgeUser['full_name'] ?: $forgeUser['login'] ?? 'Unknown';

        // Find or create workspace for BugSETI clients
        $workspace = Workspace::firstOrCreate(
            ['slug' => 'bugseti-community'],
            ['name' => 'BugSETI Community', 'owner_id' => null]
        );

        // Check if this client already has a key
        $existingKey = AgentApiKey::where('workspace_id', $workspace->id)
            ->where('name', 'like', '%' . $validated['client_id'] . '%')
            ->whereNull('revoked_at')
            ->first();

        if ($existingKey) {
            // Revoke old key and issue new one
            $existingKey->update(['revoked_at' => now()]);
        }

        $apiKey = AgentApiKey::generate(
            workspace: $workspace->id,
            name: "BugSETI — {$forgeName} ({$validated['client_id']})",
            permissions: ['bugseti.read', 'bugseti.write'],
            rateLimit: 100,
            expiresAt: null,
        );

        return response()->json([
            'api_key' => $apiKey->plainTextKey,
            'forge_user' => $forgeName,
        ], 201);
    }
}

Step 2: Add route

In agentic/app/Mod/BugSeti/Routes/api.php, add outside the authenticated groups:

// Unauthenticated bootstrap — exchanges forge token for API key
Route::post('/auth/forge', [AuthController::class, 'forge']);

Add the use statement at top of file:

use Mod\BugSeti\Controllers\AuthController;

Step 3: Test manually

cd /Users/snider/Code/host-uk/agentic
php artisan migrate
curl -X POST http://leth.test/api/bugseti/auth/forge \
  -H "Content-Type: application/json" \
  -d '{"forge_url":"https://forge.lthn.io","forge_token":"500ecb79c79da940205f37580438575dbf7a82be","client_id":"test-client-1"}'

Expected: 201 with {"api_key":"ak_...","forge_user":"..."}.

Step 4: Commit

cd /Users/snider/Code/host-uk/agentic
git add app/Mod/BugSeti/Controllers/AuthController.php app/Mod/BugSeti/Routes/api.php
git commit -m "feat(bugseti): add /auth/forge endpoint for token exchange"

Task 10: Full Integration Test

Build the binary, configure hub URL, and verify end-to-end.

Files: None (verification only)

Step 1: Run all Go tests

cd /Users/snider/Code/host-uk/core
go test ./internal/bugseti/... -count=1 -v

Expected: All tests pass.

Step 2: Build binary

task bugseti:build

Expected: Binary builds at bin/bugseti.

Step 3: Configure hub URL and test launch

# Set hub URL to devnet
cat ~/.config/bugseti/config.json | python3 -c "
import json,sys
c = json.load(sys.stdin)
c['hubUrl'] = 'https://leth.in'
json.dump(c, sys.stdout, indent=2)
" > /tmp/bugseti-config.json && mv /tmp/bugseti-config.json ~/.config/bugseti/config.json

Launch ./bin/bugseti — should start without errors, attempt hub registration.

Step 4: Final commit if needed

git add -A && git commit -m "feat(bugseti): HubService integration complete"

Summary

Task Description Files
1 Config fields config.go
2 HubService types + constructor hub.go, hub_test.go
3 HTTP request helpers hub.go, hub_test.go
4 Auto-register via forge hub.go, hub_test.go
5 Write operations hub.go, hub_test.go
6 Read operations hub.go, hub_test.go
7 Pending ops queue hub.go, hub_test.go
8 main.go integration main.go
9 Laravel auth/forge endpoint AuthController.php, api.php
10 Full integration test (verification)