Skip to content
Open
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
42 changes: 42 additions & 0 deletions clients/go/ahp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,30 @@ type SubscriptionEventSessionSummaryChanged struct {

func (SubscriptionEventSessionSummaryChanged) isSubscriptionEvent() {}

// SubscriptionEventAutomationAdded mirrors the `root/automationAdded`
// notification.
type SubscriptionEventAutomationAdded struct {
Params ahptypes.AutomationAddedParams
}

func (SubscriptionEventAutomationAdded) isSubscriptionEvent() {}

// SubscriptionEventAutomationRemoved mirrors the
// `root/automationRemoved` notification.
type SubscriptionEventAutomationRemoved struct {
Params ahptypes.AutomationRemovedParams
}

func (SubscriptionEventAutomationRemoved) isSubscriptionEvent() {}

// SubscriptionEventAutomationSummaryChanged mirrors the
// `root/automationSummaryChanged` notification.
type SubscriptionEventAutomationSummaryChanged struct {
Params ahptypes.AutomationSummaryChangedParams
}

func (SubscriptionEventAutomationSummaryChanged) isSubscriptionEvent() {}

// SubscriptionEventAuthRequired mirrors the `auth/required`
// notification.
type SubscriptionEventAuthRequired struct {
Expand Down Expand Up @@ -499,6 +523,24 @@ func (c *Client) handleNotification(n ahptypes.JsonRpcNotification) {
return
}
c.fanOut(p.Channel, SubscriptionEventSessionSummaryChanged{Params: p})
case "root/automationAdded":
var p ahptypes.AutomationAddedParams
if err := json.Unmarshal(n.Params, &p); err != nil {
return
}
c.fanOut(p.Channel, SubscriptionEventAutomationAdded{Params: p})
case "root/automationRemoved":
var p ahptypes.AutomationRemovedParams
if err := json.Unmarshal(n.Params, &p); err != nil {
return
}
c.fanOut(p.Channel, SubscriptionEventAutomationRemoved{Params: p})
case "root/automationSummaryChanged":
var p ahptypes.AutomationSummaryChangedParams
if err := json.Unmarshal(n.Params, &p); err != nil {
return
}
c.fanOut(p.Channel, SubscriptionEventAutomationSummaryChanged{Params: p})
case "auth/required":
var p ahptypes.AuthRequiredParams
if err := json.Unmarshal(n.Params, &p); err != nil {
Expand Down
114 changes: 114 additions & 0 deletions clients/go/ahp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,120 @@ func TestClientSubscriptionFanOut(t *testing.T) {
}
}

func TestClientAutomationCatalogueNotifications(t *testing.T) {
clientSide, serverSide := newMemTransportPair()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
client, err := Connect(ctx, clientSide, DefaultConfig())
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer client.Shutdown(context.Background())

sub := client.AttachSubscription(ahptypes.RootResourceURI)
stream := client.Events()
automationURI := ahptypes.URI("ahp-automation:/nightly")

tests := []struct {
name string
method string
params any
check func(t *testing.T, event SubscriptionEvent)
}{
{
name: "added",
method: "root/automationAdded",
params: ahptypes.AutomationAddedParams{
Channel: ahptypes.RootResourceURI,
Summary: ahptypes.AutomationSummary{Resource: automationURI},
},
check: func(t *testing.T, event SubscriptionEvent) {
t.Helper()
added, ok := event.(SubscriptionEventAutomationAdded)
if !ok {
t.Fatalf("got %T, want SubscriptionEventAutomationAdded", event)
}
if added.Params.Summary.Resource != automationURI {
t.Errorf("resource = %q, want %q", added.Params.Summary.Resource, automationURI)
}
},
},
{
name: "removed",
method: "root/automationRemoved",
params: ahptypes.AutomationRemovedParams{
Channel: ahptypes.RootResourceURI,
Automation: automationURI,
},
check: func(t *testing.T, event SubscriptionEvent) {
t.Helper()
removed, ok := event.(SubscriptionEventAutomationRemoved)
if !ok {
t.Fatalf("got %T, want SubscriptionEventAutomationRemoved", event)
}
if removed.Params.Automation != automationURI {
t.Errorf("automation = %q, want %q", removed.Params.Automation, automationURI)
}
},
},
{
name: "summary changed",
method: "root/automationSummaryChanged",
params: ahptypes.AutomationSummaryChangedParams{
Channel: ahptypes.RootResourceURI,
Summary: ahptypes.AutomationSummary{Resource: automationURI},
},
check: func(t *testing.T, event SubscriptionEvent) {
t.Helper()
changed, ok := event.(SubscriptionEventAutomationSummaryChanged)
if !ok {
t.Fatalf("got %T, want SubscriptionEventAutomationSummaryChanged", event)
}
if changed.Params.Summary.Resource != automationURI {
t.Errorf("resource = %q, want %q", changed.Params.Summary.Resource, automationURI)
}
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params, err := json.Marshal(tt.params)
if err != nil {
t.Fatalf("marshal params: %v", err)
}
wire, err := EncodeMessage(ahptypes.JsonRpcMessage{Notification: &ahptypes.JsonRpcNotification{
JsonRpc: ahptypes.JsonRpcV2,
Method: tt.method,
Params: params,
}})
if err != nil {
t.Fatalf("encode notification: %v", err)
}
if err := serverSide.Send(ctx, wire); err != nil {
t.Fatalf("send notification: %v", err)
}

select {
case event := <-sub.Events():
tt.check(t, event)
case <-ctx.Done():
t.Fatal("subscription did not receive event")
}

select {
case event := <-stream.Events():
if event.Channel != ahptypes.RootResourceURI {
t.Errorf("channel = %q, want %q", event.Channel, ahptypes.RootResourceURI)
}
tt.check(t, event.Event)
case <-ctx.Done():
t.Fatal("top-level stream did not receive event")
}
})
}
}

// TestClientShutdownFailsInFlightRequest confirms a Shutdown unblocks
// any pending request with ErrShutdown.
func TestClientShutdownFailsInFlightRequest(t *testing.T) {
Expand Down
67 changes: 52 additions & 15 deletions clients/go/ahp/hosts/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ type HostHandle struct {
ClientID string
State HostState
ProtocolVersion string
Automations *ahptypes.AutomationCapabilities
Agents []ahptypes.AgentInfo
Sessions []ahptypes.SessionSummary
Terminals []ahptypes.TerminalInfo
Expand Down Expand Up @@ -400,21 +401,22 @@ var ErrDuplicateHost = errors.New("hosts: host id already registered")

// hostState is the per-host bookkeeping the multi-host runtime owns.
type hostState struct {
id HostID
label string
cfg HostConfig
mu sync.RWMutex
client *ahp.Client
state HostState
clientID string
protoVer string
agents []ahptypes.AgentInfo
sessions []ahptypes.SessionSummary
terminals []ahptypes.TerminalInfo
updatedAt time.Time
generation uint64
cancel context.CancelFunc
supervised sync.WaitGroup
id HostID
label string
cfg HostConfig
mu sync.RWMutex
client *ahp.Client
state HostState
clientID string
protoVer string
automations *ahptypes.AutomationCapabilities
agents []ahptypes.AgentInfo
sessions []ahptypes.SessionSummary
terminals []ahptypes.TerminalInfo
updatedAt time.Time
generation uint64
cancel context.CancelFunc
supervised sync.WaitGroup
}

// MultiHostClient is the public multi-host registry + reconnect
Expand Down Expand Up @@ -573,6 +575,7 @@ func (m *MultiHostClient) openHost(ctx context.Context, hs *hostState) error {
hs.mu.Lock()
hs.client = client
hs.protoVer = result.ProtocolVersion
hs.automations = cloneAutomationCapabilities(result.Automations)
hs.generation++
hs.mu.Unlock()

Expand Down Expand Up @@ -716,13 +719,47 @@ func (m *MultiHostClient) snapshotHandle(hs *hostState) *HostHandle {
ClientID: hs.clientID,
State: hs.state,
ProtocolVersion: hs.protoVer,
Automations: cloneAutomationCapabilities(hs.automations),
Agents: append([]ahptypes.AgentInfo(nil), hs.agents...),
Sessions: append([]ahptypes.SessionSummary(nil), hs.sessions...),
Terminals: append([]ahptypes.TerminalInfo(nil), hs.terminals...),
UpdatedAt: hs.updatedAt,
}
}

func cloneAutomationCapabilities(capabilities *ahptypes.AutomationCapabilities) *ahptypes.AutomationCapabilities {
if capabilities == nil {
return nil
}

clone := *capabilities
if capabilities.Create != nil {
value := *capabilities.Create
clone.Create = &value
}
if capabilities.Schedules != nil {
value := *capabilities.Schedules
if capabilities.Schedules.MinIntervalMinutes != nil {
minIntervalMinutes := *capabilities.Schedules.MinIntervalMinutes
value.MinIntervalMinutes = &minIntervalMinutes
}
clone.Schedules = &value
}
if capabilities.RunCancellation != nil {
value := *capabilities.RunCancellation
clone.RunCancellation = &value
}
if capabilities.SchedulePreview != nil {
value := *capabilities.SchedulePreview
clone.SchedulePreview = &value
}
if capabilities.RunHistoryLimit != nil {
value := *capabilities.RunHistoryLimit
clone.RunHistoryLimit = &value
}
return &clone
}

// ClientHandle returns a generation-checked [HostClientHandle] for
// the named host, or ErrUnknownHost if the host is not registered.
func (m *MultiHostClient) ClientHandle(id HostID) (*HostClientHandle, error) {
Expand Down
73 changes: 72 additions & 1 deletion clients/go/ahp/hosts/hosts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ func (t *fakeTransport) Close(_ context.Context) error {
// runFakeServer responds to one Initialize request with a stub
// InitializeResult. It exits when the transport closes.
func runFakeServer(t *testing.T, serverSide *fakeTransport) {
runFakeServerWithInitializeResult(t, serverSide, ahptypes.InitializeResult{
ProtocolVersion: ahptypes.ProtocolVersion,
})
}

func runFakeServerWithInitializeResult(t *testing.T, serverSide *fakeTransport, initializeResult ahptypes.InitializeResult) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
Expand All @@ -82,7 +88,7 @@ func runFakeServer(t *testing.T, serverSide *fakeTransport) {
continue
}
if parsed.Request.Method == "initialize" {
result, _ := json.Marshal(ahptypes.InitializeResult{ProtocolVersion: ahptypes.ProtocolVersion})
result, _ := json.Marshal(initializeResult)
resp := ahptypes.JsonRpcMessage{SuccessResponse: &ahptypes.JsonRpcSuccessResponse{
JsonRpc: ahptypes.JsonRpcV2,
ID: parsed.Request.ID,
Expand All @@ -94,6 +100,71 @@ func runFakeServer(t *testing.T, serverSide *fakeTransport) {
}
}

func TestAutomationCapabilitiesUpdatedAcrossReconnect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

multi := NewMultiHostClient()
defer multi.Shutdown(context.Background())

servers := make(chan *fakeTransport, 2)
attempt := 0
cfg := NewHostConfig("automation-host", "Automation Host", func(_ context.Context, _ HostID) (ahp.Transport, error) {
attempt++
clientSide, serverSide := newFakePair()
lifetime := ahptypes.AutomationExecutionLifetimeHostLifetime
if attempt > 1 {
lifetime = ahptypes.AutomationExecutionLifetimeManaged
}
go runFakeServerWithInitializeResult(t, serverSide, ahptypes.InitializeResult{
ProtocolVersion: ahptypes.ProtocolVersion,
Automations: &ahptypes.AutomationCapabilities{
Execution: ahptypes.AutomationExecutionCapabilities{Lifetime: lifetime},
},
})
servers <- serverSide
return clientSide, nil
})
cfg.ReconnectPolicy = ReconnectPolicy{
MaxAttempts: 2,
InitialBackoff: time.Millisecond,
MaxBackoff: time.Millisecond,
BackoffMultiplier: 1,
ResetOnSuccess: true,
}

handle, err := multi.AddHost(ctx, cfg)
if err != nil {
t.Fatalf("AddHost: %v", err)
}
if handle.Automations == nil {
t.Fatal("initial Automations is nil")
}
if got := handle.Automations.Execution.Lifetime; got != ahptypes.AutomationExecutionLifetimeHostLifetime {
t.Fatalf("initial lifetime = %q, want %q", got, ahptypes.AutomationExecutionLifetimeHostLifetime)
}

firstServer := <-servers
if err := firstServer.Close(ctx); err != nil {
t.Fatalf("close first server: %v", err)
}

for {
handle = multi.Host(cfg.ID)
if handle != nil &&
handle.State.Kind == HostStateConnected &&
handle.Automations != nil &&
handle.Automations.Execution.Lifetime == ahptypes.AutomationExecutionLifetimeManaged {
break
}
select {
case <-ctx.Done():
t.Fatal("automation capabilities were not updated after reconnect")
case <-time.After(time.Millisecond):
}
}
}

// TestSingleHostHandshake exercises the [Single] one-line constructor
// against a fake server and confirms the host transitions to the
// Connected state with a populated protocol version.
Expand Down
Loading
Loading