Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,22 +1 @@
- ALWAYS USE PARALLEL TASKS SUBAGENTS FOR CODE EXPLORATION, INVESTIGATION, DEEP DIVES
- Use all tools available to keep current context window as small as possible
- When reading files, DELEGATE to subagents, if possible
- In plan mode, be bias to delegate to subagents
- Use question tool more frequently
- Use jj instead of git
- ALWAYS FOLLOW TDD, red phase to green phase
- Use ripgrep instead of grep, use fd instead of find

## Usage of question tool

Before any kind of implementation, interview me in detail using the question tool.

Ask about technical implementation, UI/UX, edge cases, concerns, and tradeoffs.
Don't ask obvious questions, dig into the hard parts I might not have considered.

Keep interviewing until we've covered everything.

## Tests

- Test actual behavior, not the implementation
- Only test implementation when there is a technical limit to simulating the behavior
11 changes: 9 additions & 2 deletions app/jobs/selfupdatejob/selfupdatejob.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package selfupdatejob

import (
"context"
"errors"
"fmt"
"path/filepath"
"runtime"
"sync"
"time"

Expand All @@ -27,7 +29,7 @@ type TriggerFunc func(context.Context, func() error)

// UpdateCheckerInterface abstracts the update check client.
type UpdateCheckerInterface interface {
Check(currentVersion string) (*updatecheck.UpdateInfo, error)
Check() (*updatecheck.UpdateInfo, error)
}

// DownloaderInterface abstracts the download and verify functionality.
Expand Down Expand Up @@ -125,8 +127,13 @@ func (j *SelfUpdateJob) Shutdown() {
// runUpdate performs a single update check and apply cycle.
func (j *SelfUpdateJob) runUpdate(ctx context.Context) error {
// Step 1: Check for updates
info, err := j.config.UpdateChecker.Check(j.config.CurrentVersion)
info, err := j.config.UpdateChecker.Check()
if err != nil {
// Log unsupported platform at WARN level and return nil (not an error condition)
if errors.Is(err, updatecheck.ErrUnsupportedPlatform) {
log.Warnf("update check failed: unsupported platform %s/%s", runtime.GOOS, runtime.GOARCH)
return nil
}
return fmt.Errorf("update check failed: %w", err)
}
if !info.UpdateAvailable {
Expand Down
26 changes: 25 additions & 1 deletion app/jobs/selfupdatejob/selfupdatejob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,30 @@ func TestUpdateFlow_SkipsWhenNoUpdate(t *testing.T) {
}
}

func TestUpdateFlow_UnsupportedPlatform_ReturnsNilError(t *testing.T) {
// When the control plane returns 400 (unsupported platform),
// runUpdate should log WARN and return nil (not an error).
checker := &mockUpdateChecker{
err: updatecheck.ErrUnsupportedPlatform,
}
downloader := &mockDownloader{}

job := NewWithConfig(SelfUpdateJobConfig{
UpdateChecker: checker,
Downloader: downloader,
CurrentVersion: "1.0.0",
})

err := job.runUpdate(context.Background())

if err != nil {
t.Errorf("expected nil error for unsupported platform, got %v", err)
}
if downloader.callCount.Load() > 0 {
t.Error("downloader should not be called when platform unsupported")
}
}

func TestUpdateFlow_SkipsWhenPreflightFails(t *testing.T) {
checker := &mockUpdateChecker{
result: &updatecheck.UpdateInfo{
Expand Down Expand Up @@ -1112,7 +1136,7 @@ type mockUpdateChecker struct {
callCount atomic.Int32
}

func (m *mockUpdateChecker) Check(currentVersion string) (*updatecheck.UpdateInfo, error) {
func (m *mockUpdateChecker) Check() (*updatecheck.UpdateInfo, error) {
m.callCount.Add(1)
return m.result, m.err
}
Expand Down
12 changes: 9 additions & 3 deletions app/services/updatecheck/updatecheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import (
"net/http"
)

// ErrUnsupportedPlatform is returned when the control plane returns 400,
// indicating no binary exists for the agent's OS/architecture combination.
var ErrUnsupportedPlatform = errors.New("unsupported platform")

// UpdateInfo represents the response from the update check endpoint.
type UpdateInfo struct {
UpdateAvailable bool `json:"update_available"`
Expand Down Expand Up @@ -43,16 +47,15 @@ func New(client *http.Client, controlPlaneURL, agentID string, signer RequestSig
}

// Check queries the control plane for available updates.
func (c *UpdateChecker) Check(currentVersion string) (*UpdateInfo, error) {
// Agent headers (X-Agent-Version, X-Agent-OS, X-Agent-Arch) are set by the HTTP transport.
func (c *UpdateChecker) Check() (*UpdateInfo, error) {
url := fmt.Sprintf("%s/api/v1/agents/%s/update", c.controlPlaneURL, c.agentID)

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

req.Header.Set("X-Agent-Version", currentVersion)

if c.signer != nil {
if err := c.signer.SignRequest(req); err != nil {
return nil, fmt.Errorf("failed to sign request: %w", err)
Expand All @@ -65,6 +68,9 @@ func (c *UpdateChecker) Check(currentVersion string) (*UpdateInfo, error) {
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusBadRequest {
return nil, fmt.Errorf("update check returned status %d: %w", resp.StatusCode, ErrUnsupportedPlatform)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update check returned status %d", resp.StatusCode)
}
Expand Down
51 changes: 36 additions & 15 deletions app/services/updatecheck/updatecheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package updatecheck

import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
Expand All @@ -23,7 +24,7 @@ func TestCheck_UpdateAvailable(t *testing.T) {
defer server.Close()

checker := newTestChecker(t, server.Client(), server.URL, nil)
info, err := checker.Check("1.0.0")
info, err := checker.Check()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -54,7 +55,7 @@ func TestCheck_NoUpdateAvailable(t *testing.T) {
defer server.Close()

checker := newTestChecker(t, server.Client(), server.URL, nil)
info, err := checker.Check("2.0.0")
info, err := checker.Check()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -65,7 +66,7 @@ func TestCheck_NoUpdateAvailable(t *testing.T) {

func TestCheck_NetworkError(t *testing.T) {
checker := newTestChecker(t, http.DefaultClient, "http://localhost:1", nil)
_, err := checker.Check("1.0.0")
_, err := checker.Check()
if err == nil {
t.Fatal("expected error for bad URL, got nil")
}
Expand All @@ -82,7 +83,7 @@ func TestCheck_SignsRequest(t *testing.T) {

signer := &mockSigner{agentID: "agent-123"}
checker := newTestChecker(t, server.Client(), server.URL, signer)
_, err := checker.Check("1.0.0")
_, err := checker.Check()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -101,23 +102,25 @@ func TestCheck_SignsRequest(t *testing.T) {
}
}

func TestCheck_SendsCurrentVersionAsHeader(t *testing.T) {
var receivedVersion string
func TestCheck_MakesRequest(t *testing.T) {
// Note: X-Agent-Version, X-Agent-OS, X-Agent-Arch headers are now set by the transport.
// Header verification is done in internal/httpclient tests.
var requestMade bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedVersion = r.Header.Get("X-Agent-Version")
requestMade = true
resp := UpdateInfo{UpdateAvailable: false}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()

checker := newTestChecker(t, server.Client(), server.URL, nil)
_, err := checker.Check("1.5.3")
_, err := checker.Check()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if receivedVersion != "1.5.3" {
t.Errorf("expected X-Agent-Version header 1.5.3, got %s", receivedVersion)
if !requestMade {
t.Error("expected request to be made")
}
}

Expand All @@ -131,7 +134,7 @@ func TestCheck_NoQueryParams(t *testing.T) {
defer server.Close()

checker := newTestChecker(t, server.Client(), server.URL, nil)
checker.Check("1.5.3")
checker.Check()

if receivedRawQuery != "" {
t.Errorf("expected no query params, got %s", receivedRawQuery)
Expand All @@ -145,20 +148,38 @@ func TestCheck_HTTPErrorStatus(t *testing.T) {
defer server.Close()

checker := newTestChecker(t, server.Client(), server.URL, nil)
_, err := checker.Check("1.0.0")
_, err := checker.Check()
if err == nil {
t.Fatal("expected error for 500 status, got nil")
}
}

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

checker := newTestChecker(t, server.Client(), server.URL, nil)
_, err := checker.Check()
if err == nil {
t.Fatal("expected error for 400 status, got nil")
}

// Verify we get the specific ErrUnsupportedPlatform error
if !errors.Is(err, ErrUnsupportedPlatform) {
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
}
}

func TestCheck_InvalidJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("not json"))
}))
defer server.Close()

checker := newTestChecker(t, server.Client(), server.URL, nil)
_, err := checker.Check("1.0.0")
_, err := checker.Check()
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
Expand All @@ -174,7 +195,7 @@ func TestCheck_UsesGETMethod(t *testing.T) {
defer server.Close()

checker := newTestChecker(t, server.Client(), server.URL, nil)
checker.Check("1.0.0")
checker.Check()

if receivedMethod != http.MethodGet {
t.Errorf("expected GET method, got %s", receivedMethod)
Expand All @@ -194,7 +215,7 @@ func TestCheck_UsesCorrectPath(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
checker.Check("1.0.0")
checker.Check()

if receivedPath != "/api/v1/agents/agent-123/update" {
t.Errorf("expected path /api/v1/agents/agent-123/update, got %s", receivedPath)
Expand Down
39 changes: 39 additions & 0 deletions internal/httpclient/transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Package httpclient provides HTTP client utilities with agent identification headers.
package httpclient

import (
"net/http"
"runtime"
"time"

"hostlink/version"
)

// AgentTransport wraps an http.RoundTripper and injects agent identification headers.
type AgentTransport struct {
Base http.RoundTripper
}

// RoundTrip implements http.RoundTripper.
func (t *AgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone request to avoid mutating the original
clone := req.Clone(req.Context())

clone.Header.Set("X-Agent-Version", version.Version)
clone.Header.Set("X-Agent-OS", runtime.GOOS)
clone.Header.Set("X-Agent-Arch", runtime.GOARCH)

base := t.Base
if base == nil {
base = http.DefaultTransport
}
return base.RoundTrip(clone)
}

// NewClient returns an *http.Client configured with AgentTransport and the specified timeout.
func NewClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: &AgentTransport{},
Timeout: timeout,
}
}
Loading