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
- Epic: https://gitlab.com/groups/gitlab-org/-/epics/18973 ("AI Governance Dashboard & Compliance Reporting (beta)")
- Related MR (wires the ranking cards): !251038 (merged)
- Related MR (
agentClasson the audit list): !251518 (merged) - External agents registration API: !245529 (merged)
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 |
|---|---|---|
![]() |
![]() |
![]() |
How to set up and validate locally
- Register an identity and open a session for it, in a project of a group with
ai_governance_dashboardenabled:
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) }- 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')- Expect one
claude-coderow, withidentityCount1 andsessionCount1. Repeat withagentClass: INTERNAL_DAPand expect[]. Onmasterthe 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.


