package service import ( "fmt" edgenode "iop/apps/edge/internal/node" "iop/packages/go/config" iop "iop/proto/gen/iop" ) // NodeSnapshot is a surface-neutral view of a registered node, suitable for // CLI, HTTP, or other transports. Connected reports whether the node is // currently attached to the live registry; configured records that have never // connected (or whose connection has dropped) still appear with Connected=false // so the snapshot source of truth is the configured NodeStore, not the live // registry. // Label is the short display label (node0, node1). AgentKind and // LifecycleState carry the registry entry's generic node classification and // lifecycle when present; for configured-only records they are empty. type NodeSnapshot struct { NodeID string Alias string Label string AgentKind string LifecycleState string Connected bool Config *iop.NodeConfigPayload ProviderSnapshots []*iop.ProviderSnapshot } // ListNodeSnapshots returns surface-neutral node descriptors derived from the // current registry contents. For nodes backed by a queue manager, the entire // snapshot is built inside a single queue-held critical section so that // runtime config, queue state, and candidate pressure are observed atomically // under the same lock order (queue → service) that SetRuntimeConfig follows. // Nodes without a queue fall back to the catalog-only path under s.mu. func (s *Service) ListNodeSnapshots() []NodeSnapshot { if s.queue == nil { return s.buildSnapshotNoQueue() } // Queue-backed: acquire the queue lock for the entire snapshot build so // the runtime config, resource state, and candidate pressure are captured // as a single consistent old-or-new tuple (no torn mixed state). s.queue.mu.Lock() defer s.queue.mu.Unlock() return s.buildSnapshotLocked() } // buildSnapshotNoQueue builds node snapshots using the configured NodeStore // catalog as the identity source, joined against the current Registry to // determine live connectivity. Nodes that are configured but not currently // connected still appear with Connected=false so the snapshot never silently // drops offline records. When no node store is configured the builder falls // back to the live registry so callers without a store still receive snapshots. func (s *Service) buildSnapshotNoQueue() []NodeSnapshot { store, _, _ := s.runtimeConfigSnapshot() if store == nil { return s.buildSnapshotFromRegistry() } records := store.All() out := make([]NodeSnapshot, 0, len(records)) for _, rec := range records { snap := s.emptySnapshotForConfigured(rec) if r, ok := store.FindByID(rec.ID); ok { if payload, err := edgenode.BuildConfigPayload(r); err == nil { snap.Config = payload } if len(r.Providers) > 0 { snap.ProviderSnapshots = staticProviderCatalogSnapshots(r, snap.Connected) } } out = append(out, snap) } return out } // buildSnapshotLocked builds node snapshots under the assumption that the // caller already holds s.queue.mu. ListNodeSnapshots uses this variant to keep // runtime config and queue state inside one queue-held critical section. The // identity source is the configured NodeStore catalog joined with current // Registry connectivity; the ordering is deterministic across calls. When no // node store is configured the builder falls back to the live registry so // callers without a store still receive snapshots. func (s *Service) buildSnapshotLocked() []NodeSnapshot { store, _, _ := s.runtimeConfigSnapshot() if store == nil { return s.buildSnapshotFromRegistry() } records := store.All() out := make([]NodeSnapshot, 0, len(records)) for _, rec := range records { snap := s.emptySnapshotForConfigured(rec) if r, ok := store.FindByID(rec.ID); ok { if payload, err := edgenode.BuildConfigPayload(r); err == nil { snap.Config = payload } snap.ProviderSnapshots = s.queue.getSnapshotForNodeLocked(rec.ID, r, snap.Connected) } out = append(out, snap) } return out } // buildSnapshotFromRegistry is the legacy catalog path used when no node store // is configured. It preserves snapshot output for callers that build a Service // without SetNodeStore. func (s *Service) buildSnapshotFromRegistry() []NodeSnapshot { entries := s.registry.AllReady() out := make([]NodeSnapshot, 0, len(entries)) for _, entry := range entries { out = append(out, s.emptySnapshot(entry)) } return out } // emptySnapshotForConfigured builds a NodeSnapshot from a configured NodeRecord. // If registry has a live entry for the same NodeID it is merged in so Alias, // Label, AgentKind, LifecycleState reflect the current connection; otherwise // the snapshot is labeled from the configured ID/index and Connected is false. func (s *Service) emptySnapshotForConfigured(rec *edgenode.NodeRecord) NodeSnapshot { snap := NodeSnapshot{ NodeID: rec.ID, Alias: rec.Alias, Connected: false, } // GetReady, not Get: a pending accepted connection has not signalled dispatch // readiness, so it is reported offline until its node applies config and // installs its handler. This keeps "connected" aligned with "dispatchable". if entry, ok := s.registry.GetReady(rec.ID); ok { snap.Connected = true snap.Label = entry.DisplayLabel() snap.AgentKind = entry.AgentKind snap.LifecycleState = entry.LifecycleState } else { snap.Label = configuredLabel(rec) } return snap } // configuredLabel builds a display label from a configured NodeRecord when no // live registry entry is present. Uses index if available, otherwise alias, // otherwise node ID. func configuredLabel(rec *edgenode.NodeRecord) string { if rec.Index >= 0 { return fmt.Sprintf("node%d", rec.Index) } if rec.Alias != "" { return rec.Alias } return rec.ID } // emptySnapshot returns a NodeSnapshot populated from the registry entry. Used // for registry-only (non-catalog) lookups such as ResolveNodeSnapshot. func (s *Service) emptySnapshot(entry *edgenode.NodeEntry) NodeSnapshot { return NodeSnapshot{ NodeID: entry.NodeID, Alias: entry.Alias, Label: entry.DisplayLabel(), AgentKind: entry.AgentKind, LifecycleState: entry.LifecycleState, Connected: true, } } // NodeEntrySnapshot converts a registry entry into the surface-neutral DTO. func NodeEntrySnapshot(entry *edgenode.NodeEntry) NodeSnapshot { if entry == nil { return NodeSnapshot{} } return NodeSnapshot{ NodeID: entry.NodeID, Alias: entry.Alias, Label: entry.DisplayLabel(), AgentKind: entry.AgentKind, LifecycleState: entry.LifecycleState, Connected: true, } } // staticProviderCatalogSnapshots builds catalog-based ProviderSnapshots for // nodes whose service has no queue. The catalog entry (id/adapter/type/category/models) // is always preserved so operators can see configured providers. When connected // is false, effective status/health/capacity/longContext counters are dropped // to unavailable/offline/0 so the snapshot reflects disconnect without removing // the configured provider from the list. disabled providers keep status/health=disabled // regardless of connectivity. func staticProviderCatalogSnapshots(rec *edgenode.NodeRecord, connected bool) []*iop.ProviderSnapshot { snaps := make([]*iop.ProviderSnapshot, 0, len(rec.Providers)) for _, prov := range rec.Providers { if prov.ID == "" { continue } servedModels := make([]string, len(prov.Models)) copy(servedModels, prov.Models) lifecycleCaps := make([]string, len(prov.LifecycleCapabilities)) copy(lifecycleCaps, prov.LifecycleCapabilities) if !config.ProviderEnabled(prov) { snaps = append(snaps, &iop.ProviderSnapshot{ Adapter: prov.Adapter, Status: "disabled", Capacity: 0, Id: prov.ID, Type: prov.Type, Category: string(prov.Category), ServedModels: servedModels, Health: "disabled", LifecycleCapabilities: lifecycleCaps, LongContextCapacity: 0, LongInFlight: 0, LongQueued: 0, }) continue } snaps = append(snaps, &iop.ProviderSnapshot{ Adapter: prov.Adapter, Status: effectiveStatus(connected), Health: effectiveHealth(connected, prov.Health), Capacity: int32(effectiveCount(connected, prov.Capacity)), Id: prov.ID, Type: prov.Type, Category: string(prov.Category), ServedModels: servedModels, LifecycleCapabilities: lifecycleCaps, LongContextCapacity: int32(effectiveCount(connected, prov.LongContextCapacity)), LongInFlight: 0, LongQueued: 0, }) } return snaps } func (s *Service) ResolveNode(ref string) (*edgenode.NodeEntry, error) { return s.registry.Resolve(ref) } // ResolveDispatchReady resolves ref exactly like ResolveNode but only ever returns a // dispatch-ready entry. func (s *Service) ResolveDispatchReady(ref string) (*edgenode.NodeEntry, error) { return s.registry.ResolveReady(ref) } // ResolveNodeSnapshot returns a surface-neutral DTO for the resolved node. func (s *Service) ResolveNodeSnapshot(ref string) (NodeSnapshot, error) { entry, err := s.ResolveNode(ref) if err != nil { return NodeSnapshot{}, err } return NodeEntrySnapshot(entry), nil } func (s *Service) GetCapabilities() []*iop.EdgeCapabilitySummary { var entries []*edgenode.NodeEntry if s.registry != nil { entries = s.registry.AllReady() } if len(entries) == 0 { return nil } return []*iop.EdgeCapabilitySummary{ { Kind: "run", Available: true, Status: "ready", Summary: "Generic execution node available", }, } } func (s *Service) GetDomainAgents() []*iop.EdgeDomainAgentSummary { return nil } // HealthStatusView is the Edge-owned status summary used by control commands // such as health.check. It is derived from the live node registry and // capability snapshot rather than a fixed simulation delay. type HealthStatusView struct { Nodes []NodeSnapshot Capabilities []*iop.EdgeCapabilitySummary } // HealthStatus returns a status summary derived from the current node registry // and capability snapshot. It is the real Edge-owned boundary that backs the // health.check control command. func (s *Service) HealthStatus() HealthStatusView { return HealthStatusView{ Nodes: s.ListNodeSnapshots(), Capabilities: s.GetCapabilities(), } }