diff --git a/hub-server/internal/middleware/auth_test.go b/hub-server/internal/middleware/auth_test.go index 5efbf61ad..7d9d84c92 100644 --- a/hub-server/internal/middleware/auth_test.go +++ b/hub-server/internal/middleware/auth_test.go @@ -109,12 +109,12 @@ func TestAuthMiddlewareInvalidToken(t *testing.T) { } func TestAuthMiddlewareRejectsTokenDanceTokenWithoutExpectedAudience(t *testing.T) { - token := "not-a-valid-local-token" + authHeaderValue := "not-a-valid-local-token" cfg := testConfig() cfg.TokenDanceID.IssuerURL = "https://id.example" cfg.TokenDanceID.ClientID = "" - c, w := ginRequest(http.MethodGet, "/client/users/me", "Bearer "+token) + c, w := ginRequest(http.MethodGet, "/client/users/me", "Bearer "+authHeaderValue) newTestAuthMW(cfg, AuthDependencies{}, nil).Handler()(c) if !c.IsAborted() { diff --git a/hub-server/internal/service/agent.go b/hub-server/internal/service/agent.go index 5903e72c2..daa908183 100644 --- a/hub-server/internal/service/agent.go +++ b/hub-server/internal/service/agent.go @@ -14,6 +14,7 @@ import ( "github.com/agenthub/hub-server/internal/errcode" "github.com/agenthub/hub-server/internal/model" "github.com/agenthub/hub-server/internal/repository" + "github.com/agenthub/hub-server/internal/service/dispatchsvc" "github.com/agenthub/hub-server/internal/ws" ) @@ -54,7 +55,7 @@ type AgentService struct { // Constructed in NewAgentService; tests using struct literals fall back to // a lazy facade via dispatchService(). DeliveryOutbox retries call into // DispatchService through Redispatcher (dispatchPayload stays private). - dispatch *DispatchService + dispatch *dispatchsvc.DispatchService // edgeCfg/jwtSecret are the Hub→Edge dispatch configuration (#1549), // injected by the composition root and forwarded to DispatchService. edgeCfg config.EdgeDispatchConfig @@ -79,7 +80,7 @@ func NewAgentService(db *gorm.DB, bus *bus.Bus, mgr *ws.Manager, cacheClient *ca s.deliveryOutbox, // DeliveryOutbox implements edgeCallbackOutbox via autoAckDeliveriesForTask ) // Dispatch after outbox so RecordDelivery/MarkDeliverySent ports are ready. - s.dispatch = NewDispatchService(db, bus, mgr, s.cacheClient, relay, s.deliveryOutbox, edgeCfg, edgeClient, jwtSecret) + s.dispatch = dispatchsvc.NewDispatchService(db, bus, wsManagerAdapter{manager: mgr}, s.cacheClient, relayServiceAdapter{relay: relay}, s.deliveryOutbox, edgeCfg, edgeClient, jwtSecret) s.deliveryOutbox.SetRedispatcher(dispatchRedispatcher{s.dispatch}) return s } diff --git a/hub-server/internal/service/agent_dispatch_facade.go b/hub-server/internal/service/agent_dispatch_facade.go index a1bd64a5f..8f0377e69 100644 --- a/hub-server/internal/service/agent_dispatch_facade.go +++ b/hub-server/internal/service/agent_dispatch_facade.go @@ -5,6 +5,7 @@ import ( "github.com/agenthub/hub-server/internal/model" "github.com/agenthub/hub-server/internal/service/dispatch" + "github.com/agenthub/hub-server/internal/service/dispatchsvc" ) // ── AgentService facade (wiring/handler stability) ─────────────────────────── @@ -15,11 +16,11 @@ import ( // dispatchService returns the composed DispatchService, lazily constructing one // from AgentService deps when tests use struct literals without NewAgentService. -func (s *AgentService) dispatchService() *DispatchService { +func (s *AgentService) dispatchService() *dispatchsvc.DispatchService { if dispatch.ComposedDispatchReady(s.dispatch != nil) { return s.dispatch } - return NewDispatchService(s.db, s.bus, s.mgr, s.cacheClient, s.relay, s.deliveryOutboxService(), s.edgeCfg, s.edgeClient, s.jwtSecret) + return dispatchsvc.NewDispatchService(s.db, s.bus, wsManagerAdapter{manager: s.mgr}, s.cacheClient, relayServiceAdapter{relay: s.relay}, s.deliveryOutboxService(), s.edgeCfg, s.edgeClient, s.jwtSecret) } // TriggerAgentTask creates a pending task for an agent and dispatches it to the inviter's edge. diff --git a/hub-server/internal/service/agent_logic_test.go b/hub-server/internal/service/agent_logic_test.go index 0b0ca2480..bb00de342 100644 --- a/hub-server/internal/service/agent_logic_test.go +++ b/hub-server/internal/service/agent_logic_test.go @@ -1,20 +1,15 @@ package service import ( - "context" "encoding/json" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/agenthub/hub-server/internal/bus" - "github.com/agenthub/hub-server/internal/config" "github.com/agenthub/hub-server/internal/errcode" "github.com/agenthub/hub-server/internal/model" "github.com/agenthub/hub-server/internal/service/agentevent" "github.com/agenthub/hub-server/internal/service/dispatch" - "github.com/agenthub/hub-server/internal/ws" ) // --- normalizeRuntimeAgentType --- @@ -265,138 +260,6 @@ func TestValidateAgentCallbackEdgeRunID(t *testing.T) { }) } -// --- DispatchService residual ports (#617) --- - -type recordingDispatchBus struct { - events []bus.Event -} - -func (b *recordingDispatchBus) Publish(ctx context.Context, event bus.Event) error { - b.events = append(b.events, event) - return nil -} - -type recordingDispatchCache struct { - routes map[string]string - pushed []string -} - -func (c *recordingDispatchCache) GetRoute(ctx context.Context, userID, deviceType string) (string, error) { - if c.routes == nil { - return "", nil - } - return c.routes[userID+":"+deviceType], nil -} - -func (c *recordingDispatchCache) GetRouteForDevice(ctx context.Context, userID, deviceType, deviceID string) (string, error) { - if c.routes == nil { - return "", nil - } - return c.routes[userID+":"+deviceType+":"+deviceID], nil -} - -func (c *recordingDispatchCache) PushPendingTask(ctx context.Context, userID, taskJSON string) error { - c.pushed = append(c.pushed, userID+":"+taskJSON) - return nil -} - -func (c *recordingDispatchCache) PushPendingTargetTask(ctx context.Context, userID, targetID, deviceID, taskJSON string) error { - c.pushed = append(c.pushed, userID+":"+targetID+":"+deviceID+":"+taskJSON) - return nil -} - -type recordingDispatchWS struct { - conn *ws.Conn - pushed int -} - -func (m *recordingDispatchWS) FindByConnID(connID string) *ws.Conn { - if m.conn == nil || m.conn.ID != connID { - return nil - } - return m.conn -} - -func (m *recordingDispatchWS) PushToConn(connID string, frame ws.Frame) ws.DeliveryResult { - m.pushed++ - return ws.DeliveryResult{Queued: true, Status: ws.DeliveryStatusQueued} -} - -type recordingDispatchOutbox struct { - recorded int - marked int - dead int - lastError string -} - -func (o *recordingDispatchOutbox) RecordDelivery(ctx context.Context, taskID, payload, edgeDeviceID string) (string, error) { - o.recorded++ - return "deliv-1", nil -} - -func (o *recordingDispatchOutbox) MarkDeliverySent(ctx context.Context, deliveryID string) error { - o.marked++ - return nil -} - -func (o *recordingDispatchOutbox) MoveDeliveryToDeadLetter(ctx context.Context, deliveryID string, lastError string) error { - o.dead++ - o.lastError = lastError - return nil -} - -func TestDispatchService_NilBusPublishIsNoop(t *testing.T) { - svc := &DispatchService{} - // Must not panic when b port is unset (partial construction). - svc.publish(context.Background(), bus.Event{Type: "agent.cancel", Payload: "x"}) -} - -func TestDispatchService_NilOutboxWrappers(t *testing.T) { - svc := &DispatchService{} - _, err := svc.recordDelivery(context.Background(), "t1", "{}", "") - require.Error(t, err) - require.Contains(t, err.Error(), "dispatch outbox unavailable") - require.Error(t, svc.markDeliverySent(context.Background(), "d1")) - // dead-letter is a no-op when outbox is unset - svc.moveDeliveryToDeadLetter(context.Background(), "d1", "boom") -} - -func TestDispatchService_SetPortsComposition(t *testing.T) { - b := &recordingDispatchBus{} - cachePort := &recordingDispatchCache{routes: map[string]string{"u1:desktop": "conn-1"}} - wsPort := &recordingDispatchWS{conn: &ws.Conn{ID: "conn-1", UserID: "u1", DeviceType: "desktop", DeviceID: "dev-1"}} - outbox := &recordingDispatchOutbox{} - - svc := NewDispatchService(nil, nil, nil, nil, nil, nil, config.EdgeDispatchConfig{}, nil, "") - require.NotNil(t, svc) - - svc.SetBus(b) - svc.SetCache(cachePort) - svc.SetManager(wsPort) - svc.SetOutbox(outbox) - svc.SetRelay(nil) - - svc.publish(context.Background(), bus.Event{Type: "agent.regenerate", Payload: map[string]string{"k": "v"}}) - require.Len(t, b.events, 1) - assert.Equal(t, "agent.regenerate", b.events[0].Type) - - id, err := svc.recordDelivery(context.Background(), "task-1", `{"task_id":"task-1"}`, "dev-1") - require.NoError(t, err) - assert.Equal(t, "deliv-1", id) - require.NoError(t, svc.markDeliverySent(context.Background(), id)) - svc.moveDeliveryToDeadLetter(context.Background(), id, "hard-fail") - assert.Equal(t, 1, outbox.recorded) - assert.Equal(t, 1, outbox.marked) - assert.Equal(t, 1, outbox.dead) - assert.Equal(t, "hard-fail", outbox.lastError) - - got := svc.cachePort() - route, err := got.GetRoute(context.Background(), "u1", "desktop") - require.NoError(t, err) - assert.Equal(t, "conn-1", route) - assert.Same(t, wsPort.conn, svc.mgr.FindByConnID("conn-1")) -} - func TestIsLoopback(t *testing.T) { assert.True(t, dispatch.IsLoopback("http://127.0.0.1:3210")) assert.True(t, dispatch.IsLoopback("http://localhost:3210")) diff --git a/hub-server/internal/service/agent_test.go b/hub-server/internal/service/agent_test.go index 182c0236f..cf3ccd3a8 100644 --- a/hub-server/internal/service/agent_test.go +++ b/hub-server/internal/service/agent_test.go @@ -185,12 +185,12 @@ func TestDispatchTaskIncludesPrompt(t *testing.T) { // Point Edge dispatch at a dead port so it always falls back to Redis. t.Setenv("AGENTHUB_EDGE_URL", "http://127.0.0.1:1") - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run the real runtime", `{"model":"claude-sonnet-4-6"}`, "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run the real runtime", `{"model":"claude-sonnet-4-6"}`, "", nil) snapshot := cache.snapshot() require.Equal(t, "user-1", snapshot.pushedUser) require.Len(t, snapshot.pushed, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushed[0]), &payload)) require.Equal(t, "Run the real runtime", payload.Prompt) require.Equal(t, "claude-code", payload.AgentType) @@ -219,12 +219,12 @@ func TestDispatchTaskIncludesTargetID(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run the selected target", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run the selected target", "", "", nil) snapshot := cache.snapshot() require.Equal(t, "user-1", snapshot.pushedUser) require.Len(t, snapshot.pushedTarget, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushedTarget[0]), &payload)) require.Equal(t, "target-1", payload.TargetID) require.Equal(t, "dev-1", payload.EdgeDeviceID) @@ -307,11 +307,11 @@ func TestDispatchTaskIncludesTeamRunContext(t *testing.T) { DisplayName: "Supervisor", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Route this team run", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Route this team run", "", "", nil) snapshot := cache.snapshot() require.Len(t, snapshot.pushed, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushed[0]), &payload)) require.Equal(t, "team-1", payload.TeamID) require.Equal(t, "run-team-1", payload.TeamRunID) @@ -383,11 +383,11 @@ func TestDispatchTaskIncludesOutputSchema(t *testing.T) { t.Setenv("AGENTHUB_EDGE_URL", "http://127.0.0.1:1") // Dispatch WITH the CustomAgent (non-TeamRun scenario). - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Give me a summary", "", "", customAgent) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Give me a summary", "", "", customAgent) snapshot := cache.snapshot() require.Len(t, snapshot.pushed, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushed[0]), &payload)) // Verify OutputSchema is present in the dispatch payload. @@ -503,11 +503,11 @@ func TestDispatchTaskIncludesOutputSchemaWithTeamRunContext(t *testing.T) { t.Setenv("AGENTHUB_EDGE_URL", "http://127.0.0.1:1") // Dispatch WITH the CustomAgent (TeamRun scenario — backward compatibility). - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Route this team run", "", "", customAgent) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Route this team run", "", "", customAgent) snapshot := cache.snapshot() require.Len(t, snapshot.pushed, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushed[0]), &payload)) // TeamRun context is preserved (backward compatibility). @@ -546,11 +546,11 @@ func TestDispatchTaskWithoutCustomAgentOmitsOutputSchema(t *testing.T) { } t.Setenv("AGENTHUB_EDGE_URL", "http://127.0.0.1:1") - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run without custom agent", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run without custom agent", "", "", nil) snapshot := cache.snapshot() require.Len(t, snapshot.pushed, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushed[0]), &payload)) // OutputSchema MUST be nil when no CustomAgent is associated. @@ -581,7 +581,7 @@ func TestDispatchTaskWithTargetIDButNoDeviceFailsClosed(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run invalid target", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run invalid target", "", "", nil) select { case <-connA.Send: @@ -615,7 +615,7 @@ func TestDispatchTaskDoesNotPushWhenDispatchedStateMissing(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run missing task", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run missing task", "", "", nil) select { case <-conn.Send: @@ -649,7 +649,7 @@ func TestDispatchTaskDoesNotPushTerminalTask(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run cancelled task", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run cancelled task", "", "", nil) select { case <-conn.Send: @@ -689,12 +689,12 @@ func TestDispatchTaskPreservesNonTargetTaskWhenDeliveryBufferFull(t *testing.T) DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run on online desktop", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run on online desktop", "", "", nil) snapshot := cache.snapshot() require.Equal(t, "user-1", snapshot.pushedUser) require.Len(t, snapshot.pushed, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushed[0]), &payload)) require.Equal(t, task.ID, payload.TaskID) } @@ -734,13 +734,13 @@ func TestDispatchTaskRoutesTargetBoundTaskToBoundDevice(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run on B", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run on B", "", "", nil) select { case data := <-connB.Send: var frame struct { - Type string `json:"type"` - Payload dispatchPayload `json:"payload"` + Type string `json:"type"` + Payload dispatch.Payload `json:"payload"` } require.NoError(t, json.Unmarshal(data, &frame)) require.Equal(t, ws.TypeAgentDispatch, frame.Type) @@ -796,7 +796,7 @@ func TestDispatchTaskQueuesTargetBoundTaskWhenDeliveryBufferFull(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run on B", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run on B", "", "", nil) snapshot := cache.snapshot() require.Len(t, snapshot.pushedTarget, 1) @@ -832,7 +832,7 @@ func TestDispatchTaskDoesNotPushTargetWhenDispatchedStateMissing(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run missing target task", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run missing target task", "", "", nil) select { case <-connB.Send: @@ -872,7 +872,7 @@ func TestDispatchTaskDoesNotPushTerminalTargetTask(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run cancelled target", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run cancelled target", "", "", nil) select { case <-connB.Send: @@ -908,7 +908,7 @@ func TestDispatchTaskQueuesTargetBoundTaskWhenBoundDeviceOffline(t *testing.T) { DisplayName: "Codex", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "Run on offline B", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "Run on offline B", "", "", nil) select { case <-connA.Send: @@ -1642,7 +1642,7 @@ func TestTriggerAgentTaskStoresAndDispatchesOwnedTarget(t *testing.T) { return len(cache.snapshot().pushedTarget) == 1 }, time.Second, 10*time.Millisecond) snapshot := cache.snapshot() - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushedTarget[0]), &payload)) require.Equal(t, "target-local", payload.TargetID) require.Equal(t, "dev-target", payload.EdgeDeviceID) diff --git a/hub-server/internal/service/cache_fallback.go b/hub-server/internal/service/cache_fallback.go index f7753c949..e668b9b6f 100644 --- a/hub-server/internal/service/cache_fallback.go +++ b/hub-server/internal/service/cache_fallback.go @@ -23,16 +23,6 @@ func resolveAgentCache(c agentCache) agentCache { return c } -// resolveDispatchCache validates the dispatch route/offline-queue cache port and -// falls back to cache.NoOpCache when nil (partial unit tests). Production wiring -// injects *cache.Client via NewDispatchService / NewAgentService. -func resolveDispatchCache(c dispatchCache) dispatchCache { - if isNilCache(c) { - return cache.NoOpCache{} - } - return c -} - func isNilCache(c any) bool { if c == nil { return true diff --git a/hub-server/internal/service/cache_fallback_test.go b/hub-server/internal/service/cache_fallback_test.go index 2e219e838..0a90b73b6 100644 --- a/hub-server/internal/service/cache_fallback_test.go +++ b/hub-server/internal/service/cache_fallback_test.go @@ -24,10 +24,4 @@ func TestResolveCacheUsesNoopForTypedNilClient(t *testing.T) { require.ErrorIs(t, agent.PushPendingTask(ctx, "user-1", "{}"), cache.ErrCacheUnavailable) _, err = agent.AllocateSeq(ctx, "session-1") require.ErrorIs(t, err, cache.ErrCacheUnavailable) - - dispatch := resolveDispatchCache(typedNil) - require.IsType(t, cache.NoOpCache{}, dispatch) - _, err = dispatch.GetRoute(ctx, "user-1", "desktop") - require.ErrorIs(t, err, cache.ErrCacheUnavailable) - require.ErrorIs(t, dispatch.PushPendingTask(ctx, "user-1", "{}"), cache.ErrCacheUnavailable) } diff --git a/hub-server/internal/service/delivery_outbox_model.go b/hub-server/internal/service/delivery_outbox_model.go index f7de9b896..4244e89a2 100644 --- a/hub-server/internal/service/delivery_outbox_model.go +++ b/hub-server/internal/service/delivery_outbox_model.go @@ -80,18 +80,6 @@ func (r deliveryOutboxRecord) toEntry() DeliveryOutboxEntry { return DeliveryOutboxEntry(r) } -// redispatchTarget carries only the opaque fields Redispatcher / DispatchService -// need to re-send a stored payload. It is not a GORM model and must not grow -// journal columns — that keeps redispatch free of deliveryOutboxRecord. -// Same-package unexported type so agent_dispatch.go can use it without ownership -// transfer (#801 keeps redispatchTarget co-located; does not edit dispatch). -type redispatchTarget struct { - TaskID string - DeliveryID string - Payload string - EdgeDeviceID string -} - // ── DeliveryOutbox private repository helpers ────────────────────────────── // outboxModel is the GORM model handle for delivery_outbox mutations. diff --git a/hub-server/internal/service/delivery_outbox_retry.go b/hub-server/internal/service/delivery_outbox_retry.go index 65eac4410..d31487ad7 100644 --- a/hub-server/internal/service/delivery_outbox_retry.go +++ b/hub-server/internal/service/delivery_outbox_retry.go @@ -7,6 +7,7 @@ import ( "time" "github.com/agenthub/hub-server/internal/metrics" + "github.com/agenthub/hub-server/internal/service/dispatchsvc" ) // ── Retry loop orchestration ─────────────────────────────────────────────── @@ -93,11 +94,12 @@ func (o *DeliveryOutbox) retryDeliveries(ctx context.Context) { // ── Redispatcher adapter (implementation on DispatchService) ──────────────── -// dispatchRedispatcher adapts *DispatchService to the Redispatcher port without -// exporting dispatchPayload or deliveryOutboxRecord to DeliveryOutbox. -// Redispatch residual ownership moved in #573. +// dispatchRedispatcher adapts *dispatchsvc.DispatchService to the Redispatcher +// port without exporting dispatch payload types or the outbox row to +// DeliveryOutbox. Redispatch residual ownership moved in #573; the dispatch +// implementation moved to service/dispatchsvc. type dispatchRedispatcher struct { - d *DispatchService + d *dispatchsvc.DispatchService } func (a dispatchRedispatcher) RedispatchDelivery(ctx context.Context, taskID, deliveryID, payloadJSON, edgeDeviceID string) error { @@ -107,12 +109,7 @@ func (a dispatchRedispatcher) RedispatchDelivery(ctx context.Context, taskID, de // Propagate soft-fail errors so retryDeliveries does not MarkDeliverySent // after a failed offline-queue / route attempt (#999). Dead-letter paths // return nil (already terminal; MarkDeliverySent is a no-op). - return a.d.redispatchDelivery(ctx, redispatchTarget{ - TaskID: taskID, - DeliveryID: deliveryID, - Payload: payloadJSON, - EdgeDeviceID: edgeDeviceID, - }) + return a.d.RedispatchDelivery(ctx, taskID, deliveryID, payloadJSON, edgeDeviceID) } // lazyDispatchRedispatcher resolves DispatchService only when a retry fires. diff --git a/hub-server/internal/service/delivery_outbox_test.go b/hub-server/internal/service/delivery_outbox_test.go index ae537cccc..a64634e2b 100644 --- a/hub-server/internal/service/delivery_outbox_test.go +++ b/hub-server/internal/service/delivery_outbox_test.go @@ -19,6 +19,7 @@ import ( "github.com/agenthub/hub-server/internal/model" "github.com/agenthub/hub-server/internal/service/deliveryoutbox" "github.com/agenthub/hub-server/internal/service/dispatch" + "github.com/agenthub/hub-server/internal/service/dispatchsvc" ) // newOutboxDB creates an in-memory SQLite database with the delivery_outbox @@ -694,13 +695,13 @@ func TestDispatchIncludesDeliveryID(t *testing.T) { DisplayName: "Claude", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "test prompt", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "test prompt", "", "", nil) snapshot := cache.snapshot() require.Equal(t, "user-1", snapshot.pushedUser) require.Len(t, snapshot.pushed, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snapshot.pushed[0]), &payload)) require.NotEmpty(t, payload.DeliveryID, "dispatch payload should include delivery_id") @@ -960,7 +961,7 @@ func TestOutbox_RetryLoopAdapterSoftFailDoesNotMarkSent(t *testing.T) { seedRetryableTaskAndDelivery(t, db, "task-soft", "del-soft", payload, DeliveryStatusRetrying, 1, now) cache := &failPushCache{pushErr: errors.New("redis unavailable")} - ds := NewDispatchService(db, nil, nil, cache, nil, nil, config.EdgeDispatchConfig{}, nil, "") + ds := dispatchsvc.NewDispatchService(db, nil, nil, cache, nil, nil, config.EdgeDispatchConfig{}, nil, "") outbox := NewDeliveryOutbox(db, dispatchRedispatcher{d: ds}) outbox.retryDeliveries(ctx) @@ -984,7 +985,7 @@ func TestOutbox_RetryLoopAdapterSuccessMarksSentAndBumpsUpdatedAt(t *testing.T) seedRetryableTaskAndDelivery(t, db, "task-ok", "del-ok", payload, DeliveryStatusSent, 0, old) cache := &mockAgentCache{} - ds := NewDispatchService(db, nil, nil, cache, nil, nil, config.EdgeDispatchConfig{}, nil, "") + ds := dispatchsvc.NewDispatchService(db, nil, nil, cache, nil, nil, config.EdgeDispatchConfig{}, nil, "") outbox := NewDeliveryOutbox(db, dispatchRedispatcher{d: ds}) before := time.Now().UTC() @@ -1309,7 +1310,7 @@ func TestOutbox_RunningTaskNotRedispatched(t *testing.T) { cache := &mockAgentCache{} outbox := NewDeliveryOutbox(db, nil) - ds := NewDispatchService(db, nil, nil, cache, nil, outbox, config.EdgeDispatchConfig{}, nil, "") + ds := dispatchsvc.NewDispatchService(db, nil, nil, cache, nil, outbox, config.EdgeDispatchConfig{}, nil, "") outbox.SetRedispatcher(dispatchRedispatcher{d: ds}) outbox.retryDeliveries(ctx) @@ -1356,12 +1357,12 @@ func TestOutbox_OfflineDispatchDoesNotMarkSent(t *testing.T) { ID: "agent-off", AgentType: "claude-code", SessionID: "sess-off", InviterUserID: "user-1", DisplayName: "Claude", } - svc.dispatchService().dispatchTask(context.Background(), task, agent, "test prompt", "", "", nil) + svc.dispatchService().DispatchTask(context.Background(), task, agent, "test prompt", "", "", nil) snap := cache.snapshot() require.Len(t, snap.pushed, 1) - var payload dispatchPayload + var payload dispatch.Payload require.NoError(t, json.Unmarshal([]byte(snap.pushed[0]), &payload)) require.NotEmpty(t, payload.DeliveryID) diff --git a/hub-server/internal/service/dispatch_adapters.go b/hub-server/internal/service/dispatch_adapters.go new file mode 100644 index 000000000..a37ffded4 --- /dev/null +++ b/hub-server/internal/service/dispatch_adapters.go @@ -0,0 +1,54 @@ +package service + +import ( + "context" + "encoding/json" + + "github.com/agenthub/hub-server/internal/service/dispatchsvc" + "github.com/agenthub/hub-server/internal/ws" +) + +// ── dispatchsvc transport adapters ───────────────────────────────────────── +// +// The dispatch package defines wire-free ports (ManagerPort / RelayPort); the +// service layer adapts the concrete ws.Manager and RelayService onto them so +// the dispatch flow never imports transport or sibling-service types. + +// wsManagerAdapter adapts *ws.Manager onto dispatchsvc.ManagerPort. +type wsManagerAdapter struct { + manager *ws.Manager +} + +func (a wsManagerAdapter) FindByConnID(connID string) *dispatchsvc.ConnPort { + conn := a.manager.FindByConnID(connID) + if conn == nil { + return nil + } + return &dispatchsvc.ConnPort{ + ID: conn.ID, + UserID: conn.UserID, + DeviceType: conn.DeviceType, + DeviceID: conn.DeviceID, + } +} + +func (a wsManagerAdapter) PushToConn(connID string, frame dispatchsvc.FramePort) dispatchsvc.DeliveryResultPort { + result := a.manager.PushToConn(connID, ws.Frame{Type: frame.Type, Payload: frame.Payload}) + return dispatchsvc.DeliveryResultPort{ + Queued: result.Queued, + Status: string(result.Status), + Err: result.Err, + } +} + +// relayServiceAdapter adapts relayDispatcher (RelayService subset) onto +// dispatchsvc.RelayPort. The dispatch flow discards the created command's +// metadata, so the adapter drops it. +type relayServiceAdapter struct { + relay relayDispatcher +} + +func (a relayServiceAdapter) CreateCommand(ctx context.Context, targetEdgeID, commandType string, payload json.RawMessage, createdBy string) error { + _, err := a.relay.CreateCommand(ctx, targetEdgeID, commandType, payload, createdBy) + return err +} diff --git a/hub-server/internal/service/agent_dispatch.go b/hub-server/internal/service/dispatchsvc/agent_dispatch.go similarity index 92% rename from hub-server/internal/service/agent_dispatch.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch.go index 710ae9cc8..874589b42 100644 --- a/hub-server/internal/service/agent_dispatch.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch.go @@ -1,4 +1,4 @@ -package service +package dispatchsvc import ( "context" @@ -16,7 +16,6 @@ import ( "github.com/agenthub/hub-server/internal/repository" "github.com/agenthub/hub-server/internal/safego" "github.com/agenthub/hub-server/internal/service/dispatch" - "github.com/agenthub/hub-server/internal/ws" ) // ── DTO aliases, ports, and wiring surface moved to agent_dispatch_ports.go (#1068). @@ -42,7 +41,7 @@ func (s *DispatchService) launchDispatchTask(ctx context.Context, task *model.Pe // only the production composition root wires a real semaphore. if s.dispatchSem == nil { safego.SafeGo("dispatch.launch", func() { - s.dispatchTask(ctx, task, ai, prompt, modelParams, targetType, customAgent) + s.DispatchTask(ctx, task, ai, prompt, modelParams, targetType, customAgent) }) return } @@ -58,7 +57,7 @@ func (s *DispatchService) launchDispatchTask(ctx context.Context, task *model.Pe } safego.SafeGo("dispatch.launch", func() { defer func() { <-s.dispatchSem }() - s.dispatchTask(ctx, task, ai, prompt, modelParams, targetType, customAgent) + s.DispatchTask(ctx, task, ai, prompt, modelParams, targetType, customAgent) }) } @@ -167,7 +166,11 @@ func (s *DispatchService) validateDispatchTarget(ctx context.Context, userID, ta return dispatch.NewTargetSnapshot(target.ID, target.TargetType, deviceID), nil } -func (s *DispatchService) dispatchTask(ctx context.Context, task *model.PendingAgentTask, ai *model.AgentInstance, prompt, modelParams, targetType string, customAgent *model.CustomAgent) { +// DispatchTask runs the full dispatch orchestration for an already-persisted +// queued task: payload assembly, outbox record, route classification, and +// delivery (HTTP / WS / offline / relay). Exported so the AgentService +// facade and the service-level integration tests can drive it directly. +func (s *DispatchService) DispatchTask(ctx context.Context, task *model.PendingAgentTask, ai *model.AgentInstance, prompt, modelParams, targetType string, customAgent *model.CustomAgent) { // Pure payload assembly (#902/#1033/#1056); history loaders stay orchestration-side. dp := dispatch.AssembleDispatchPayload(dispatch.AssembleInputCore( task.ID, ai.ID, ai.AgentType, task.TargetID, task.EdgeDeviceID, ai.SessionID, @@ -238,7 +241,7 @@ func (s *DispatchService) dispatchTask(ctx context.Context, task *model.PendingA } return } - frame := ws.NewFrame(ws.TypeAgentDispatch, json.RawMessage(payload)) + frame := FramePort{Type: frameTypeAgentDispatch, Payload: json.RawMessage(payload)} if err := repository.UpdatePendingTaskDispatched(s.db, task.ID, conn.DeviceID); !dispatch.RepoUpdateSucceeded(err) { slog.Error(dispatch.DispatchLogMarkAgentDispatched, "task_id", task.ID, "user_id", ai.InviterUserID, "device_id", conn.DeviceID, "error", err) return @@ -287,7 +290,7 @@ func (s *DispatchService) dispatchTask(ctx context.Context, task *model.PendingA case dispatch.RouteHubRelay: // hub_relay uses the relay service; failures fall back to offline target queue. - _, err := s.relay.CreateCommand(ctx, ai.InviterUserID, dispatch.AgentDispatchRelayCommand, json.RawMessage(payload), ai.InviterUserID) + err := s.relay.CreateCommand(ctx, ai.InviterUserID, dispatch.AgentDispatchRelayCommand, json.RawMessage(payload), ai.InviterUserID) if !dispatch.HubRelayCreateSucceeded(err) { slog.Error(dispatch.DispatchLogRelayCreateFailed, "task_id", task.ID, "user_id", ai.InviterUserID, "error", err) if pushErr := cacheClient.PushPendingTargetTask(ctx, ai.InviterUserID, task.TargetID, task.EdgeDeviceID, string(payload)); !dispatch.OfflineQueuePushSucceeded(pushErr) { @@ -346,6 +349,28 @@ func (s *DispatchService) issueRunStartCapability(dp *dispatchPayload) string { // ── Redispatch residual (moved from AgentService in #573) ──────────────────── +// redispatchTarget carries only the opaque fields the redispatch path needs to +// re-send a stored payload. It is not a GORM model and must not grow journal +// columns — that keeps redispatch free of the outbox row type. +type redispatchTarget struct { + TaskID string + DeliveryID string + Payload string + EdgeDeviceID string +} + +// RedispatchDelivery re-dispatches a stored delivery by payload fields. This +// is the exported seam the service-layer outbox retry loop calls; it builds +// the internal redispatchTarget so the outbox never touches dispatch internals. +func (s *DispatchService) RedispatchDelivery(ctx context.Context, taskID, deliveryID, payloadJSON, edgeDeviceID string) error { + return s.redispatchDelivery(ctx, redispatchTarget{ + TaskID: taskID, + DeliveryID: deliveryID, + Payload: payloadJSON, + EdgeDeviceID: edgeDeviceID, + }) +} + // redispatchDelivery re-dispatches a delivery by parsing the stored payload and // routing it to the target Edge device. Pure JSON prep is in dispatch; dead-letter // + routing stay here. Accepts redispatchTarget only — never the private GORM row. @@ -432,7 +457,7 @@ func (s *DispatchService) retryDispatchToTarget(ctx context.Context, task *pendi switch dispatch.ClassifyRedeliveryRoute(preferDevice, connID, dispatch.ManagerPortAvailable(s.mgr != nil), routeErr, facts.ConnFound, facts.ConnUserMatch) { case dispatch.RouteTargetBound: - result := s.mgr.PushToConn(connID, ws.NewFrame(ws.TypeAgentDispatch, json.RawMessage(newPayload))) + result := s.mgr.PushToConn(connID, FramePort{Type: frameTypeAgentDispatch, Payload: json.RawMessage(newPayload)}) if dispatch.RedeliveryWSPushSucceeded(result.Queued) { slog.Info(dispatch.RedispatchLogWSSucceeded, "delivery_id", rec.DeliveryID, "task_id", rec.TaskID, "device_id", task.EdgeDeviceID) @@ -442,7 +467,7 @@ func (s *DispatchService) retryDispatchToTarget(ctx context.Context, task *pendi "delivery_id", rec.DeliveryID, "task_id", rec.TaskID, "delivery_status", result.Status, "error", result.Err) case dispatch.RouteInviterDesktop: - result := s.mgr.PushToConn(connID, ws.NewFrame(ws.TypeAgentDispatch, json.RawMessage(newPayload))) + result := s.mgr.PushToConn(connID, FramePort{Type: frameTypeAgentDispatch, Payload: json.RawMessage(newPayload)}) if dispatch.RedeliveryWSPushSucceeded(result.Queued) { slog.Info(dispatch.RedispatchLogWSFallbackSucceeded, "delivery_id", rec.DeliveryID, "task_id", rec.TaskID) diff --git a/hub-server/internal/service/agent_dispatch_breaker.go b/hub-server/internal/service/dispatchsvc/agent_dispatch_breaker.go similarity index 99% rename from hub-server/internal/service/agent_dispatch_breaker.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch_breaker.go index 79ee7e12a..7696e0059 100644 --- a/hub-server/internal/service/agent_dispatch_breaker.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch_breaker.go @@ -1,4 +1,4 @@ -package service +package dispatchsvc import ( "sync" diff --git a/hub-server/internal/service/agent_dispatch_breaker_test.go b/hub-server/internal/service/dispatchsvc/agent_dispatch_breaker_test.go similarity index 99% rename from hub-server/internal/service/agent_dispatch_breaker_test.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch_breaker_test.go index 37b269ff1..d95dcaac4 100644 --- a/hub-server/internal/service/agent_dispatch_breaker_test.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch_breaker_test.go @@ -1,4 +1,4 @@ -package service +package dispatchsvc import ( "testing" diff --git a/hub-server/internal/service/agent_dispatch_context.go b/hub-server/internal/service/dispatchsvc/agent_dispatch_context.go similarity index 99% rename from hub-server/internal/service/agent_dispatch_context.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch_context.go index acc4f9683..773152d55 100644 --- a/hub-server/internal/service/agent_dispatch_context.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch_context.go @@ -1,4 +1,4 @@ -package service +package dispatchsvc import ( "github.com/agenthub/hub-server/internal/model" diff --git a/hub-server/internal/service/agent_dispatch_edge_http.go b/hub-server/internal/service/dispatchsvc/agent_dispatch_edge_http.go similarity index 99% rename from hub-server/internal/service/agent_dispatch_edge_http.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch_edge_http.go index 47d9959a1..334e49f08 100644 --- a/hub-server/internal/service/agent_dispatch_edge_http.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch_edge_http.go @@ -1,4 +1,4 @@ -package service +package dispatchsvc import ( "bytes" diff --git a/hub-server/internal/service/agent_dispatch_edge_http_test.go b/hub-server/internal/service/dispatchsvc/agent_dispatch_edge_http_test.go similarity index 99% rename from hub-server/internal/service/agent_dispatch_edge_http_test.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch_edge_http_test.go index 4d0c860ef..86d91046d 100644 --- a/hub-server/internal/service/agent_dispatch_edge_http_test.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch_edge_http_test.go @@ -1,4 +1,4 @@ -package service +package dispatchsvc // #1549 contract tests for the Hub→Edge dispatch client: URL/token must come // from the injected config (composition root), never from process env, and diff --git a/hub-server/internal/service/agent_dispatch_lifecycle.go b/hub-server/internal/service/dispatchsvc/agent_dispatch_lifecycle.go similarity index 99% rename from hub-server/internal/service/agent_dispatch_lifecycle.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch_lifecycle.go index ecfb6983d..a7357c637 100644 --- a/hub-server/internal/service/agent_dispatch_lifecycle.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch_lifecycle.go @@ -1,4 +1,4 @@ -package service +package dispatchsvc import ( "context" diff --git a/hub-server/internal/service/agent_dispatch_ports.go b/hub-server/internal/service/dispatchsvc/agent_dispatch_ports.go similarity index 80% rename from hub-server/internal/service/agent_dispatch_ports.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch_ports.go index fc08dc4c2..4512597b5 100644 --- a/hub-server/internal/service/agent_dispatch_ports.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch_ports.go @@ -1,7 +1,8 @@ -package service +package dispatchsvc import ( "context" + "encoding/json" "log/slog" "net/http" @@ -11,7 +12,6 @@ import ( "github.com/agenthub/hub-server/internal/config" "github.com/agenthub/hub-server/internal/metrics" "github.com/agenthub/hub-server/internal/service/dispatch" - "github.com/agenthub/hub-server/internal/ws" ) // dispatchPayload is a same-package alias for the pure dispatch.Payload DTO so @@ -65,11 +65,50 @@ type dispatchCache interface { PushPendingTargetTask(ctx context.Context, userID, targetID, deviceID, taskJSON string) error } -// dispatchWS is the WebSocket connection lookup/push port used for device-bound -// and inviter desktop dispatch. Implemented by *ws.Manager. -type dispatchWS interface { - FindByConnID(connID string) *ws.Conn - PushToConn(connID string, frame ws.Frame) ws.DeliveryResult +// ConnPort is the connection snapshot the dispatch flow needs: identity and +// device binding only. The service-layer adapter maps *ws.Conn onto it so the +// business layer never imports the transport package. +type ConnPort struct { + ID string + UserID string + DeviceType string + DeviceID string +} + +// FramePort is the wire frame the dispatch flow pushes. The service-layer +// adapter maps it onto ws.Frame; Type uses the same wire strings +// (frameTypeAgentDispatch mirrors ws.TypeAgentDispatch). +type FramePort struct { + Type string + Payload json.RawMessage +} + +// DeliveryResultPort reports the outcome of a PushToConn attempt. The adapter +// maps ws.DeliveryResult onto it (Queued/Status/Err only; ConnDrops is +// transport detail the dispatch flow does not consume). +type DeliveryResultPort struct { + Queued bool + Status string + Err error +} + +// ManagerPort is the WebSocket connection lookup/push port used for +// device-bound and inviter desktop dispatch. Implemented by the +// wsManagerAdapter in the service package over *ws.Manager. +type ManagerPort interface { + FindByConnID(connID string) *ConnPort + PushToConn(connID string, frame FramePort) DeliveryResultPort +} + +// frameTypeAgentDispatch mirrors ws.TypeAgentDispatch; the adapter translates +// between FramePort and the wire frame, so the string must stay in sync. +const frameTypeAgentDispatch = "agent.dispatch" + +// RelayPort is the hub_relay command dispatch port. The service layer adapts +// *RelayService (whose CreateCommand returns command metadata the dispatch +// flow does not consume). +type RelayPort interface { + CreateCommand(ctx context.Context, targetEdgeID, commandType string, payload json.RawMessage, createdBy string) error } // DispatchService owns agent task dispatch orchestration: trigger, payload build, @@ -80,9 +119,9 @@ type dispatchWS interface { type DispatchService struct { db *gorm.DB bus dispatchBus - mgr dispatchWS + mgr ManagerPort cacheClient dispatchCache - relay relayDispatcher + relay RelayPort outbox dispatchOutbox // edgeCfg is the Hub→Edge dispatch client config (#1549). Read once at // construction by the composition root; the request path never calls @@ -117,7 +156,7 @@ type DispatchService struct { // service layer never reads process env (#1549). edgeClient must come from // the composition root (outboundhttp.NewClient); nil is tolerated for tests // that never hit the edge HTTP path (#1594). -func NewDispatchService(db *gorm.DB, bus dispatchBus, mgr dispatchWS, cacheClient dispatchCache, relay relayDispatcher, outbox dispatchOutbox, edgeCfg config.EdgeDispatchConfig, edgeClient *http.Client, jwtSecret string) *DispatchService { +func NewDispatchService(db *gorm.DB, bus dispatchBus, mgr ManagerPort, cacheClient dispatchCache, relay RelayPort, outbox dispatchOutbox, edgeCfg config.EdgeDispatchConfig, edgeClient *http.Client, jwtSecret string) *DispatchService { return &DispatchService{ db: db, bus: bus, @@ -158,7 +197,7 @@ func (s *DispatchService) SetCache(cacheClient dispatchCache) { } // SetManager injects (or replaces) the WebSocket manager port. -func (s *DispatchService) SetManager(mgr dispatchWS) { +func (s *DispatchService) SetManager(mgr ManagerPort) { if !dispatch.ServiceReceiverAvailable(s != nil) { return } @@ -166,7 +205,7 @@ func (s *DispatchService) SetManager(mgr dispatchWS) { } // SetRelay injects (or replaces) the hub_relay command dispatcher port. -func (s *DispatchService) SetRelay(relay relayDispatcher) { +func (s *DispatchService) SetRelay(relay RelayPort) { if !dispatch.ServiceReceiverAvailable(s != nil) { return } diff --git a/hub-server/internal/service/agent_dispatch_target_bound.go b/hub-server/internal/service/dispatchsvc/agent_dispatch_target_bound.go similarity index 95% rename from hub-server/internal/service/agent_dispatch_target_bound.go rename to hub-server/internal/service/dispatchsvc/agent_dispatch_target_bound.go index ea1202cf3..4200ff9d1 100644 --- a/hub-server/internal/service/agent_dispatch_target_bound.go +++ b/hub-server/internal/service/dispatchsvc/agent_dispatch_target_bound.go @@ -1,4 +1,4 @@ -package service +package dispatchsvc import ( "context" @@ -9,7 +9,6 @@ import ( "github.com/agenthub/hub-server/internal/model" "github.com/agenthub/hub-server/internal/repository" "github.com/agenthub/hub-server/internal/service/dispatch" - "github.com/agenthub/hub-server/internal/ws" ) func (s *DispatchService) dispatchTargetBoundTask(ctx context.Context, cacheClient dispatchCache, task *model.PendingAgentTask, userID, deviceID string, payload []byte) bool { @@ -40,7 +39,7 @@ func (s *DispatchService) dispatchTargetBoundTask(ctx context.Context, cacheClie queueTargetTask(dispatch.TargetBoundReasonConnMismatch, nil) return false } - frame := ws.NewFrame(ws.TypeAgentDispatch, json.RawMessage(payload)) + frame := FramePort{Type: frameTypeAgentDispatch, Payload: json.RawMessage(payload)} if err := repository.UpdatePendingTaskDispatched(s.db, task.ID, deviceID); !dispatch.RepoUpdateSucceeded(err) { slog.Error(dispatch.DispatchLogTargetBoundMarkFailed, "task_id", task.ID, "user_id", userID, "target_id", task.TargetID, "device_id", deviceID, "error", err) return false diff --git a/hub-server/internal/service/dispatchsvc/cache_port.go b/hub-server/internal/service/dispatchsvc/cache_port.go new file mode 100644 index 000000000..fbb3e79c4 --- /dev/null +++ b/hub-server/internal/service/dispatchsvc/cache_port.go @@ -0,0 +1,32 @@ +package dispatchsvc + +import ( + "reflect" + + "github.com/agenthub/hub-server/internal/cache" +) + +// resolveDispatchCache validates the route / offline-queue cache port and +// falls back to cache.NoOpCache when nil is passed (for unit tests that do +// not exercise cache paths). Production code must inject a real *cache.Client. +func resolveDispatchCache(c dispatchCache) dispatchCache { + if isNilCache(c) { + return cache.NoOpCache{} + } + return c +} + +// isNilCache reports whether an interface holds nil (typed-nil pointers and +// untyped nil both count). +func isNilCache(c any) bool { + if c == nil { + return true + } + v := reflect.ValueOf(c) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} diff --git a/hub-server/internal/service/dispatchsvc/cache_port_test.go b/hub-server/internal/service/dispatchsvc/cache_port_test.go new file mode 100644 index 000000000..d462c88fd --- /dev/null +++ b/hub-server/internal/service/dispatchsvc/cache_port_test.go @@ -0,0 +1,23 @@ +package dispatchsvc + +import ( + "context" + "testing" + + "github.com/agenthub/hub-server/internal/cache" + "github.com/stretchr/testify/require" +) + +// TestResolveDispatchCacheNilFallback mirrors the service-layer cache-fallback +// contract: typed-nil and untyped-nil dispatch cache ports fall back to +// cache.NoOpCache. +func TestResolveDispatchCacheNilFallback(t *testing.T) { + ctx := context.Background() + + var typedNil *cache.Client + port := resolveDispatchCache(typedNil) + require.IsType(t, cache.NoOpCache{}, port) + _, err := port.GetRoute(ctx, "user-1", "desktop") + require.ErrorIs(t, err, cache.ErrCacheUnavailable) + require.ErrorIs(t, port.PushPendingTask(ctx, "user-1", "{}"), cache.ErrCacheUnavailable) +} diff --git a/hub-server/internal/service/dispatchsvc/doc.go b/hub-server/internal/service/dispatchsvc/doc.go new file mode 100644 index 000000000..0aa159bce --- /dev/null +++ b/hub-server/internal/service/dispatchsvc/doc.go @@ -0,0 +1,10 @@ +// Package dispatchsvc owns agent task dispatch orchestration: trigger, +// payload assembly, route classification (HTTP / WebSocket / offline queue / +// hub relay), capability minting, and the outbox redispatch residual. +// +// The package is transport-free: it depends on repository / cache / bus / +// metrics / service/dispatch (pure helpers) only, and expresses its WS and +// relay collaborators through local ports (ManagerPort / RelayPort) that the +// service layer adapts from ws.Manager and RelayService. It must never import +// internal/ws or the sibling service implementations. +package dispatchsvc diff --git a/hub-server/internal/service/dispatchsvc/ports_test.go b/hub-server/internal/service/dispatchsvc/ports_test.go new file mode 100644 index 000000000..1f60fcfc9 --- /dev/null +++ b/hub-server/internal/service/dispatchsvc/ports_test.go @@ -0,0 +1,139 @@ +package dispatchsvc + +import ( + "context" + "testing" + + "github.com/agenthub/hub-server/internal/bus" + "github.com/agenthub/hub-server/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── Port mocks (#617 residual, moved with the dispatch package) ───────────── + +type recordingDispatchBus struct { + events []bus.Event +} + +func (b *recordingDispatchBus) Publish(ctx context.Context, event bus.Event) error { + b.events = append(b.events, event) + return nil +} + +type recordingDispatchCache struct { + routes map[string]string + pushed []string +} + +func (c *recordingDispatchCache) GetRoute(ctx context.Context, userID, deviceType string) (string, error) { + if c.routes == nil { + return "", nil + } + return c.routes[userID+":"+deviceType], nil +} + +func (c *recordingDispatchCache) GetRouteForDevice(ctx context.Context, userID, deviceType, deviceID string) (string, error) { + if c.routes == nil { + return "", nil + } + return c.routes[userID+":"+deviceType+":"+deviceID], nil +} + +func (c *recordingDispatchCache) PushPendingTask(ctx context.Context, userID, taskJSON string) error { + c.pushed = append(c.pushed, userID+":"+taskJSON) + return nil +} + +func (c *recordingDispatchCache) PushPendingTargetTask(ctx context.Context, userID, targetID, deviceID, taskJSON string) error { + c.pushed = append(c.pushed, userID+":"+targetID+":"+deviceID+":"+taskJSON) + return nil +} + +type recordingDispatchWS struct { + conn *ConnPort + pushed int +} + +func (m *recordingDispatchWS) FindByConnID(connID string) *ConnPort { + if m.conn == nil || m.conn.ID != connID { + return nil + } + return m.conn +} + +func (m *recordingDispatchWS) PushToConn(connID string, frame FramePort) DeliveryResultPort { + m.pushed++ + return DeliveryResultPort{Queued: true, Status: "queued"} +} + +type recordingDispatchOutbox struct { + recorded int + marked int + dead int + lastError string +} + +func (o *recordingDispatchOutbox) RecordDelivery(ctx context.Context, taskID, payload, edgeDeviceID string) (string, error) { + o.recorded++ + return "deliv-1", nil +} + +func (o *recordingDispatchOutbox) MarkDeliverySent(ctx context.Context, deliveryID string) error { + o.marked++ + return nil +} + +func (o *recordingDispatchOutbox) MoveDeliveryToDeadLetter(ctx context.Context, deliveryID string, lastError string) error { + o.dead++ + o.lastError = lastError + return nil +} + +// ── Port tests ────────────────────────────────────────────────────────────── + +func TestDispatchService_NilBusPublishIsNoop(t *testing.T) { + svc := &DispatchService{} + // Must not panic when the bus port is unset (partial construction). + svc.publish(context.Background(), bus.Event{Type: "agent.cancel", Payload: "x"}) +} + +func TestDispatchService_NilOutboxWrappers(t *testing.T) { + svc := &DispatchService{} + _, err := svc.recordDelivery(context.Background(), "t1", "{}", "") + require.Error(t, err) + require.Contains(t, err.Error(), "dispatch outbox unavailable") + require.Error(t, svc.markDeliverySent(context.Background(), "d1")) + // dead-letter is a no-op when outbox is unset + svc.moveDeliveryToDeadLetter(context.Background(), "d1", "boom") +} + +func TestDispatchService_ConstructorPortsComposition(t *testing.T) { + b := &recordingDispatchBus{} + cachePort := &recordingDispatchCache{routes: map[string]string{"u1:desktop": "conn-1"}} + wsPort := &recordingDispatchWS{conn: &ConnPort{ID: "conn-1", UserID: "u1", DeviceType: "desktop", DeviceID: "dev-1"}} + outbox := &recordingDispatchOutbox{} + + svc := NewDispatchService(nil, b, wsPort, cachePort, nil, outbox, config.EdgeDispatchConfig{}, nil, "") + require.NotNil(t, svc) + + svc.publish(context.Background(), bus.Event{Type: "agent.regenerate", Payload: map[string]string{"k": "v"}}) + require.Len(t, b.events, 1) + assert.Equal(t, "agent.regenerate", b.events[0].Type) + + id, err := svc.recordDelivery(context.Background(), "task-1", `{"task_id":"task-1"}`, "dev-1") + require.NoError(t, err) + assert.Equal(t, "deliv-1", id) + require.NoError(t, svc.markDeliverySent(context.Background(), id)) + svc.moveDeliveryToDeadLetter(context.Background(), id, "hard-fail") + assert.Equal(t, 1, outbox.recorded) + assert.Equal(t, 1, outbox.marked) + assert.Equal(t, 1, outbox.dead) + assert.Equal(t, "hard-fail", outbox.lastError) + + got := svc.cachePort() + route, err := got.GetRoute(context.Background(), "u1", "desktop") + require.NoError(t, err) + assert.Equal(t, "conn-1", route) + assert.Same(t, wsPort.conn, svc.mgr.FindByConnID("conn-1")) +} diff --git a/scripts/verify/hub-lint-baseline.json b/scripts/verify/hub-lint-baseline.json index 2fa43b464..ac97f2f3a 100644 --- a/scripts/verify/hub-lint-baseline.json +++ b/scripts/verify/hub-lint-baseline.json @@ -14,8 +14,8 @@ }, { "linter": "gocognit", - "file": "internal/service/agent_dispatch.go", - "message": "cognitive complexity 98 of func `(*DispatchService).dispatchTask` is high (> 30)" + "file": "internal/service/dispatchsvc/agent_dispatch.go", + "message": "cognitive complexity 98 of func `(*DispatchService).DispatchTask` is high (> 30)" }, { "linter": "gocognit",