Add connected agents to AI governance metrics

What does this MR do and why?

The AI Governance dashboard has a "Show All agents | DAP | Connected" control. It segments the KPI tiles, and on the backend the rankings, via agentClass on aiGovernanceMetrics. The AI agent inventory card lists configured catalog agents from aiCatalogConfiguredItems. Those are Duo Agent Platform (DAP) agents by definition.

Connected agents are external tools, such as Claude Code or OpenCode, that register a machine identity and open sessions through the external agents API. There was no API the dashboard could call to list them. This MR adds that data source.

It adds a connectedAgents(limit) field on AiGovernanceMetrics, of type AiGovernanceConnectedAgent. One row per registered external agent type in the container (a project, or a group hierarchy through its projects). Fields: agentType (claude-code, opencode), identityCount (registered machines, revoked included), activeCount, revokedCount, userCount (distinct users), sessionCount (sessions opened by those machines in the selected timeframe), and lastSessionAt (all-time, nullable). Rows are ordered by identityCount descending, then agentType. limit defaults to 5 and maxes at 20, matching the existing topUsers/topProjects constants.

The field is lookahead-gated like the rankings, so nothing runs unless it is selected. It returns [] for agentClass: INTERNAL_DAP. ALL and EXTERNAL return the list. Authorization is inherited from the parent field (read_agent_artifacts). It runs behind the existing ai_governance_dashboard flag. No new flag.

On the backend, two new class methods do the work: Ai::ExternalAgents::AgentIdentity.registration_counts_by_agent_type (grouped counts over ai_agent_identities, with new scopes in_namespace_hierarchy and with_agent_types), and Ai::DuoWorkflows::Workflow.session_activity_by_agent_type (grouped over workflows filtered by agent_identity_id, with a new scope for_agent_identities). Ai::Governance::MetricsService runs them after the metrics backend call and merges the rows into the payload.

Identities are not replicated to ClickHouse, so this path is PostgreSQL only. It runs the same way on ClickHouse-backed instances.

Frontend changes to the inventory card are a separate MR.

References

Screenshots or screen recordings

The inventory card below uses a throwaway frontend integration (not committed) to show the field driving the card under each toggle state. The real frontend change for the card ships in a separate MR. Demo group with 4 registered Claude Code machines (1 revoked, 3 users) and 2 OpenCode machines.

All agents Connected DAP
connected-agents-ALL connected-agents-CONNECTED connected-agents-DAP

How to set up and validate locally

  1. Register an identity and open a session for it, in a project of a group with ai_governance_dashboard enabled:
project = Project.find_by_full_path('<group>/<project>')
identity = Ai::ExternalAgents::AgentIdentity.create!(user: User.first, project: project, agent_type: 'claude-code', machine_fingerprint: SecureRandom.hex(32))
Ai::DuoWorkflows::Workflow.last.dup.tap { |w| w.assign_attributes(project: project, namespace: nil, user: identity.user, agent_type: 'claude-code', agent_identity_id: identity.id, created_at: 1.day.ago); w.save!(validate: false) }
  1. Run the query:
query = <<~GQL
  { group(fullPath: "<group>") { aiGovernanceMetrics(timeframe: LAST_7_DAYS) {
      connectedAgents { agentType identityCount activeCount revokedCount userCount sessionCount lastSessionAt } } } }
GQL
GitlabSchema.execute(query, context: { current_user: User.first }).to_h.dig('data', 'group', 'aiGovernanceMetrics', 'connectedAgents')
  1. Expect one claude-code row, with identityCount 1 and sessionCount 1. Repeat with agentClass: INTERNAL_DAP and expect []. On master the field does not exist yet, and the query returns a GraphQL error.

Tests: 91 examples, 0 failures, across the service spec (hierarchy scoping, revoked handling, timeframe vs all-time, limit, project container, empty container, INTERNAL_DAP short-circuit, two-query budget), the request spec (group and project, default/EXTERNAL/INTERNAL_DAP, LAST_24_HOURS window, limit validation, unselected field passes connected_agents_limit: nil), type specs, the resolver spec, and the agent identity model spec. RuboCop is clean. GraphQL reference docs and introspection are regenerated.

Database

No migrations. This MR adds two new query shapes, both executed only when connectedAgents is selected, and a QueryRecorder spec verifies two queries per request. The identities query filters by project_id and uses idx_ai_agent_identities_on_project_id. The workflows query filters by agent_identity_id and uses the partial index index_duo_workflows_workflows_on_agent_identity_id. Both queries group by agent_type, which has only two possible values, so the result sets stay tiny.

Plans were captured on postgres.ai against seeded rows, since ai_agent_identities has no production rows yet: gitlab-org (9970) and gitlab-org/gitlab (278964). Three identities and 50 sessions were inserted in the clone before running EXPLAIN. Every query finished in about 1 ms with under 100 buffers.

Raw SQL and plans

Q1 registration counts by agent type | container: group (gitlab-org 9970) | limit 5

SELECT "ai_agent_identities"."agent_type", COUNT(*), COUNT(*) FILTER (WHERE revoked_at IS NULL), COUNT(DISTINCT user_id) FROM "ai_agent_identities" WHERE "ai_agent_identities"."project_id" IN (SELECT "projects"."id" FROM "projects" WHERE "projects"."namespace_id" IN (SELECT "namespaces"."id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}')))) GROUP BY "ai_agent_identities"."agent_type" ORDER BY COUNT(*) DESC, "ai_agent_identities"."agent_type" ASC LIMIT 5;

Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main-v2/sessions/56219/commands/160360 (1.0 ms)

Q2 session activity by agent type | container: group (gitlab-org 9970)

SELECT "duo_workflows_workflows"."agent_type", COUNT(*) FILTER (WHERE created_at >= '2026-08-27 00:00:00' AND created_at < '2026-09-03 00:00:00'), MAX(created_at) FROM "duo_workflows_workflows" WHERE "duo_workflows_workflows"."agent_identity_id" IN (SELECT "ai_agent_identities"."id" FROM "ai_agent_identities" WHERE "ai_agent_identities"."project_id" IN (SELECT "projects"."id" FROM "projects" WHERE "projects"."namespace_id" IN (SELECT "namespaces"."id" FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND (traversal_ids @> ('{9970}')))) AND "ai_agent_identities"."agent_type" IN ('claude-code', 'opencode')) GROUP BY "duo_workflows_workflows"."agent_type";

Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main-v2/sessions/56219/commands/160361 (0.5 ms)

Q1 registration counts by agent type | container: project (gitlab-org/gitlab 278964) | limit 5

SELECT "ai_agent_identities"."agent_type", COUNT(*), COUNT(*) FILTER (WHERE revoked_at IS NULL), COUNT(DISTINCT user_id) FROM "ai_agent_identities" WHERE "ai_agent_identities"."project_id" = 278964 GROUP BY "ai_agent_identities"."agent_type" ORDER BY COUNT(*) DESC, "ai_agent_identities"."agent_type" ASC LIMIT 5;

Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main-v2/sessions/56219/commands/160365 (0.1 ms)

Q2 session activity by agent type | container: project (gitlab-org/gitlab 278964)

SELECT "duo_workflows_workflows"."agent_type", COUNT(*) FILTER (WHERE created_at >= '2026-08-27 00:00:00' AND created_at < '2026-09-03 00:00:00'), MAX(created_at) FROM "duo_workflows_workflows" WHERE "duo_workflows_workflows"."agent_identity_id" IN (SELECT "ai_agent_identities"."id" FROM "ai_agent_identities" WHERE "ai_agent_identities"."project_id" = 278964 AND "ai_agent_identities"."agent_type" IN ('claude-code', 'opencode')) GROUP BY "duo_workflows_workflows"."agent_type";

Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main-v2/sessions/56219/commands/160367 (0.4 ms)

MR acceptance checklist

Checked against https://docs.gitlab.com/development/code_review/#acceptance-checklist: this change adds no new user-facing strings and no new feature flag, and is covered by the tests listed above.

Edited by Andrew Jung

Merge request reports

Loading
Loading